Add local implementation of Zotero web API (#4270)
This required some tweaks to other parts of Zotero infrastructure: - Search: - Add 'includeDeleted' condition to match behavior of 'includeTrashed' API parameter in a single search - Data objects: - Improve toResponseJSON() implementations so output better matches the web API - Add toResponseJSON() to Zotero.Tags - has to be async so it can query the database and generally works differently from other toResponseJSON() functions, but accomplishes the same task - Remove unused getAPIData() and apiDataGenerator() DataObject functions. They aren't functional and wouldn't really make implementing the local server easier, so now seemed like a decent time to remove them - Server: - Support resolving routes using pathparser.jsm - Add allowMissingParams option to PathParser#add(): prevents /route from matching /route/:param - Replace the query property of the data object sent to endpoint init()s with searchParams, an instance of URLSearchParams - supports #getAll() for repeatable parameters - URIs: - Make getObjectURI() public, add utilities for converting URIs to API endpoints and web library URLs
This commit is contained in:
parent
fe47cbe805
commit
44d9530ecf
17 changed files with 1202 additions and 109 deletions
|
@ -1434,7 +1434,7 @@ Zotero.Server.Connector.Import.prototype = {
|
|||
}
|
||||
|
||||
try {
|
||||
var session = Zotero.Server.Connector.SessionManager.create(requestData.query.session);
|
||||
var session = Zotero.Server.Connector.SessionManager.create(requestData.searchParams.get('session'));
|
||||
}
|
||||
catch (e) {
|
||||
return [409, "application/json", JSON.stringify({ error: "SESSION_EXISTS" })];
|
||||
|
@ -1473,7 +1473,7 @@ Zotero.Server.Connector.InstallStyle.prototype = {
|
|||
init: Zotero.Promise.coroutine(function* (requestData) {
|
||||
try {
|
||||
var { styleTitle, styleID } = yield Zotero.Styles.install(
|
||||
requestData.data, requestData.query.origin || null, true
|
||||
requestData.data, requestData.searchParams.get('origin') || null, true
|
||||
);
|
||||
} catch (e) {
|
||||
return [400, "text/plain", e.message];
|
||||
|
|
|
@ -716,6 +716,20 @@ Zotero.Collection.prototype.serialize = function(nested) {
|
|||
}
|
||||
|
||||
|
||||
Zotero.Collection.prototype.toResponseJSON = function (options = {}) {
|
||||
let json = this.constructor._super.prototype.toResponseJSON.call(this, options);
|
||||
json.meta.numCollections = this.getChildCollections(true).length;
|
||||
json.meta.numItems = this.getChildItems(true).length;
|
||||
if (this.parentID) {
|
||||
json.links.up = {
|
||||
href: Zotero.URI.toAPIURL(Zotero.URI.getCollectionURI(Zotero.Collections.get(this.parentID)), options.apiURL),
|
||||
type: 'application/json'
|
||||
};
|
||||
}
|
||||
return json;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Populate the object's data from an API JSON data object
|
||||
*
|
||||
|
|
|
@ -1325,19 +1325,35 @@ Zotero.DataObject.prototype._finalizeErase = Zotero.Promise.coroutine(function*
|
|||
|
||||
|
||||
Zotero.DataObject.prototype.toResponseJSON = function (options = {}) {
|
||||
// TODO: library block?
|
||||
|
||||
let uri = Zotero.URI.getObjectURI(this);
|
||||
var json = {
|
||||
key: this.key,
|
||||
version: this.version,
|
||||
library: this.library.toResponseJSON({ ...options, includeGroupDetails: false }),
|
||||
links: {
|
||||
self: {
|
||||
href: Zotero.URI.toAPIURL(uri, options.apiURL),
|
||||
type: 'application/json'
|
||||
},
|
||||
alternate: {
|
||||
href: Zotero.URI.toWebURL(uri),
|
||||
type: 'text/html'
|
||||
}
|
||||
},
|
||||
meta: {},
|
||||
data: this.toJSON(options)
|
||||
};
|
||||
if (options.version) {
|
||||
json.version = json.data.version = options.version;
|
||||
}
|
||||
if (this.parentID) {
|
||||
json.links.up = {
|
||||
href: Zotero.URI.toAPIURL(Zotero.URI.getObjectURI(this.ObjectsClass.get(this.parentID)), options.apiURL),
|
||||
type: 'application/json'
|
||||
};
|
||||
}
|
||||
return json;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Zotero.DataObject.prototype._preToJSON = function (options) {
|
||||
|
|
|
@ -239,6 +239,40 @@ Zotero.Group.prototype._finalizeErase = Zotero.Promise.coroutine(function* (env)
|
|||
yield Zotero.Group._super.prototype._finalizeErase.call(this, env);
|
||||
});
|
||||
|
||||
Zotero.Group.prototype.toResponseJSON = function (options = {}) {
|
||||
if (options.includeGroupDetails) {
|
||||
let uri = Zotero.URI.getGroupURI(this);
|
||||
return {
|
||||
id: this.id,
|
||||
version: this.version,
|
||||
links: {
|
||||
self: {
|
||||
href: Zotero.URI.toAPIURL(uri, options.apiURL),
|
||||
type: 'application/json'
|
||||
},
|
||||
alternate: {
|
||||
href: Zotero.URI.toWebURL(uri),
|
||||
type: 'text/html'
|
||||
}
|
||||
},
|
||||
meta: {
|
||||
// created
|
||||
// lastModified
|
||||
// numItems
|
||||
},
|
||||
data: {
|
||||
id: this.id,
|
||||
version: this.version,
|
||||
name: this.name,
|
||||
description: this.description
|
||||
}
|
||||
};
|
||||
}
|
||||
else {
|
||||
return Zotero.Group._super.prototype.toResponseJSON.call(this, options);
|
||||
}
|
||||
};
|
||||
|
||||
Zotero.Group.prototype.fromJSON = function (json, userID) {
|
||||
if (json.name !== undefined) this.name = json.name;
|
||||
if (json.description !== undefined) this.description = json.description;
|
||||
|
|
|
@ -5499,6 +5499,15 @@ Zotero.Item.prototype.toResponseJSON = function (options = {}) {
|
|||
if (this.isRegularItem()) {
|
||||
json.meta.numChildren = this.numChildren();
|
||||
}
|
||||
|
||||
if (this.isImportedAttachment()) {
|
||||
json.links.enclosure = {
|
||||
href: this.getLocalFileURL(),
|
||||
type: this.attachmentContentType,
|
||||
title: this.attachmentFilename
|
||||
};
|
||||
}
|
||||
|
||||
return json;
|
||||
};
|
||||
|
||||
|
|
|
@ -139,67 +139,6 @@ Zotero.Items = function() {
|
|||
});
|
||||
|
||||
|
||||
/**
|
||||
* Return item data in web API format
|
||||
*
|
||||
* var data = Zotero.Items.getAPIData(0, 'collections/NF3GJ38A/items');
|
||||
*
|
||||
* @param {Number} libraryID
|
||||
* @param {String} [apiPath='items'] - Web API style
|
||||
* @return {Promise<String>}.
|
||||
*/
|
||||
this.getAPIData = Zotero.Promise.coroutine(function* (libraryID, apiPath) {
|
||||
var gen = this.getAPIDataGenerator(...arguments);
|
||||
var data = "";
|
||||
while (true) {
|
||||
var result = gen.next();
|
||||
if (result.done) {
|
||||
break;
|
||||
}
|
||||
var val = yield result.value;
|
||||
if (typeof val == 'string') {
|
||||
data += val;
|
||||
}
|
||||
else if (val === undefined) {
|
||||
continue;
|
||||
}
|
||||
else {
|
||||
throw new Error("Invalid return value from generator");
|
||||
}
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Zotero.Utilities.Internal.getAsyncInputStream-compatible generator that yields item data
|
||||
* in web API format as strings
|
||||
*
|
||||
* @param {Object} params - Request parameters from Zotero.API.parsePath()
|
||||
*/
|
||||
this.apiDataGenerator = function* (params) {
|
||||
Zotero.debug(params);
|
||||
var s = new Zotero.Search;
|
||||
s.addCondition('libraryID', 'is', params.libraryID);
|
||||
if (params.scopeObject == 'collections') {
|
||||
s.addCondition('collection', 'is', params.scopeObjectKey);
|
||||
}
|
||||
s.addCondition('title', 'contains', 'test');
|
||||
var ids = yield s.search();
|
||||
|
||||
yield '[\n';
|
||||
|
||||
for (let i=0; i<ids.length; i++) {
|
||||
let prefix = i > 0 ? ',\n' : '';
|
||||
let item = yield this.getAsync(ids[i], { noCache: true });
|
||||
var json = item.toResponseJSON();
|
||||
yield prefix + JSON.stringify(json, null, 4);
|
||||
}
|
||||
|
||||
yield '\n]';
|
||||
};
|
||||
|
||||
|
||||
//
|
||||
// Bulk data loading functions
|
||||
//
|
||||
|
|
|
@ -678,6 +678,25 @@ Zotero.Library.prototype._finalizeErase = Zotero.Promise.coroutine(function* (en
|
|||
this._disabled = true;
|
||||
});
|
||||
|
||||
Zotero.Library.prototype.toResponseJSON = function (options = {}) {
|
||||
let uri = Zotero.URI.getLibraryURI(this.libraryID);
|
||||
return {
|
||||
type: this.libraryType,
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
links: {
|
||||
self: {
|
||||
href: Zotero.URI.toAPIURL(uri, options.apiURL),
|
||||
type: 'application/json'
|
||||
},
|
||||
alternate: {
|
||||
href: Zotero.URI.toWebURL(uri),
|
||||
type: 'text/html'
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
Zotero.Library.prototype.hasCollections = function () {
|
||||
if (this._hasCollections === null) {
|
||||
throw new Error("Collection data has not been loaded");
|
||||
|
|
|
@ -1008,6 +1008,10 @@ Zotero.Search.prototype._buildQuery = Zotero.Promise.coroutine(function* () {
|
|||
case 'deleted':
|
||||
var deleted = condition.operator == 'true';
|
||||
continue;
|
||||
|
||||
case 'includeDeleted':
|
||||
var includeDeleted = condition.operator == 'true';
|
||||
continue;
|
||||
|
||||
case 'noChildren':
|
||||
var noChildren = condition.operator == 'true';
|
||||
|
@ -1101,31 +1105,38 @@ Zotero.Search.prototype._buildQuery = Zotero.Promise.coroutine(function* () {
|
|||
}
|
||||
}
|
||||
|
||||
// Exclude deleted items (and their child items) by default
|
||||
let not = deleted ? "" : "NOT ";
|
||||
sql += ` WHERE (itemID ${not} IN (`
|
||||
// Deleted items
|
||||
+ "SELECT itemID FROM deletedItems "
|
||||
// Child notes of deleted items
|
||||
+ "UNION SELECT itemID FROM itemNotes "
|
||||
+ "WHERE parentItemID IS NOT NULL AND "
|
||||
+ "parentItemID IN (SELECT itemID FROM deletedItems) "
|
||||
// Child attachments of deleted items
|
||||
+ "UNION SELECT itemID FROM itemAttachments "
|
||||
+ "WHERE parentItemID IS NOT NULL AND "
|
||||
+ "parentItemID IN (SELECT itemID FROM deletedItems)"
|
||||
// Annotations of deleted attachments
|
||||
+ "UNION SELECT itemID FROM itemAnnotations "
|
||||
+ "WHERE parentItemID IN (SELECT itemID FROM deletedItems)"
|
||||
// Annotations of attachments of deleted items
|
||||
+ "UNION SELECT itemID FROM itemAnnotations "
|
||||
+ "WHERE parentItemID IN (SELECT itemID FROM itemAttachments WHERE parentItemID IN (SELECT itemID FROM deletedItems))"
|
||||
+ "))";
|
||||
// Exclude deleted items (and their child items) by default, unless includeDeleted is true
|
||||
if (includeDeleted) {
|
||||
sql += " WHERE 1";
|
||||
}
|
||||
else {
|
||||
let not = deleted ? "" : "NOT ";
|
||||
sql += ` WHERE (itemID ${not} IN (`
|
||||
// Deleted items
|
||||
+ "SELECT itemID FROM deletedItems "
|
||||
// Child notes of deleted items
|
||||
+ "UNION SELECT itemID FROM itemNotes "
|
||||
+ "WHERE parentItemID IS NOT NULL AND "
|
||||
+ "parentItemID IN (SELECT itemID FROM deletedItems) "
|
||||
// Child attachments of deleted items
|
||||
+ "UNION SELECT itemID FROM itemAttachments "
|
||||
+ "WHERE parentItemID IS NOT NULL AND "
|
||||
+ "parentItemID IN (SELECT itemID FROM deletedItems)"
|
||||
// Annotations of deleted attachments
|
||||
+ "UNION SELECT itemID FROM itemAnnotations "
|
||||
+ "WHERE parentItemID IN (SELECT itemID FROM deletedItems)"
|
||||
// Annotations of attachments of deleted items
|
||||
+ "UNION SELECT itemID FROM itemAnnotations "
|
||||
+ "WHERE parentItemID IN (SELECT itemID FROM itemAttachments WHERE parentItemID IN (SELECT itemID FROM deletedItems))"
|
||||
+ "))";
|
||||
}
|
||||
|
||||
if (noChildren){
|
||||
sql += " AND (itemID NOT IN (SELECT itemID FROM itemNotes "
|
||||
+ "WHERE parentItemID IS NOT NULL) AND itemID NOT IN "
|
||||
+ "(SELECT itemID FROM itemAttachments "
|
||||
+ "WHERE parentItemID IS NOT NULL) AND itemID NOT IN "
|
||||
+ "(SELECT itemID FROM itemAnnotations "
|
||||
+ "WHERE parentItemID IS NOT NULL))";
|
||||
}
|
||||
|
||||
|
|
|
@ -81,6 +81,14 @@ Zotero.SearchConditions = new function(){
|
|||
false: true
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: 'includeDeleted',
|
||||
operators: {
|
||||
true: true,
|
||||
false: true
|
||||
}
|
||||
},
|
||||
|
||||
// Don't include child items
|
||||
{
|
||||
|
|
|
@ -75,7 +75,7 @@ Zotero.Tags = new function() {
|
|||
/**
|
||||
* Returns the tagID matching given fields, or false if none
|
||||
*
|
||||
* @param {String} name - Tag data in API JSON format
|
||||
* @param {String} name - Tag name
|
||||
* @return {Integer} tagID
|
||||
*/
|
||||
this.getID = function (name) {
|
||||
|
@ -99,7 +99,7 @@ Zotero.Tags = new function() {
|
|||
*
|
||||
* Requires a wrapping transaction
|
||||
*
|
||||
* @param {String} name - Tag data in API JSON format
|
||||
* @param {String} name - Tag name
|
||||
* @return {Promise<Integer>} tagID
|
||||
*/
|
||||
this.create = Zotero.Promise.coroutine(function* (name) {
|
||||
|
@ -219,6 +219,52 @@ Zotero.Tags = new function() {
|
|||
var rows = yield Zotero.DB.queryAsync(sql, str ? '%' + str + '%' : undefined);
|
||||
return rows.map((row) => this.cleanData(row));
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Convert tags (a single tag or an array) in API JSON format to API response JSON format.
|
||||
*
|
||||
* @param {Number} libraryID
|
||||
* @param {Object[]} tags
|
||||
* @param {Object} [options]
|
||||
* @return {Promise<Object[]>}
|
||||
*/
|
||||
this.toResponseJSON = function (libraryID, tags, options = {}) {
|
||||
return Promise.all(tags.map(async (tag) => {
|
||||
tag = { ...this.cleanData(tag), type: tag.type };
|
||||
let numItems;
|
||||
if (tag.type == 0 || tag.type == 1) {
|
||||
let sql = "SELECT COUNT(itemID) "
|
||||
+ "FROM tags JOIN itemTags USING (tagID) JOIN items USING (itemID) "
|
||||
+ `WHERE tagID = ? AND type = ? AND libraryID = ?`;
|
||||
numItems = await Zotero.DB.valueQueryAsync(sql, [this.getID(tag.tag), tag.type, libraryID]);
|
||||
}
|
||||
else {
|
||||
let sql = "SELECT COUNT(itemID) "
|
||||
+ "FROM tags JOIN itemTags USING (tagID) JOIN items USING (itemID) "
|
||||
+ `WHERE tagID = ? AND libraryID = ?`;
|
||||
numItems = await Zotero.DB.valueQueryAsync(sql, [this.getID(tag.tag), libraryID]);
|
||||
}
|
||||
let uri = Zotero.URI.getTagURI(libraryID, tag.tag);
|
||||
return {
|
||||
tag: tag.tag,
|
||||
links: {
|
||||
self: {
|
||||
href: Zotero.URI.toAPIURL(uri),
|
||||
type: 'application/json'
|
||||
},
|
||||
alternate: {
|
||||
href: uri, // No toWebURL - match dataserver behavior
|
||||
type: 'text/html'
|
||||
}
|
||||
},
|
||||
meta: {
|
||||
type: tag.type || 0,
|
||||
numItems
|
||||
}
|
||||
};
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
|
|
651
chrome/content/zotero/xpcom/localAPI/server_localAPI.js
Normal file
651
chrome/content/zotero/xpcom/localAPI/server_localAPI.js
Normal file
|
@ -0,0 +1,651 @@
|
|||
/*
|
||||
***** BEGIN LICENSE BLOCK *****
|
||||
|
||||
Copyright © 2022 Corporation for Digital Scholarship
|
||||
Vienna, Virginia, USA
|
||||
http://zotero.org
|
||||
|
||||
This file is part of Zotero.
|
||||
|
||||
Zotero is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Zotero is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
***** END LICENSE BLOCK *****
|
||||
*/
|
||||
|
||||
/*
|
||||
|
||||
This file provides a reasonably complete local implementation of the Zotero API (api.zotero.org).
|
||||
Endpoints are accessible on the local server (localhost:23119 by default) under /api/.
|
||||
|
||||
Limitations compared to api.zotero.org:
|
||||
|
||||
- Only API version 3 (https://www.zotero.org/support/dev/web_api/v3/basics) is supported,
|
||||
and only one API version will ever be supported at a time. If a new API version is released
|
||||
and your client needs to maintain support for older versions, first query /api/ and read the
|
||||
Zotero-API-Version response header, then make requests conditionally.
|
||||
- Write access is not yet supported.
|
||||
- No authentication.
|
||||
- No access to user data for users other than the local logged-in user. Use user ID 0
|
||||
or the user's actual API user ID (https://www.zotero.org/settings/keys).
|
||||
- Minimal access to metadata about groups.
|
||||
- Atom is not supported.
|
||||
- Pagination and limits are not supported.
|
||||
- If your code relies on any undefined behavior or especially unusual corner cases in the
|
||||
web API, it'll probably work differently when using the local API. This implementation is
|
||||
primarily concerned with matching the web API's spec and secondarily with matching its
|
||||
observed behavior, but it does not make any attempt to replicate implementation details
|
||||
that your code might rely on. Sort orders might differ, quicksearch results will probably
|
||||
differ, and JSON you get from the local API is never going to be exactly identical to what
|
||||
you would get from the web API.
|
||||
|
||||
That said, there are benefits:
|
||||
|
||||
- No pagination is needed because the API doesn't mind sending you many megabytes of data
|
||||
at a time - nothing ever touches the network.
|
||||
- For the same reason, no rate limits, and it's really fast.
|
||||
- <userOrGroupPrefix>/searches/:searchKey/items returns the set of items matching a saved
|
||||
search. The web API doesn't support actually executing searches.
|
||||
|
||||
*/
|
||||
|
||||
const LOCAL_API_VERSION = 3;
|
||||
|
||||
const exportFormats = new Map([
|
||||
['bibtex', '9cb70025-a888-4a29-a210-93ec52da40d4'],
|
||||
['biblatex', 'b6e39b57-8942-4d11-8259-342c46ce395f'],
|
||||
['bookmarks', '4e7119e0-02be-4848-86ef-79a64185aad8'],
|
||||
['coins', '05d07af9-105a-4572-99f6-a8e231c0daef'],
|
||||
['csljson', 'bc03b4fe-436d-4a1f-ba59-de4d2d7a63f7'],
|
||||
['csv', '25f4c5e2-d790-4daa-a667-797619c7e2f2'],
|
||||
['mods', '0e2235e7-babf-413c-9acf-f27cce5f059c'],
|
||||
['refer', '881f60f2-0802-411a-9228-ce5f47b64c7d'],
|
||||
['rdf_bibliontology', '14763d25-8ba0-45df-8f52-b8d1108e7ac9'],
|
||||
['rdf_dc', '6e372642-ed9d-4934-b5d1-c11ac758ebb7'],
|
||||
['rdf_zotero', '14763d24-8ba0-45df-8f52-b8d1108e7ac9'],
|
||||
['ris', '32d59d2d-b65a-4da4-b0a3-bdd3cfb979e7'],
|
||||
['tei', '032ae9b7-ab90-9205-a479-baf81f49184a'],
|
||||
['wikipedia', '3f50aaac-7acc-4350-acd0-59cb77faf620'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Base class for all local API endpoints. Implements pre- and post-processing steps.
|
||||
*/
|
||||
class LocalAPIEndpoint {
|
||||
async init(requestData) {
|
||||
let apiVersion = parseInt(
|
||||
requestData.headers['Zotero-API-Version']
|
||||
|| requestData.searchParams.get('v')
|
||||
|| LOCAL_API_VERSION
|
||||
);
|
||||
// Only allow mismatched version on /api/ no-op endpoint
|
||||
if (apiVersion !== LOCAL_API_VERSION && requestData.pathname != '/api/') {
|
||||
return this.makeResponse(501, 'text/plain', `API version not implemented: ${parseInt(apiVersion)}`);
|
||||
}
|
||||
|
||||
let userID = requestData.pathParams.userID && parseInt(requestData.pathParams.userID);
|
||||
if (userID !== undefined
|
||||
&& userID != 0
|
||||
&& userID != Zotero.Users.getCurrentUserID()) {
|
||||
return this.makeResponse(400, 'text/plain', 'Only data for the logged-in user is available locally - use userID 0');
|
||||
}
|
||||
|
||||
requestData.libraryID = requestData.pathParams.groupID
|
||||
? Zotero.Groups.getLibraryIDFromGroupID(parseInt(requestData.pathParams.groupID))
|
||||
: Zotero.Libraries.userLibraryID;
|
||||
|
||||
let response = await this.run(requestData);
|
||||
if (response.data) {
|
||||
if (requestData.searchParams.has('since')) {
|
||||
let since = parseInt(requestData.searchParams.get('since'));
|
||||
if (Number.isNaN(since)) {
|
||||
return this.makeResponse(400, 'text/plain', `Invalid 'since' value '${requestData.searchParams.get('since')}'`);
|
||||
}
|
||||
response.data = response.data.filter(dataObject => dataObject.version > since);
|
||||
}
|
||||
|
||||
if (Array.isArray(response.data) && response.data.length > 1) {
|
||||
let sort = requestData.searchParams.get('sort') || 'dateModified';
|
||||
if (!['dateAdded', 'dateModified', 'title', 'creator', 'itemType', 'date', 'publisher', 'publicationTitle', 'journalAbbreviation', 'language', 'accessDate', 'libraryCatalog', 'callNumber', 'rights', 'addedBy', 'numItems']
|
||||
.includes(sort)) {
|
||||
return this.makeResponse(400, 'text/plain', `Invalid 'sort' value '${sort}'`);
|
||||
}
|
||||
if (sort == 'creator') {
|
||||
sort = 'sortCreator';
|
||||
}
|
||||
let direction;
|
||||
if (requestData.searchParams.has('direction')) {
|
||||
let directionParam = requestData.searchParams.get('direction');
|
||||
if (directionParam == 'asc') {
|
||||
direction = 1;
|
||||
}
|
||||
else if (directionParam == 'desc') {
|
||||
direction = -1;
|
||||
}
|
||||
else {
|
||||
return this.makeResponse(400, 'text/plain', `Invalid 'direction' value '${directionParam}'`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
direction = sort.startsWith('date') ? -1 : 1;
|
||||
}
|
||||
response.data.sort((a, b) => {
|
||||
let aField = a[sort];
|
||||
if (!aField && a instanceof Zotero.Item) {
|
||||
aField = a.getField(sort, true, true);
|
||||
}
|
||||
let bField = b[sort];
|
||||
if (!bField && b instanceof Zotero.Item) {
|
||||
bField = b.getField(sort, true, true);
|
||||
}
|
||||
if (sort == 'date') {
|
||||
aField = Zotero.Date.multipartToSQL(aField);
|
||||
bField = Zotero.Date.multipartToSQL(bField);
|
||||
}
|
||||
return aField < bField
|
||||
? (-direction)
|
||||
: (aField > bField
|
||||
? direction
|
||||
: 0);
|
||||
});
|
||||
}
|
||||
|
||||
return this.makeDataObjectResponse(requestData, response.data);
|
||||
}
|
||||
else {
|
||||
return this.makeResponse(...response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param requestData Passed to {@link init}
|
||||
* @param dataObjectOrObjects
|
||||
* @returns {Promise} A response to be returned from {@link init}
|
||||
*/
|
||||
async makeDataObjectResponse(requestData, dataObjectOrObjects) {
|
||||
let contentType;
|
||||
let body;
|
||||
switch (requestData.searchParams.get('format')) {
|
||||
case 'atom':
|
||||
return this.makeResponse(501, 'text/plain', 'Local API does not support Atom output');
|
||||
case 'bib':
|
||||
contentType = 'text/html';
|
||||
body = await citeprocToHTML(dataObjectOrObjects, requestData.searchParams, false);
|
||||
break;
|
||||
case 'keys':
|
||||
if (!Array.isArray(dataObjectOrObjects)) {
|
||||
return this.makeResponse(400, 'text/plain', 'Only multi-object requests can output keys');
|
||||
}
|
||||
contentType = 'text/plain';
|
||||
body = dataObjectOrObjects.map(o => o.key)
|
||||
.join('\n');
|
||||
break;
|
||||
case 'versions':
|
||||
if (!Array.isArray(dataObjectOrObjects)) {
|
||||
return this.makeResponse(400, 'text/plain', 'Only multi-object requests can output versions');
|
||||
}
|
||||
contentType = 'application/json';
|
||||
body = JSON.stringify(Object.fromEntries(dataObjectOrObjects.map(o => [o.key, o.version])), null, 4);
|
||||
break;
|
||||
case 'json':
|
||||
case null:
|
||||
contentType = 'application/json';
|
||||
body = JSON.stringify(await toResponseJSON(dataObjectOrObjects, requestData.searchParams), null, 4);
|
||||
break;
|
||||
default:
|
||||
if (exportFormats.has(requestData.searchParams.get('format'))) {
|
||||
contentType = 'text/plain';
|
||||
body = await exportItems(dataObjectOrObjects, exportFormats.get(requestData.searchParams.get('format')));
|
||||
}
|
||||
else {
|
||||
return this.makeResponse(400, 'text/plain', `Invalid 'format' value '${requestData.searchParams.get('format')}'`);
|
||||
}
|
||||
}
|
||||
return this.makeResponse(200, contentType, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP response array with API headers.
|
||||
*
|
||||
* @param {Number} status
|
||||
* @param {String | Object} contentTypeOrHeaders
|
||||
* @param {String} body
|
||||
* @returns {[Number, Object, String]}
|
||||
*/
|
||||
makeResponse(status, contentTypeOrHeaders, body) {
|
||||
if (typeof contentTypeOrHeaders == 'string') {
|
||||
contentTypeOrHeaders = {
|
||||
'Content-Type': contentTypeOrHeaders
|
||||
};
|
||||
}
|
||||
contentTypeOrHeaders['Zotero-API-Version'] = LOCAL_API_VERSION;
|
||||
contentTypeOrHeaders['Zotero-Schema-Version'] = Zotero.Schema.globalSchemaVersion;
|
||||
return [status, contentTypeOrHeaders, body];
|
||||
}
|
||||
|
||||
/**
|
||||
* Subclasses must implement this method to process requests.
|
||||
*
|
||||
* @param {Object} requestData
|
||||
* @return {Promise<{ data }> | { data } | [Number, (String | Object), String]}
|
||||
* An object with a 'data' property containing a {@link Zotero.DataObject} or an array of DataObjects,
|
||||
* or an HTTP response array (status code, Content-Type or headers, body).
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
run(requestData) {
|
||||
throw new Error("run() must be implemented");
|
||||
}
|
||||
}
|
||||
|
||||
const _404 = [404, 'text/plain', 'Not found'];
|
||||
|
||||
Zotero.Server.LocalAPI = {};
|
||||
|
||||
Zotero.Server.LocalAPI.Root = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
run(_) {
|
||||
return [200, 'text/plain', 'Nothing to see here.'];
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/"] = Zotero.Server.LocalAPI.Root;
|
||||
|
||||
Zotero.Server.LocalAPI.Collections = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
run({ pathname, pathParams, libraryID }) {
|
||||
let top = pathname.endsWith('/top');
|
||||
let collections = pathParams.collectionKey
|
||||
? Zotero.Collections.getByParent(Zotero.Collections.getIDFromLibraryAndKey(libraryID, pathParams.collectionKey))
|
||||
: Zotero.Collections.getByLibrary(libraryID, !top);
|
||||
return { data: collections };
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/users/:userID/collections"] = Zotero.Server.LocalAPI.Collections;
|
||||
Zotero.Server.Endpoints["/api/groups/:groupID/collections"] = Zotero.Server.LocalAPI.Collections;
|
||||
Zotero.Server.Endpoints["/api/users/:userID/collections/top"] = Zotero.Server.LocalAPI.Collections;
|
||||
Zotero.Server.Endpoints["/api/groups/:groupID/collections/top"] = Zotero.Server.LocalAPI.Collections;
|
||||
Zotero.Server.Endpoints["/api/users/:userID/collections/:collectionKey/collections"] = Zotero.Server.LocalAPI.Collections;
|
||||
Zotero.Server.Endpoints["/api/groups/:groupID/collections/:collectionKey/collections"] = Zotero.Server.LocalAPI.Collections;
|
||||
|
||||
Zotero.Server.LocalAPI.Collection = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
run({ pathParams, libraryID }) {
|
||||
let collection = Zotero.Collections.getByLibraryAndKey(libraryID, pathParams.collectionKey);
|
||||
if (!collection) return _404;
|
||||
return { data: collection };
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/users/:userID/collections/:collectionKey"] = Zotero.Server.LocalAPI.Collection;
|
||||
Zotero.Server.Endpoints["/api/groups/:groupID/collections/:collectionKey"] = Zotero.Server.LocalAPI.Collection;
|
||||
|
||||
|
||||
Zotero.Server.LocalAPI.Groups = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
run(_) {
|
||||
let groups = Zotero.Groups.getAll();
|
||||
return { data: groups };
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/users/:userID/groups"] = Zotero.Server.LocalAPI.Groups;
|
||||
|
||||
Zotero.Server.LocalAPI.Group = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
run({ pathParams }) {
|
||||
let group = Zotero.Groups.get(pathParams.groupID);
|
||||
if (!group) return _404;
|
||||
return { data: group };
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/groups/:groupID"] = Zotero.Server.LocalAPI.Group;
|
||||
Zotero.Server.Endpoints["/api/users/:userID/groups/:groupID"] = Zotero.Server.LocalAPI.Group;
|
||||
|
||||
|
||||
Zotero.Server.LocalAPI.Items = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
async run({ pathname, pathParams, searchParams, libraryID }) {
|
||||
let isTags = pathname.endsWith('/tags');
|
||||
if (isTags) {
|
||||
// Cut it off so other .endsWith() checks work
|
||||
pathname = pathname.slice(0, -5);
|
||||
}
|
||||
|
||||
let search = new Zotero.Search();
|
||||
search.libraryID = libraryID;
|
||||
search.addCondition('noChildren', pathname.endsWith('/top') ? 'true' : 'false');
|
||||
if (pathParams.collectionKey) {
|
||||
search.addCondition('collectionID', 'is',
|
||||
Zotero.Collections.getIDFromLibraryAndKey(libraryID, pathParams.collectionKey));
|
||||
}
|
||||
else if (pathParams.itemKey) {
|
||||
// We'll filter out the parent later
|
||||
search.addCondition('key', 'is', pathParams.itemKey);
|
||||
search.addCondition('includeChildren', 'true');
|
||||
}
|
||||
else if (pathname.endsWith('/trash')) {
|
||||
search.addCondition('deleted', 'true');
|
||||
}
|
||||
else if (pathname.endsWith('/publications/items')) {
|
||||
search.addCondition('publications', 'true');
|
||||
}
|
||||
|
||||
if (searchParams.get('includeTrashed') == '1' && !pathname.endsWith('/trash')) {
|
||||
search.addCondition('includeDeleted', 'true');
|
||||
}
|
||||
|
||||
let savedSearch;
|
||||
if (pathParams.searchKey) {
|
||||
savedSearch = Zotero.Searches.getByLibraryAndKey(libraryID, pathParams.searchKey);
|
||||
if (!savedSearch) return _404;
|
||||
search.setScope(savedSearch);
|
||||
}
|
||||
|
||||
if (searchParams.has('itemKey')) {
|
||||
let scope = new Zotero.Search();
|
||||
if (savedSearch) {
|
||||
scope.setScope(savedSearch);
|
||||
}
|
||||
scope.libraryID = libraryID;
|
||||
scope.addCondition('joinMode', 'any');
|
||||
let keys = new Set(searchParams.get('itemKey').split(','));
|
||||
for (let key of keys) {
|
||||
scope.addCondition('key', 'is', key);
|
||||
}
|
||||
search.setScope(scope);
|
||||
}
|
||||
|
||||
let q = searchParams.get(isTags ? 'itemQ' : 'q');
|
||||
let qMode = searchParams.get(isTags ? 'itemQMode' : 'qmode');
|
||||
if (q) {
|
||||
search.addCondition('libraryID', 'is', libraryID);
|
||||
search.addCondition('quicksearch-' + (qMode || 'titleCreatorYear'), 'contains', q);
|
||||
}
|
||||
|
||||
Zotero.debug('Executing local API search');
|
||||
Zotero.debug(search.toJSON());
|
||||
// Searches sometimes return duplicate IDs; de-duplicate first
|
||||
// TODO: Fix in search.js
|
||||
let uniqueResultIDs = [...new Set(await search.search())];
|
||||
let items = await Zotero.Items.getAsync(uniqueResultIDs);
|
||||
|
||||
if (pathParams.itemKey) {
|
||||
// Filter out the parent, as promised
|
||||
items = items.filter(item => item.key != pathParams.itemKey);
|
||||
}
|
||||
|
||||
// Now evaluate the API's search syntax on the search results
|
||||
items = evaluateSearchSyntax(
|
||||
searchParams.getAll('itemType'),
|
||||
items,
|
||||
(item, itemType) => item.itemType == itemType
|
||||
);
|
||||
items = evaluateSearchSyntax(
|
||||
searchParams.getAll(isTags ? 'itemTag' : 'tag'),
|
||||
items,
|
||||
(item, tag) => item.hasTag(tag)
|
||||
);
|
||||
|
||||
if (isTags) {
|
||||
let tmpTable = await Zotero.Search.idsToTempTable(items.map(item => item.id));
|
||||
try {
|
||||
let tags = await Zotero.Tags.getAllWithin({ tmpTable });
|
||||
|
||||
let tagQ = searchParams.get('q');
|
||||
if (tagQ) {
|
||||
let pred = searchParams.get('qmode') == 'startsWith'
|
||||
? (tag => tag.tag.startsWith(tagQ))
|
||||
: (tag => tag.tag.includes(tagQ));
|
||||
tags = tags.filter(pred);
|
||||
}
|
||||
|
||||
// getAllWithin() calls cleanData(), which discards type fields when they are 0
|
||||
// But we always want them, so add them back if necessary
|
||||
let json = await Zotero.Tags.toResponseJSON(libraryID,
|
||||
tags.map(tag => ({ ...tag, type: tag.type || 0 })));
|
||||
return [200, 'application/json', JSON.stringify(json, null, 4)];
|
||||
}
|
||||
finally {
|
||||
await Zotero.DB.queryAsync("DROP TABLE IF EXISTS " + tmpTable, [], { noCache: true });
|
||||
}
|
||||
}
|
||||
|
||||
return { data: items };
|
||||
}
|
||||
};
|
||||
|
||||
// Add basic library-wide item endpoints
|
||||
for (let topTrashPart of ['', '/top', '/trash']) {
|
||||
for (let tagsPart of ['', '/tags']) {
|
||||
for (let userGroupPart of ['/api/users/:userID', '/api/groups/:groupID']) {
|
||||
let path = userGroupPart + '/items' + topTrashPart + tagsPart;
|
||||
Zotero.Server.Endpoints[path] = Zotero.Server.LocalAPI.Items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add collection-scoped item endpoints
|
||||
for (let topPart of ['', '/top']) {
|
||||
for (let tagsPart of ['', '/tags']) {
|
||||
for (let userGroupPart of ['/api/users/:userID', '/api/groups/:groupID']) {
|
||||
let path = userGroupPart + '/collections/:collectionKey/items' + topPart + tagsPart;
|
||||
Zotero.Server.Endpoints[path] = Zotero.Server.LocalAPI.Items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the rest manually
|
||||
Zotero.Server.Endpoints["/api/users/:userID/items/:itemKey/children"] = Zotero.Server.LocalAPI.Items;
|
||||
Zotero.Server.Endpoints["/api/groups/:groupID/items/:itemKey/children"] = Zotero.Server.LocalAPI.Items;
|
||||
Zotero.Server.Endpoints["/api/users/:userID/publications/items"] = Zotero.Server.LocalAPI.Items;
|
||||
Zotero.Server.Endpoints["/api/users/:userID/publications/items/tags"] = Zotero.Server.LocalAPI.Items;
|
||||
Zotero.Server.Endpoints["/api/users/:userID/searches/:searchKey/items"] = Zotero.Server.LocalAPI.Items;
|
||||
Zotero.Server.Endpoints["/api/groups/:groupID/searches/:searchKey/items"] = Zotero.Server.LocalAPI.Items;
|
||||
|
||||
Zotero.Server.LocalAPI.Item = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
run({ pathParams, libraryID }) {
|
||||
let item = Zotero.Items.getByLibraryAndKey(libraryID, pathParams.itemKey);
|
||||
if (!item) return _404;
|
||||
return { data: item };
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/users/:userID/items/:itemKey"] = Zotero.Server.LocalAPI.Item;
|
||||
Zotero.Server.Endpoints["/api/groups/:groupID/items/:itemKey"] = Zotero.Server.LocalAPI.Item;
|
||||
|
||||
|
||||
Zotero.Server.LocalAPI.Searches = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
async run({ libraryID }) {
|
||||
let searches = await Zotero.Searches.getAll(libraryID);
|
||||
return { data: searches };
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/users/:userID/searches"] = Zotero.Server.LocalAPI.Searches;
|
||||
Zotero.Server.Endpoints["/api/groups/:groupID/searches"] = Zotero.Server.LocalAPI.Searches;
|
||||
|
||||
Zotero.Server.LocalAPI.Search = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
async run({ pathParams, libraryID }) {
|
||||
let search = Zotero.Searches.getByLibraryAndKey(libraryID, pathParams.searchKey);
|
||||
if (!search) return _404;
|
||||
return { data: search };
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/users/:userID/searches/:searchKey"] = Zotero.Server.LocalAPI.Search;
|
||||
Zotero.Server.Endpoints["/api/groups/:groupID/searches/:searchKey"] = Zotero.Server.LocalAPI.Search;
|
||||
|
||||
|
||||
Zotero.Server.LocalAPI.Tags = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
async run({ libraryID }) {
|
||||
let tags = await Zotero.Tags.getAll(libraryID);
|
||||
let json = await Zotero.Tags.toResponseJSON(libraryID, tags);
|
||||
return [200, 'application/json', JSON.stringify(json, null, 4)];
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/users/:userID/tags"] = Zotero.Server.LocalAPI.Tags;
|
||||
Zotero.Server.Endpoints["/api/groups/:groupID/tags"] = Zotero.Server.LocalAPI.Tags;
|
||||
|
||||
Zotero.Server.LocalAPI.Tag = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
async run({ pathParams, libraryID }) {
|
||||
let tag = decodeURIComponent(pathParams.tag.replaceAll('+', '%20'));
|
||||
let json = await Zotero.Tags.toResponseJSON(libraryID, [{ tag }]);
|
||||
if (!json) return _404;
|
||||
return [200, 'application/json', JSON.stringify(json, null, 4)];
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/users/:userID/tags/:tag"] = Zotero.Server.LocalAPI.Tag;
|
||||
Zotero.Server.Endpoints["/api/groups/:groupID/tags/:tag"] = Zotero.Server.LocalAPI.Tag;
|
||||
|
||||
|
||||
/**
|
||||
* Convert a {@link Zotero.DataObject}, or an array of DataObjects, to response JSON
|
||||
* with appropriate included data based on the 'include' query parameter.
|
||||
*
|
||||
* @param {Zotero.DataObject | Zotero.DataObject[]} dataObjectOrObjects
|
||||
* @param {URLSearchParams} searchParams
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function toResponseJSON(dataObjectOrObjects, searchParams) {
|
||||
if (Array.isArray(dataObjectOrObjects)) {
|
||||
return Promise.all(dataObjectOrObjects.map(o => toResponseJSON(o, searchParams)));
|
||||
}
|
||||
|
||||
// Ask the data object for its response JSON representation, updating URLs to point to localhost
|
||||
let dataObject = dataObjectOrObjects;
|
||||
let responseJSON = dataObject.toResponseJSON({
|
||||
apiURL: `http://localhost:${Zotero.Prefs.get('httpServer.port')}/api/`,
|
||||
includeGroupDetails: true
|
||||
});
|
||||
|
||||
// Add includes and remove 'data' if not requested
|
||||
let include = searchParams.has('include') ? searchParams.get('include') : 'data';
|
||||
let dataIncluded = false;
|
||||
for (let includeFormat of include.split(',')) {
|
||||
switch (includeFormat) {
|
||||
case 'bib':
|
||||
responseJSON.bib = await citeprocToHTML(dataObject, searchParams, false);
|
||||
break;
|
||||
case 'citation':
|
||||
responseJSON.citation = await citeprocToHTML(dataObject, searchParams, true);
|
||||
break;
|
||||
case 'data':
|
||||
dataIncluded = true;
|
||||
break;
|
||||
default:
|
||||
if (exportFormats.has(includeFormat)) {
|
||||
responseJSON[includeFormat] = await exportItems([dataObject], exportFormats.get(includeFormat));
|
||||
}
|
||||
else {
|
||||
// Ignore since we don't have a great way to propagate the error up
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!dataIncluded) {
|
||||
delete responseJSON.data;
|
||||
}
|
||||
return responseJSON;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use citeproc to output HTML for an item or items.
|
||||
*
|
||||
* @param {Zotero.Item | Zotero.Item[]} itemOrItems
|
||||
* @param {URLSearchParams} searchParams
|
||||
* @param {Boolean} asCitationList
|
||||
* @returns {Promise<String>}
|
||||
*/
|
||||
async function citeprocToHTML(itemOrItems, searchParams, asCitationList) {
|
||||
let items = Array.isArray(itemOrItems)
|
||||
? itemOrItems
|
||||
: [itemOrItems];
|
||||
|
||||
// Filter out attachments, annotations, and notes, which we can't generate citations for
|
||||
items = items.filter(item => item.isRegularItem());
|
||||
let styleID = searchParams.get('style') || 'chicago-note-bibliography';
|
||||
let locale = searchParams.get('locale') || 'en-US';
|
||||
let linkWrap = searchParams.get('linkwrap') == '1';
|
||||
|
||||
if (!styleID.startsWith('http://www.zotero.org/styles/')) {
|
||||
styleID = 'http://www.zotero.org/styles/' + styleID;
|
||||
}
|
||||
let style = Zotero.Styles.get(styleID);
|
||||
if (!style) {
|
||||
// The client wants a style we don't have locally, so download it
|
||||
let url = styleID.replace('http', 'https');
|
||||
await Zotero.Styles.install({ url }, url, true);
|
||||
style = Zotero.Styles.get(styleID);
|
||||
}
|
||||
|
||||
let cslEngine = style.getCiteProc(locale, 'html');
|
||||
cslEngine.opt.development_extensions.wrap_url_and_doi = linkWrap;
|
||||
return Zotero.Cite.makeFormattedBibliographyOrCitationList(cslEngine, items, 'html', asCitationList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export items to a string with the given translator.
|
||||
*
|
||||
* @param {Zotero.Item[]} items
|
||||
* @param {String} translatorID
|
||||
* @returns {Promise<String>}
|
||||
*/
|
||||
function exportItems(items, translatorID) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let translation = new Zotero.Translate.Export();
|
||||
translation.setItems(items.slice());
|
||||
translation.setTranslator(translatorID);
|
||||
translation.setHandler('done', () => {
|
||||
resolve(translation.string);
|
||||
});
|
||||
translation.setHandler('error', (_, error) => {
|
||||
reject(error);
|
||||
});
|
||||
translation.translate();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the API's search syntax: https://www.zotero.org/support/dev/web_api/v3/basics#search_syntax
|
||||
*
|
||||
* @param {String[]} searchStrings The search strings provided by the client as query parameters
|
||||
* @param {Object[]} items The items to search on. Can be of any type.
|
||||
* @param {(item: Object, attribute: String) => Boolean} predicate Returns whether an item has an attribute.
|
||||
* For a call evaluating the 'tag' query parameter, for example, the predicate should return whether the item
|
||||
* has the attribute as one of its tags.
|
||||
* @returns {Object[]} A filtered array of items
|
||||
*/
|
||||
function evaluateSearchSyntax(searchStrings, items, predicate) {
|
||||
for (let searchString of searchStrings) {
|
||||
let negate = false;
|
||||
if (searchString[0] == '-') {
|
||||
negate = true;
|
||||
searchString = searchString.substring(1);
|
||||
}
|
||||
if (searchString[0] == '\\' && searchString[1] == '-') {
|
||||
searchString = searchString.substring(1);
|
||||
}
|
||||
let ors = searchString.split('||').map(or => or.trim());
|
||||
items = items.filter(item => ors.some(or => predicate(item, or) == !negate));
|
||||
}
|
||||
return items;
|
||||
}
|
|
@ -287,12 +287,30 @@ Zotero.Server.DataListener.prototype._headerFinished = function() {
|
|||
this._requestFinished(this._generateResponse(400, "text/plain", "Invalid method specified\n"));
|
||||
return;
|
||||
}
|
||||
if(!Zotero.Server.Endpoints[method[2]]) {
|
||||
this._requestFinished(this._generateResponse(404, "text/plain", "No endpoint found\n"));
|
||||
return;
|
||||
|
||||
this.pathParams = {};
|
||||
if (Zotero.Server.Endpoints[method[2]]) {
|
||||
this.endpoint = Zotero.Server.Endpoints[method[2]];
|
||||
}
|
||||
else {
|
||||
let router = new Zotero.Router(this.pathParams);
|
||||
for (let [potentialTemplate, endpoint] of Object.entries(Zotero.Server.Endpoints)) {
|
||||
if (!potentialTemplate.includes(':')) continue;
|
||||
router.add(potentialTemplate, () => {
|
||||
this.pathParams._endpoint = endpoint;
|
||||
}, true, /* Do not allow missing params */ false);
|
||||
}
|
||||
if (router.run(method[2].split('?')[0])) { // Don't let parser handle query params - we do that already
|
||||
this.endpoint = this.pathParams._endpoint;
|
||||
delete this.pathParams._endpoint;
|
||||
delete this.pathParams.url;
|
||||
}
|
||||
else {
|
||||
this._requestFinished(this._generateResponse(404, "text/plain", "No endpoint found\n"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.pathname = method[2];
|
||||
this.endpoint = Zotero.Server.Endpoints[method[2]];
|
||||
this.query = method[3];
|
||||
|
||||
if(method[1] == "HEAD" || method[1] == "OPTIONS") {
|
||||
|
@ -503,7 +521,7 @@ Zotero.Server.DataListener.prototype._processEndpoint = Zotero.Promise.coroutine
|
|||
// Pass to endpoint
|
||||
//
|
||||
// Single-parameter endpoint
|
||||
// - Takes an object with 'method', 'pathname', 'query', 'headers', and 'data'
|
||||
// - Takes an object with 'method', 'pathname', 'pathParams', 'searchParams', 'headers', and 'data'
|
||||
// - Returns a status code, an array containing [statusCode, contentType, body],
|
||||
// or a promise for either
|
||||
if (endpoint.init.length === 1
|
||||
|
@ -525,7 +543,8 @@ Zotero.Server.DataListener.prototype._processEndpoint = Zotero.Promise.coroutine
|
|||
let maybePromise = endpoint.init({
|
||||
method,
|
||||
pathname: this.pathname,
|
||||
query: this.query ? Zotero.Server.decodeQueryString(this.query.substr(1)) : {},
|
||||
pathParams: this.pathParams,
|
||||
searchParams: new URLSearchParams(this.query ? this.query.substring(1) : ''),
|
||||
headers,
|
||||
data: decodedData
|
||||
});
|
||||
|
@ -552,9 +571,9 @@ Zotero.Server.DataListener.prototype._processEndpoint = Zotero.Promise.coroutine
|
|||
const uaRe = /[\r\n]User-Agent: +([^\r\n]+)/i;
|
||||
var m = uaRe.exec(this.header);
|
||||
var url = {
|
||||
"pathname":this.pathname,
|
||||
"query":this.query ? Zotero.Server.decodeQueryString(this.query.substr(1)) : {},
|
||||
"userAgent":m && m[1]
|
||||
pathname: this.pathname,
|
||||
searchParams: new URLSearchParams(this.query ? this.query.substring(1) : ''),
|
||||
userAgent: m && m[1]
|
||||
};
|
||||
endpoint.init(url, decodedData, sendResponseCallback);
|
||||
}
|
||||
|
|
|
@ -710,7 +710,7 @@ Zotero.Style.prototype.getCiteProc = function(locale, format, automaticJournalAb
|
|||
if(!parentStyle) {
|
||||
throw new Error(
|
||||
'Style references ' + this.source + ', but this style is not installed',
|
||||
Zotero.Utilities.pathToFileURI(this.path)
|
||||
Zotero.File.pathToFileURI(this.path)
|
||||
);
|
||||
}
|
||||
var version = parentStyle._version;
|
||||
|
|
|
@ -145,7 +145,7 @@ Zotero.URI = new function () {
|
|||
* Return URI of item, which might be a local URI if user hasn't synced
|
||||
*/
|
||||
this.getItemURI = function (item) {
|
||||
return this._getObjectURI(item);
|
||||
return this.getObjectURI(item);
|
||||
}
|
||||
|
||||
|
||||
|
@ -169,7 +169,7 @@ Zotero.URI = new function () {
|
|||
* Return URI of collection, which might be a local URI if user hasn't synced
|
||||
*/
|
||||
this.getCollectionURI = function (collection) {
|
||||
return this._getObjectURI(collection);
|
||||
return this.getObjectURI(collection);
|
||||
}
|
||||
|
||||
|
||||
|
@ -195,16 +195,30 @@ Zotero.URI = new function () {
|
|||
|
||||
|
||||
/**
|
||||
* @param {Zotero.Group} group
|
||||
* @return {String}
|
||||
* @param {Zotero.Group} group
|
||||
* @param {Boolean} webRoot
|
||||
* @return {String}
|
||||
*/
|
||||
this.getGroupURI = function (group, webRoot) {
|
||||
var uri = this._getObjectURI(group);
|
||||
var uri = this.getObjectURI(group);
|
||||
if (webRoot) {
|
||||
uri = uri.replace(ZOTERO_CONFIG.BASE_URI, ZOTERO_CONFIG.WWW_BASE_URL);
|
||||
this.toWebURL(uri);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Zotero.Search} search
|
||||
* @param {Boolean} webRoot
|
||||
* @return {String}
|
||||
*/
|
||||
this.getSearchURI = function (search, webRoot) {
|
||||
var uri = this.getObjectURI(search);
|
||||
if (webRoot) {
|
||||
uri = this.toWebURL(uri);
|
||||
}
|
||||
return uri;
|
||||
};
|
||||
|
||||
this._getObjectPath = function(obj) {
|
||||
let path = this.getLibraryPath(obj.libraryID);
|
||||
|
@ -219,13 +233,30 @@ Zotero.URI = new function () {
|
|||
if (obj instanceof Zotero.Collection) {
|
||||
return path + '/collections/' + obj.key;
|
||||
}
|
||||
|
||||
if (obj instanceof Zotero.Search) {
|
||||
return path + '/searches/' + obj.key;
|
||||
}
|
||||
|
||||
throw new Error("Unsupported object type '" + obj._objectType + "'");
|
||||
}
|
||||
|
||||
this._getObjectURI = function(obj) {
|
||||
|
||||
/**
|
||||
* @param {Zotero.Item | Zotero.Collection | Zotero.Group | Zotero.Search} obj
|
||||
* @return {String}
|
||||
*/
|
||||
this.getObjectURI = function(obj) {
|
||||
return this.defaultPrefix + this._getObjectPath(obj);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Number} libraryID
|
||||
* @param {String | Object} tag Tag name or tag object
|
||||
*/
|
||||
this.getTagURI = function (libraryID, tag) {
|
||||
return this.getLibraryURI(libraryID) + '/tags/'
|
||||
+ encodeURIComponent(tag.tag || tag).replaceAll('%20', '+');
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert an item URI into an item
|
||||
|
@ -319,6 +350,24 @@ Zotero.URI = new function () {
|
|||
this.getURIFeed = function (feedURI) {
|
||||
return this._getURIObjectLibrary(feedURI, 'feed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} uri
|
||||
* @param {String} [apiURL]
|
||||
*/
|
||||
this.toAPIURL = function (uri, apiURL) {
|
||||
if (!apiURL) {
|
||||
apiURL = ZOTERO_CONFIG.API_URL;
|
||||
}
|
||||
return uri.replace(ZOTERO_CONFIG.BASE_URI, apiURL);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {String} uri
|
||||
*/
|
||||
this.toWebURL = function (uri) {
|
||||
return uri.replace(ZOTERO_CONFIG.BASE_URI, ZOTERO_CONFIG.WWW_BASE_URL);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
|
|
|
@ -156,6 +156,7 @@ const xpcomFilesLocal = [
|
|||
'connector/httpIntegrationClient',
|
||||
'connector/server_connector',
|
||||
'connector/server_connectorIntegration',
|
||||
'localAPI/server_localAPI',
|
||||
];
|
||||
|
||||
Components.utils.import("resource://gre/modules/ComponentUtils.jsm");
|
||||
|
|
|
@ -35,6 +35,10 @@
|
|||
var params = {};
|
||||
var missingParams = {};
|
||||
|
||||
if (!rule.allowMissingParams && rule.parts.length != pathParts.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse path components
|
||||
for (var i = 0; i < rule.parts.length; i++) {
|
||||
var rulePart = rule.parts[i];
|
||||
|
@ -71,11 +75,12 @@
|
|||
}
|
||||
|
||||
return {
|
||||
add: function (route, handler, autoPopulateOnMatch) {
|
||||
add: function (route, handler, autoPopulateOnMatch = true, allowMissingParams = true) {
|
||||
this.rules.push({
|
||||
parts: route.replace(/^\//, '').split('/'),
|
||||
handler: handler,
|
||||
autoPopulateOnMatch: autoPopulateOnMatch === undefined || autoPopulateOnMatch
|
||||
autoPopulateOnMatch,
|
||||
allowMissingParams
|
||||
});
|
||||
},
|
||||
|
||||
|
|
272
test/tests/server_localAPITest.js
Normal file
272
test/tests/server_localAPITest.js
Normal file
|
@ -0,0 +1,272 @@
|
|||
"use strict";
|
||||
|
||||
describe("Local API Server", function () {
|
||||
let apiRoot;
|
||||
|
||||
let collection;
|
||||
let subcollection;
|
||||
let collectionItem1;
|
||||
let collectionItem2;
|
||||
let subcollectionItem;
|
||||
let subcollectionAttachment;
|
||||
let allItems;
|
||||
|
||||
function apiGet(endpoint, options = {}) {
|
||||
return Zotero.HTTP.request('GET', apiRoot + endpoint, {
|
||||
headers: {
|
||||
'Zotero-Allowed-Request': '1'
|
||||
},
|
||||
responseType: 'json',
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
before(async function () {
|
||||
Zotero.Prefs.set('httpServer.enabled', true);
|
||||
apiRoot = 'http://127.0.0.1:' + Zotero.Prefs.get('httpServer.port') + '/api';
|
||||
|
||||
await resetDB({
|
||||
thisArg: this
|
||||
});
|
||||
|
||||
collection = await createDataObject('collection', { setTitle: true });
|
||||
subcollection = await createDataObject('collection', { setTitle: true, parentID: collection.id });
|
||||
collectionItem1 = await createDataObject('item', { setTitle: true, collections: [collection.id], itemType: 'bookSection' });
|
||||
collectionItem1.setCreators([{ firstName: 'A', lastName: 'Person', creatorType: 'author' }]);
|
||||
collectionItem1.saveTx();
|
||||
collectionItem2 = await createDataObject('item', { setTitle: true, collections: [collection.id], tags: ['some tag'] });
|
||||
collectionItem2.setCreators([{ firstName: 'A', lastName: 'Zerson', creatorType: 'author' }]);
|
||||
collectionItem2.saveTx();
|
||||
subcollectionItem = await createDataObject('item', { setTitle: true, collections: [subcollection.id] });
|
||||
subcollectionAttachment = await importPDFAttachment(subcollectionItem);
|
||||
allItems = [collectionItem1, collectionItem2, subcollectionItem, subcollectionAttachment];
|
||||
});
|
||||
|
||||
describe("/", function () {
|
||||
it("should return a Zotero-API-Version response header", async function () {
|
||||
let xhr = await Zotero.HTTP.request('GET', apiRoot + '/', {
|
||||
headers: {
|
||||
'Zotero-Allowed-Request': '1'
|
||||
}
|
||||
});
|
||||
assert.equal(xhr.getResponseHeader('Zotero-API-Version'), ZOTERO_CONFIG.API_VERSION);
|
||||
});
|
||||
|
||||
it("should allow an old Zotero-API-Version request header", async function () {
|
||||
let xhr = await Zotero.HTTP.request('GET', apiRoot + '/', {
|
||||
headers: {
|
||||
'Zotero-Allowed-Request': '1',
|
||||
'Zotero-API-Version': '2',
|
||||
}
|
||||
});
|
||||
assert.isNotEmpty(xhr.getResponseHeader('Zotero-API-Version'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("<userOrGroupPrefix>/collections", function () {
|
||||
it("should return all collections", async function () {
|
||||
let { response } = await apiGet('/users/0/collections');
|
||||
assert.isArray(response);
|
||||
assert.lengthOf(response, 2);
|
||||
|
||||
let col = response.find(c => c.key == collection.key);
|
||||
let subcol = response.find(c => c.key == subcollection.key);
|
||||
|
||||
assert.equal(col.data.name, collection.name);
|
||||
assert.equal(col.meta.numCollections, 1);
|
||||
assert.equal(col.meta.numItems, 2);
|
||||
|
||||
assert.equal(subcol.data.name, subcollection.name);
|
||||
assert.equal(subcol.meta.numCollections, 0);
|
||||
assert.equal(subcol.meta.numItems, 1);
|
||||
});
|
||||
|
||||
describe("/top", function () {
|
||||
it("should return top-level collections", async function () {
|
||||
let { response } = await apiGet('/users/0/collections/top');
|
||||
assert.isArray(response);
|
||||
assert.lengthOf(response, 1);
|
||||
|
||||
let col = response.find(c => c.key == collection.key);
|
||||
assert.ok(col);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/<key>", function () {
|
||||
it("should return a collection with parent information", async function () {
|
||||
let { response } = await apiGet(`/users/0/collections/${subcollection.key}`);
|
||||
assert.isNotArray(response);
|
||||
assert.equal(response.data.name, subcollection.name);
|
||||
assert.equal(response.data.parentCollection, collection.key);
|
||||
assert.include(response.links.up.href, collection.key);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("<userOrGroupPrefix>/items", function () {
|
||||
it("should return all items", async function () {
|
||||
let { response } = await apiGet('/users/0/items');
|
||||
assert.isArray(response);
|
||||
assert.lengthOf(response, 4);
|
||||
});
|
||||
|
||||
describe("/top", function () {
|
||||
it("should return top-level items", async function () {
|
||||
let { response } = await apiGet('/users/0/items/top');
|
||||
assert.isArray(response);
|
||||
assert.sameMembers(response.map(item => item.key), [collectionItem1.key, collectionItem2.key, subcollectionItem.key]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/:itemID/children", function () {
|
||||
it("should return the children and not return the parent", async function () {
|
||||
let { response } = await apiGet(`/users/0/items/${subcollectionItem.key}/children`);
|
||||
assert.lengthOf(response, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Child attachment items", function () {
|
||||
it("should have 'up' and 'enclosure' links", async function () {
|
||||
let { response } = await apiGet(`/users/0/items/${subcollectionAttachment.key}`);
|
||||
assert.isTrue(response.links.up.href.includes('/api/'));
|
||||
assert.isTrue(response.links.enclosure.href.startsWith('file:'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("?itemType", function () {
|
||||
it("should filter by item type", async function () {
|
||||
let { response } = await apiGet('/users/0/items?itemType=book');
|
||||
assert.lengthOf(response, 2);
|
||||
assert.isTrue(response.every(item => item.data.itemType == 'book'));
|
||||
});
|
||||
|
||||
it("should be able to be negated", async function () {
|
||||
let { response } = await apiGet('/users/0/items?itemType=-book');
|
||||
assert.lengthOf(response, 2);
|
||||
assert.isTrue(response.every(item => item.data.itemType == 'bookSection' || item.data.itemType == 'attachment'));
|
||||
});
|
||||
|
||||
it("should support OR combinations", async function () {
|
||||
let { response } = await apiGet('/users/0/items?itemType=book || bookSection');
|
||||
assert.lengthOf(response, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("?tag", function () {
|
||||
it("should filter by tag", async function () {
|
||||
let { response } = await apiGet('/users/0/items?tag=some tag');
|
||||
assert.lengthOf(response, 1);
|
||||
});
|
||||
|
||||
it("should be able to be negated", async function () {
|
||||
let { response } = await apiGet('/users/0/items?tag=-some tag');
|
||||
assert.lengthOf(response, 3);
|
||||
});
|
||||
|
||||
it("should be able to be combined with ?itemType", async function () {
|
||||
let { response } = await apiGet('/users/0/items?itemType=book&tag=some tag');
|
||||
assert.lengthOf(response, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("?format", function () {
|
||||
describe("=ris", function () {
|
||||
it("should output RIS", async function () {
|
||||
let { response } = await apiGet('/users/0/items?format=ris', { responseType: 'text' });
|
||||
assert.isTrue(response.startsWith('TY'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("=bib", function () {
|
||||
it("should output a bibliography", async function () {
|
||||
let { response } = await apiGet('/users/0/items?format=bib', { responseType: 'text' });
|
||||
assert.isTrue(response.startsWith('<div class="csl-bib-body"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("=keys", function () {
|
||||
it("should output a plain-text list of keys", async function () {
|
||||
let { response } = await apiGet('/users/0/items?format=keys', { responseType: 'text' });
|
||||
for (let item of allItems) {
|
||||
assert.isTrue(response.includes(item.key));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("=versions", function () {
|
||||
it("should output a JSON object mapping keys to versions", async function () {
|
||||
let { response } = await apiGet('/users/0/items?format=versions');
|
||||
assert.propertyVal(response, collectionItem1.key, collectionItem1.version);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("?include", function () {
|
||||
it("should exclude data when empty", async function () {
|
||||
let { response } = await apiGet(`/users/0/items/${collectionItem1.key}?include=`);
|
||||
assert.notProperty(response, 'data');
|
||||
});
|
||||
|
||||
describe("=citation", function () {
|
||||
it("should output citations", async function () {
|
||||
let { response } = await apiGet('/users/0/items?include=citation');
|
||||
assert.isTrue(response[0].citation.startsWith('<ol>'));
|
||||
});
|
||||
|
||||
describe("&style", function () {
|
||||
it("should use the given citation style, even if not yet installed", async function () {
|
||||
let styleID = 'http://www.zotero.org/styles/cell';
|
||||
if (Zotero.Styles.get(styleID)) {
|
||||
await Zotero.Styles.get(styleID).remove();
|
||||
}
|
||||
|
||||
let styleString = await Zotero.File.getContentsAsync(
|
||||
Zotero.File.pathToFile(OS.Path.join(getTestDataDirectory().path, 'cell.csl')));
|
||||
|
||||
let stub = sinon.stub(Zotero.Styles, 'install');
|
||||
stub.callsFake(() => stub.wrappedMethod({ string: styleString }, 'cell.csl', true));
|
||||
|
||||
let { response } = await apiGet(`/users/0/items/${collectionItem1.key}?include=citation&style=${encodeURIComponent(styleID)}`);
|
||||
assert.isTrue(stub.called);
|
||||
assert.equal(response.citation, '(Person)');
|
||||
|
||||
stub.restore();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("?since", function () {
|
||||
it("should filter the results", async function () {
|
||||
let { response: response1 } = await apiGet('/users/0/items?since=' + Zotero.Libraries.userLibrary.libraryVersion);
|
||||
assert.isEmpty(response1);
|
||||
|
||||
let { response: response2 } = await apiGet('/users/0/items?since=-1');
|
||||
assert.lengthOf(response2, allItems.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("?q", function () {
|
||||
it("should filter the results", async function () {
|
||||
let { response } = await apiGet('/users/0/items?q=Person');
|
||||
assert.lengthOf(response, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("?sort", function () {
|
||||
it("should sort by creator", async function () {
|
||||
let { response } = await apiGet('/users/0/items?sort=creator&direction=desc');
|
||||
assert.isBelow(response.findIndex(item => item.key == collectionItem2.key), response.findIndex(item => item.key == collectionItem1.key));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("<userOrGroupPrefix>/tags", function () {
|
||||
it("should return all tags in the library", async function () {
|
||||
let { response } = await apiGet('/users/0/tags');
|
||||
assert.lengthOf(response, 1);
|
||||
assert.equal(response[0].tag, 'some tag');
|
||||
assert.equal(response[0].meta.numItems, 1);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue