Mostly complete (read-only) compatibility with web library (#4270)
- Add pagination, limits, and Link header - Add schema endpoints and dummy /settings endpoint - Add /file endpoints - Browser security restrictions prevent the web library from actually loading the file: URIs that the local API returns, but out-of-browser use will work fine - Add toResponseJSONAsync() DataObject function: delegates to toResponseJSON() by default, adds information that requires awaiting promises - Best attachment (links.attachment) and file size (links.enclosure.length) for items, meta.numItems for groups - Separate function for compatibility with the existing test code that uses toResponseJSON(), but we could consider unifying This commit does not add the Access-Control headers that allow webpages to make requests to the local API, since I don't think we actually want that.
This commit is contained in:
parent
44d9530ecf
commit
5d197e4b12
6 changed files with 351 additions and 45 deletions
|
@ -1356,6 +1356,17 @@ Zotero.DataObject.prototype.toResponseJSON = function (options = {}) {
|
|||
};
|
||||
|
||||
|
||||
/**
|
||||
* Subclasses can override to provide more information that requires awaiting promises.
|
||||
* Delegates to {@link Zotero.DataObject#toResponseJSON} by default.
|
||||
*
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
Zotero.DataObject.prototype.toResponseJSONAsync = async function (options = {}) {
|
||||
return this.toResponseJSON(options);
|
||||
};
|
||||
|
||||
|
||||
Zotero.DataObject.prototype._preToJSON = function (options) {
|
||||
var env = { options };
|
||||
env.mode = options.mode || 'new';
|
||||
|
|
|
@ -258,7 +258,6 @@ Zotero.Group.prototype.toResponseJSON = function (options = {}) {
|
|||
meta: {
|
||||
// created
|
||||
// lastModified
|
||||
// numItems
|
||||
},
|
||||
data: {
|
||||
id: this.id,
|
||||
|
@ -273,6 +272,15 @@ Zotero.Group.prototype.toResponseJSON = function (options = {}) {
|
|||
}
|
||||
};
|
||||
|
||||
Zotero.Group.prototype.toResponseJSONAsync = async function (options = {}) {
|
||||
let json = this.toResponseJSON(options);
|
||||
if (options.includeGroupDetails) {
|
||||
json.meta.numItems = await Zotero.DB.valueQueryAsync(
|
||||
"SELECT COUNT(*) FROM items WHERE libraryID = ?", this.libraryID);
|
||||
}
|
||||
return json;
|
||||
};
|
||||
|
||||
Zotero.Group.prototype.fromJSON = function (json, userID) {
|
||||
if (json.name !== undefined) this.name = json.name;
|
||||
if (json.description !== undefined) this.description = json.description;
|
||||
|
|
|
@ -2345,7 +2345,7 @@ Zotero.Item.prototype.isAttachment = function() {
|
|||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<Boolean>}
|
||||
* @return {Boolean}
|
||||
*/
|
||||
Zotero.Item.prototype.isImportedAttachment = function() {
|
||||
if (!this.isAttachment()) {
|
||||
|
@ -2361,7 +2361,7 @@ Zotero.Item.prototype.isImportedAttachment = function() {
|
|||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<Boolean>}
|
||||
* @return {Boolean}
|
||||
*/
|
||||
Zotero.Item.prototype.isStoredFileAttachment = function() {
|
||||
if (!this.isAttachment()) {
|
||||
|
@ -2371,7 +2371,7 @@ Zotero.Item.prototype.isStoredFileAttachment = function() {
|
|||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<Boolean>}
|
||||
* @return {Boolean}
|
||||
*/
|
||||
Zotero.Item.prototype.isWebAttachment = function() {
|
||||
if (!this.isAttachment()) {
|
||||
|
@ -5492,13 +5492,17 @@ Zotero.Item.prototype.toResponseJSON = function (options = {}) {
|
|||
// parsedDate
|
||||
var parsedDate = Zotero.Date.multipartToSQL(this.getField('date', true, true));
|
||||
if (parsedDate) {
|
||||
// 0000?
|
||||
// Trim off trailing -00 segments
|
||||
parsedDate = parsedDate.replace(/(-00)+$/, '');
|
||||
json.meta.parsedDate = parsedDate;
|
||||
}
|
||||
// numChildren
|
||||
if (this.isRegularItem()) {
|
||||
json.meta.numChildren = this.numChildren();
|
||||
}
|
||||
else {
|
||||
json.meta.numChildren = false;
|
||||
}
|
||||
|
||||
if (this.isImportedAttachment()) {
|
||||
json.links.enclosure = {
|
||||
|
@ -5512,6 +5516,25 @@ Zotero.Item.prototype.toResponseJSON = function (options = {}) {
|
|||
};
|
||||
|
||||
|
||||
Zotero.Item.prototype.toResponseJSONAsync = async function (options = {}) {
|
||||
let json = this.toResponseJSON(options);
|
||||
if (this.isRegularItem()) {
|
||||
let bestAttachment = await this.getBestAttachment();
|
||||
if (bestAttachment) {
|
||||
json.links.attachment = {
|
||||
href: Zotero.URI.toAPIURL(Zotero.URI.getItemURI(bestAttachment), options.apiURL),
|
||||
type: 'application/json',
|
||||
attachmentType: bestAttachment.attachmentContentType
|
||||
};
|
||||
}
|
||||
}
|
||||
else if (this.isImportedAttachment()) {
|
||||
json.links.enclosure.length = await Zotero.Attachments.getTotalFileSize(this);
|
||||
}
|
||||
return json;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Migrate valid fields in Extra to real fields
|
||||
*
|
||||
|
|
|
@ -30,32 +30,36 @@ Endpoints are accessible on the local server (localhost:23119 by default) under
|
|||
|
||||
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
|
||||
- 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).
|
||||
- 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.
|
||||
- Item type/field endpoints (https://www.zotero.org/support/dev/web_api/v3/types_and_fields) will
|
||||
return localized names in the user's locale. The locale query parameter is not supported. The
|
||||
single exception is /api/creatorFields, which follows the web API's behavior in always returning
|
||||
results in English, *not* the user's locale.
|
||||
- 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.
|
||||
- Pagination is often unnecessary because the API doesn't mind sending you many megabytes of data
|
||||
at a time - nothing ever touches the network. For that reason, returned results are not limited
|
||||
by default (unlike in the web API, which has a default limit of 25 and will not return more than
|
||||
100 results at a time).
|
||||
- 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.
|
||||
- <userOrGroupPrefix>/searches/:searchKey/items returns the set of items matching a saved search
|
||||
(unlike in the web API, which doesn't support actually executing searches).
|
||||
|
||||
*/
|
||||
|
||||
|
@ -90,7 +94,7 @@ class LocalAPIEndpoint {
|
|||
);
|
||||
// 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)}`);
|
||||
return this.makeResponse(501, 'text/plain', `API version not implemented: ${apiVersion}`);
|
||||
}
|
||||
|
||||
let userID = requestData.pathParams.userID && parseInt(requestData.pathParams.userID);
|
||||
|
@ -106,7 +110,8 @@ class LocalAPIEndpoint {
|
|||
|
||||
let response = await this.run(requestData);
|
||||
if (response.data) {
|
||||
if (requestData.searchParams.has('since')) {
|
||||
let dataIsArray = Array.isArray(response.data);
|
||||
if (dataIsArray && 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')}'`);
|
||||
|
@ -114,7 +119,7 @@ class LocalAPIEndpoint {
|
|||
response.data = response.data.filter(dataObject => dataObject.version > since);
|
||||
}
|
||||
|
||||
if (Array.isArray(response.data) && response.data.length > 1) {
|
||||
if (dataIsArray && 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)) {
|
||||
|
@ -160,7 +165,38 @@ class LocalAPIEndpoint {
|
|||
});
|
||||
}
|
||||
|
||||
return this.makeDataObjectResponse(requestData, response.data);
|
||||
let totalResults = 1;
|
||||
let links;
|
||||
if (dataIsArray) {
|
||||
totalResults = response.data.length;
|
||||
let start = parseInt(requestData.searchParams.get('start')) || 0;
|
||||
if (start < 0) start = 0;
|
||||
if (start >= response.data.length) start = response.data.length;
|
||||
let limit = parseInt(requestData.searchParams.get('limit')) || response.data.length - start;
|
||||
if (limit < 0) limit = 0;
|
||||
response.data = response.data.slice(start, start + limit);
|
||||
|
||||
links = this.buildLinks(requestData, start, limit, totalResults);
|
||||
}
|
||||
else {
|
||||
links = this.buildLinks(requestData, 0, 0, 1);
|
||||
}
|
||||
|
||||
let headers = {
|
||||
'Total-Results': totalResults,
|
||||
'Link': Object.entries(links).map(([rel, url]) => `<${url}>; rel="${rel}"`).join(', ')
|
||||
};
|
||||
let lastModifiedVersion = dataIsArray
|
||||
? Zotero.Libraries.get(requestData.libraryID).libraryVersion
|
||||
: response.data.version;
|
||||
if (lastModifiedVersion !== undefined) {
|
||||
headers['Last-Modified-Version'] = lastModifiedVersion;
|
||||
}
|
||||
if (requestData.headers['If-Modified-Since-Version']
|
||||
&& lastModifiedVersion <= parseInt(requestData.headers['If-Modified-Since-Version'])) {
|
||||
return this.makeResponse(304, headers, '');
|
||||
}
|
||||
return this.makeDataObjectResponse(requestData, response.data, headers);
|
||||
}
|
||||
else {
|
||||
return this.makeResponse(...response);
|
||||
|
@ -168,11 +204,90 @@ class LocalAPIEndpoint {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param requestData Passed to {@link init}
|
||||
* @param dataObjectOrObjects
|
||||
* Build an object mapping link 'rel' attributes to URLs for the given request data and response info.
|
||||
*
|
||||
* @param {Object} requestData
|
||||
* @param {Number} start
|
||||
* @param {Number} limit
|
||||
* @param {Number} totalResults
|
||||
* @returns {Object}
|
||||
*/
|
||||
buildLinks(requestData, start, limit, totalResults) {
|
||||
let links = {};
|
||||
|
||||
let buildURL = (searchParams) => {
|
||||
let url = new URL(requestData.pathname, 'http://' + requestData.headers['Host']);
|
||||
url.search = searchParams.toString();
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
// Logic adapted from https://github.com/zotero/dataserver/blob/18443360/model/API.inc.php#L588-L642
|
||||
// first
|
||||
if (start) {
|
||||
let p = new URLSearchParams(requestData.searchParams);
|
||||
p.delete('start');
|
||||
links.first = buildURL(p);
|
||||
}
|
||||
// prev
|
||||
if (start) {
|
||||
let p = new URLSearchParams(requestData.searchParams);
|
||||
let prevStart = start - limit;
|
||||
if (prevStart <= 0) {
|
||||
p.delete('start');
|
||||
}
|
||||
else {
|
||||
p.set('start', prevStart.toString());
|
||||
}
|
||||
links.prev = buildURL(p);
|
||||
}
|
||||
// last
|
||||
if (!start && limit >= totalResults) {
|
||||
let p = new URLSearchParams(requestData.searchParams);
|
||||
links.last = buildURL(p);
|
||||
}
|
||||
else if (limit) {
|
||||
let lastStart;
|
||||
if (start >= totalResults) {
|
||||
lastStart = totalResults - limit;
|
||||
}
|
||||
else {
|
||||
lastStart = totalResults - totalResults % limit;
|
||||
if (lastStart == totalResults) {
|
||||
lastStart = totalResults - limit;
|
||||
}
|
||||
}
|
||||
let p = new URLSearchParams(requestData.searchParams);
|
||||
if (lastStart > 0) {
|
||||
p.set('start', lastStart.toString());
|
||||
}
|
||||
else {
|
||||
p.delete('start');
|
||||
}
|
||||
links.last = buildURL(p);
|
||||
|
||||
// next
|
||||
let nextStart = start + limit;
|
||||
if (nextStart < totalResults) {
|
||||
p.set('start', nextStart.toString());
|
||||
links.next = buildURL(p);
|
||||
}
|
||||
}
|
||||
|
||||
// alternate: cut off '/api/', replace userID 0 with current user's ID
|
||||
links.alternate = ZOTERO_CONFIG.WWW_BASE_URL
|
||||
+ requestData.pathname.substring(5)
|
||||
.replace('users/0/', `users/${Zotero.Users.getCurrentUserID()}/`);
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} requestData Passed to {@link init}
|
||||
* @param {Zotero.DataObject | Zotero.DataObject[]} dataObjectOrObjects
|
||||
* @param {Object} headers
|
||||
* @returns {Promise} A response to be returned from {@link init}
|
||||
*/
|
||||
async makeDataObjectResponse(requestData, dataObjectOrObjects) {
|
||||
async makeDataObjectResponse(requestData, dataObjectOrObjects, headers) {
|
||||
let contentType;
|
||||
let body;
|
||||
switch (requestData.searchParams.get('format')) {
|
||||
|
@ -187,8 +302,7 @@ class LocalAPIEndpoint {
|
|||
return this.makeResponse(400, 'text/plain', 'Only multi-object requests can output keys');
|
||||
}
|
||||
contentType = 'text/plain';
|
||||
body = dataObjectOrObjects.map(o => o.key)
|
||||
.join('\n');
|
||||
body = dataObjectOrObjects.map(o => o.key).join('\n');
|
||||
break;
|
||||
case 'versions':
|
||||
if (!Array.isArray(dataObjectOrObjects)) {
|
||||
|
@ -211,7 +325,7 @@ class LocalAPIEndpoint {
|
|||
return this.makeResponse(400, 'text/plain', `Invalid 'format' value '${requestData.searchParams.get('format')}'`);
|
||||
}
|
||||
}
|
||||
return this.makeResponse(200, contentType, body);
|
||||
return this.makeResponse(200, { ...headers, 'Content-Type': contentType }, body);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -260,6 +374,111 @@ Zotero.Server.LocalAPI.Root = class extends LocalAPIEndpoint {
|
|||
};
|
||||
Zotero.Server.Endpoints["/api/"] = Zotero.Server.LocalAPI.Root;
|
||||
|
||||
|
||||
Zotero.Server.LocalAPI.Schema = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
async run(_) {
|
||||
return [200, 'application/json', await Zotero.File.getContentsFromURLAsync('resource://zotero/schema/global/schema.json')];
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/schema"] = Zotero.Server.LocalAPI.Schema;
|
||||
|
||||
Zotero.Server.LocalAPI.ItemTypes = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
run(_) {
|
||||
let itemTypes = Zotero.ItemTypes.getAll().map((type) => {
|
||||
return {
|
||||
itemType: type.name,
|
||||
localized: Zotero.ItemTypes.getLocalizedString(type.name)
|
||||
};
|
||||
});
|
||||
return [200, 'application/json', JSON.stringify(itemTypes, null, 4)];
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/itemTypes"] = Zotero.Server.LocalAPI.ItemTypes;
|
||||
|
||||
Zotero.Server.LocalAPI.ItemFields = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
run(_) {
|
||||
let itemFields = Zotero.ItemFields.getAll().map((field) => {
|
||||
return {
|
||||
field: field.name,
|
||||
localized: Zotero.ItemFields.getLocalizedString(field.name)
|
||||
};
|
||||
});
|
||||
return [200, 'application/json', JSON.stringify(itemFields, null, 4)];
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/itemFields"] = Zotero.Server.LocalAPI.ItemFields;
|
||||
|
||||
Zotero.Server.LocalAPI.ItemTypeFields = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
run({ searchParams }) {
|
||||
let itemType = searchParams.get('itemType');
|
||||
if (!itemType || !Zotero.ItemTypes.getID(itemType)) {
|
||||
return [400, 'text/plain', "Invalid or missing 'itemType' value"];
|
||||
}
|
||||
let itemFields = Zotero.ItemFields.getItemTypeFields(Zotero.ItemTypes.getID(itemType))
|
||||
.map((fieldID) => {
|
||||
return {
|
||||
field: Zotero.ItemFields.getName(fieldID),
|
||||
localized: Zotero.ItemFields.getLocalizedString(fieldID)
|
||||
};
|
||||
});
|
||||
return [200, 'application/json', JSON.stringify(itemFields, null, 4)];
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/itemTypeFields"] = Zotero.Server.LocalAPI.ItemTypeFields;
|
||||
|
||||
Zotero.Server.LocalAPI.ItemTypeCreatorTypes = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
run({ searchParams }) {
|
||||
let itemType = searchParams.get('itemType');
|
||||
if (!itemType || !Zotero.ItemTypes.getID(itemType)) {
|
||||
return [400, 'text/plain', "Invalid or missing 'itemType' value"];
|
||||
}
|
||||
let creatorTypes = Zotero.CreatorTypes.getTypesForItemType(Zotero.ItemTypes.getID(itemType))
|
||||
.map((creatorType) => {
|
||||
return {
|
||||
creatorType: creatorType.name,
|
||||
localized: creatorType.localizedName
|
||||
};
|
||||
});
|
||||
return [200, 'application/json', JSON.stringify(creatorTypes, null, 4)];
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/itemTypeCreatorTypes"] = Zotero.Server.LocalAPI.ItemTypeCreatorTypes;
|
||||
|
||||
Zotero.Server.LocalAPI.CreatorFields = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
run(_) {
|
||||
let creatorFields = [
|
||||
{ field: 'firstName', localized: 'First' },
|
||||
{ field: 'lastName', localized: 'Last' },
|
||||
{ field: 'name', localized: 'Name' }
|
||||
];
|
||||
return [200, 'application/json', JSON.stringify(creatorFields, null, 4)];
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/creatorFields"] = Zotero.Server.LocalAPI.CreatorFields;
|
||||
|
||||
|
||||
Zotero.Server.LocalAPI.Settings = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
run(_) {
|
||||
return [200, 'application/json', '{}'];
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/users/:userID/settings"] = Zotero.Server.LocalAPI.Settings;
|
||||
|
||||
|
||||
Zotero.Server.LocalAPI.Collections = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
|
@ -323,10 +542,14 @@ Zotero.Server.LocalAPI.Items = class extends LocalAPIEndpoint {
|
|||
// Cut it off so other .endsWith() checks work
|
||||
pathname = pathname.slice(0, -5);
|
||||
}
|
||||
let isTop = pathname.endsWith('/top');
|
||||
if (isTop) {
|
||||
pathname = pathname.slice(0, -4);
|
||||
}
|
||||
|
||||
let search = new Zotero.Search();
|
||||
search.libraryID = libraryID;
|
||||
search.addCondition('noChildren', pathname.endsWith('/top') ? 'true' : 'false');
|
||||
search.addCondition('noChildren', isTop ? 'true' : 'false');
|
||||
if (pathParams.collectionKey) {
|
||||
search.addCondition('collectionID', 'is',
|
||||
Zotero.Collections.getIDFromLibraryAndKey(libraryID, pathParams.collectionKey));
|
||||
|
@ -416,7 +639,7 @@ Zotero.Server.LocalAPI.Items = class extends LocalAPIEndpoint {
|
|||
// 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)];
|
||||
return { data: json };
|
||||
}
|
||||
finally {
|
||||
await Zotero.DB.queryAsync("DROP TABLE IF EXISTS " + tmpTable, [], { noCache: true });
|
||||
|
@ -428,13 +651,15 @@ Zotero.Server.LocalAPI.Items = class extends LocalAPIEndpoint {
|
|||
};
|
||||
|
||||
// Add basic library-wide item endpoints
|
||||
for (let topTrashPart of ['', '/top', '/trash']) {
|
||||
for (let trashPart of ['', '/trash']) {
|
||||
for (let topPart of ['', '/top']) {
|
||||
for (let tagsPart of ['', '/tags']) {
|
||||
for (let userGroupPart of ['/api/users/:userID', '/api/groups/:groupID']) {
|
||||
let path = userGroupPart + '/items' + topTrashPart + tagsPart;
|
||||
let path = userGroupPart + '/items' + trashPart + topPart + tagsPart;
|
||||
Zotero.Server.Endpoints[path] = Zotero.Server.LocalAPI.Items;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add collection-scoped item endpoints
|
||||
|
@ -451,6 +676,7 @@ for (let topPart of ['', '/top']) {
|
|||
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/top"] = 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;
|
||||
|
@ -468,6 +694,29 @@ Zotero.Server.Endpoints["/api/users/:userID/items/:itemKey"] = Zotero.Server.Loc
|
|||
Zotero.Server.Endpoints["/api/groups/:groupID/items/:itemKey"] = Zotero.Server.LocalAPI.Item;
|
||||
|
||||
|
||||
Zotero.Server.LocalAPI.ItemFile = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
run({ pathname, pathParams, libraryID }) {
|
||||
let item = Zotero.Items.getByLibraryAndKey(libraryID, pathParams.itemKey);
|
||||
if (!item) return _404;
|
||||
if (!item.isFileAttachment()) {
|
||||
return [400, 'text/plain', `Not a file attachment: ${item.key}`];
|
||||
}
|
||||
if (pathname.endsWith('/url')) {
|
||||
return [200, 'text/plain', item.getLocalFileURL()];
|
||||
}
|
||||
return [302, { 'Location': item.getLocalFileURL() }, ''];
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/users/:userID/items/:itemKey/file"] = Zotero.Server.LocalAPI.ItemFile;
|
||||
Zotero.Server.Endpoints["/api/groups/:groupID/items/:itemKey/file"] = Zotero.Server.LocalAPI.ItemFile;
|
||||
Zotero.Server.Endpoints["/api/users/:userID/items/:itemKey/file/view"] = Zotero.Server.LocalAPI.ItemFile;
|
||||
Zotero.Server.Endpoints["/api/groups/:groupID/items/:itemKey/file/view"] = Zotero.Server.LocalAPI.ItemFile;
|
||||
Zotero.Server.Endpoints["/api/users/:userID/items/:itemKey/file/view/url"] = Zotero.Server.LocalAPI.ItemFile;
|
||||
Zotero.Server.Endpoints["/api/groups/:groupID/items/:itemKey/file/view/url"] = Zotero.Server.LocalAPI.ItemFile;
|
||||
|
||||
|
||||
Zotero.Server.LocalAPI.Searches = class extends LocalAPIEndpoint {
|
||||
supportedMethods = ['GET'];
|
||||
|
||||
|
@ -498,7 +747,7 @@ Zotero.Server.LocalAPI.Tags = class extends LocalAPIEndpoint {
|
|||
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)];
|
||||
return { data: json };
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/users/:userID/tags"] = Zotero.Server.LocalAPI.Tags;
|
||||
|
@ -511,7 +760,7 @@ Zotero.Server.LocalAPI.Tag = class extends LocalAPIEndpoint {
|
|||
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)];
|
||||
return { data: json };
|
||||
}
|
||||
};
|
||||
Zotero.Server.Endpoints["/api/users/:userID/tags/:tag"] = Zotero.Server.LocalAPI.Tag;
|
||||
|
@ -533,10 +782,12 @@ async function toResponseJSON(dataObjectOrObjects, searchParams) {
|
|||
|
||||
// Ask the data object for its response JSON representation, updating URLs to point to localhost
|
||||
let dataObject = dataObjectOrObjects;
|
||||
let responseJSON = dataObject.toResponseJSON({
|
||||
let responseJSON = dataObject.toResponseJSONAsync
|
||||
? await dataObject.toResponseJSONAsync({
|
||||
apiURL: `http://localhost:${Zotero.Prefs.get('httpServer.port')}/api/`,
|
||||
includeGroupDetails: true
|
||||
});
|
||||
})
|
||||
: dataObject;
|
||||
|
||||
// Add includes and remove 'data' if not requested
|
||||
let include = searchParams.has('include') ? searchParams.get('include') : 'data';
|
||||
|
|
|
@ -30,6 +30,7 @@ Zotero.Server = new function() {
|
|||
201:"Created",
|
||||
204:"No Content",
|
||||
300:"Multiple Choices",
|
||||
304:"Not Modified",
|
||||
400:"Bad Request",
|
||||
403:"Forbidden",
|
||||
404:"Not Found",
|
||||
|
|
|
@ -131,6 +131,18 @@ describe("Local API Server", function () {
|
|||
assert.isTrue(response.links.up.href.includes('/api/'));
|
||||
assert.isTrue(response.links.enclosure.href.startsWith('file:'));
|
||||
});
|
||||
|
||||
it("should return file URL from /file/view/url", async function () {
|
||||
let { response } = await apiGet(`/users/0/items/${subcollectionAttachment.key}/file/view/url`, { responseType: 'text' });
|
||||
assert.isTrue(response.startsWith('file:'));
|
||||
});
|
||||
|
||||
// followRedirects: false not working?
|
||||
it.skip("should redirect to file URL from /file/view", async function () {
|
||||
let request = await apiGet(`/users/0/items/${subcollectionAttachment.key}/file/view`,
|
||||
{ responseType: 'text', followRedirects: false });
|
||||
assert.isTrue(request.getResponseHeader('Location').startsWith('file:'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("?itemType", function () {
|
||||
|
|
Loading…
Reference in a new issue