diff --git a/chrome/content/zotero/xpcom/connector/server_connector.js b/chrome/content/zotero/xpcom/connector/server_connector.js index 9c4b3b9b16..d4695f75d5 100644 --- a/chrome/content/zotero/xpcom/connector/server_connector.js +++ b/chrome/content/zotero/xpcom/connector/server_connector.js @@ -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]; diff --git a/chrome/content/zotero/xpcom/data/collection.js b/chrome/content/zotero/xpcom/data/collection.js index 042bc68144..9756ae3813 100644 --- a/chrome/content/zotero/xpcom/data/collection.js +++ b/chrome/content/zotero/xpcom/data/collection.js @@ -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 * diff --git a/chrome/content/zotero/xpcom/data/dataObject.js b/chrome/content/zotero/xpcom/data/dataObject.js index 5d58e1dad3..ceed9a878c 100644 --- a/chrome/content/zotero/xpcom/data/dataObject.js +++ b/chrome/content/zotero/xpcom/data/dataObject.js @@ -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) { diff --git a/chrome/content/zotero/xpcom/data/group.js b/chrome/content/zotero/xpcom/data/group.js index 9f19846d4f..7726726205 100644 --- a/chrome/content/zotero/xpcom/data/group.js +++ b/chrome/content/zotero/xpcom/data/group.js @@ -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; diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js index ff4facbb1b..a02a501a12 100644 --- a/chrome/content/zotero/xpcom/data/item.js +++ b/chrome/content/zotero/xpcom/data/item.js @@ -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; }; diff --git a/chrome/content/zotero/xpcom/data/items.js b/chrome/content/zotero/xpcom/data/items.js index 94d50ce274..a02936bb84 100644 --- a/chrome/content/zotero/xpcom/data/items.js +++ b/chrome/content/zotero/xpcom/data/items.js @@ -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}. - */ - 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 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 // diff --git a/chrome/content/zotero/xpcom/data/library.js b/chrome/content/zotero/xpcom/data/library.js index ed7da1d09a..df3eff7d6a 100644 --- a/chrome/content/zotero/xpcom/data/library.js +++ b/chrome/content/zotero/xpcom/data/library.js @@ -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"); diff --git a/chrome/content/zotero/xpcom/data/search.js b/chrome/content/zotero/xpcom/data/search.js index 40d3d20e86..f835d43bc4 100644 --- a/chrome/content/zotero/xpcom/data/search.js +++ b/chrome/content/zotero/xpcom/data/search.js @@ -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))"; } diff --git a/chrome/content/zotero/xpcom/data/searchConditions.js b/chrome/content/zotero/xpcom/data/searchConditions.js index 3c3351a650..6ffd44a745 100644 --- a/chrome/content/zotero/xpcom/data/searchConditions.js +++ b/chrome/content/zotero/xpcom/data/searchConditions.js @@ -81,6 +81,14 @@ Zotero.SearchConditions = new function(){ false: true } }, + + { + name: 'includeDeleted', + operators: { + true: true, + false: true + } + }, // Don't include child items { diff --git a/chrome/content/zotero/xpcom/data/tags.js b/chrome/content/zotero/xpcom/data/tags.js index 12ea90a728..8da35248bd 100644 --- a/chrome/content/zotero/xpcom/data/tags.js +++ b/chrome/content/zotero/xpcom/data/tags.js @@ -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} 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} + */ + 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 + } + }; + })); + }; /** diff --git a/chrome/content/zotero/xpcom/localAPI/server_localAPI.js b/chrome/content/zotero/xpcom/localAPI/server_localAPI.js new file mode 100644 index 0000000000..7875892162 --- /dev/null +++ b/chrome/content/zotero/xpcom/localAPI/server_localAPI.js @@ -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 . + + ***** 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. +- /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} + */ +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} + */ +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} + */ +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; +} diff --git a/chrome/content/zotero/xpcom/server.js b/chrome/content/zotero/xpcom/server.js index fe7ef67ea3..9c0094bd0b 100755 --- a/chrome/content/zotero/xpcom/server.js +++ b/chrome/content/zotero/xpcom/server.js @@ -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); } diff --git a/chrome/content/zotero/xpcom/style.js b/chrome/content/zotero/xpcom/style.js index 278086892f..723d1bb164 100644 --- a/chrome/content/zotero/xpcom/style.js +++ b/chrome/content/zotero/xpcom/style.js @@ -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; diff --git a/chrome/content/zotero/xpcom/uri.js b/chrome/content/zotero/xpcom/uri.js index 0275b32b32..1fcf85c791 100644 --- a/chrome/content/zotero/xpcom/uri.js +++ b/chrome/content/zotero/xpcom/uri.js @@ -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); + }; /** diff --git a/chrome/content/zotero/zotero.mjs b/chrome/content/zotero/zotero.mjs index bc547cc7fe..585ab57c77 100644 --- a/chrome/content/zotero/zotero.mjs +++ b/chrome/content/zotero/zotero.mjs @@ -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"); diff --git a/resource/pathparser.jsm b/resource/pathparser.jsm index 96b4cf0b37..c38d89dc7a 100644 --- a/resource/pathparser.jsm +++ b/resource/pathparser.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 }); }, diff --git a/test/tests/server_localAPITest.js b/test/tests/server_localAPITest.js new file mode 100644 index 0000000000..d5f9689725 --- /dev/null +++ b/test/tests/server_localAPITest.js @@ -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("/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("/", 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("/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('
')); + }); + + 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("/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); + }); + }); +});