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:
Abe Jellinek 2022-09-29 11:01:58 -04:00 committed by Dan Stillman
parent fe47cbe805
commit 44d9530ecf
17 changed files with 1202 additions and 109 deletions

View file

@ -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];

View file

@ -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
*

View file

@ -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) {

View file

@ -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;

View file

@ -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;
};

View file

@ -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
//

View file

@ -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");

View file

@ -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))";
}

View file

@ -81,6 +81,14 @@ Zotero.SearchConditions = new function(){
false: true
}
},
{
name: 'includeDeleted',
operators: {
true: true,
false: true
}
},
// Don't include child items
{

View file

@ -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
}
};
}));
};
/**

View 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;
}

View file

@ -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);
}

View file

@ -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;

View file

@ -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);
};
/**

View file

@ -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");

View file

@ -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
});
},

View 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);
});
});
});