From 5e18337cc648bdd8604f7cc5792bc53acddd086c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Wed, 20 Nov 2024 14:43:34 +0200 Subject: [PATCH] Simple attachment saving via Connector (instead of Zotero) (#5148) --- chrome/content/zotero/xpcom/attachments.js | 86 ++- chrome/content/zotero/xpcom/file.js | 62 ++ .../zotero/xpcom/server/saveSession.js | 312 ++++++++ chrome/content/zotero/xpcom/server/server.js | 39 +- .../zotero/xpcom/server/server_connector.js | 729 ++++++++---------- .../xpcom/translation/translate_item.js | 562 ++++++++------ chrome/content/zotero/zotero.mjs | 1 + test/tests/serverTest.js | 10 +- 8 files changed, 1118 insertions(+), 683 deletions(-) create mode 100644 chrome/content/zotero/xpcom/server/saveSession.js diff --git a/chrome/content/zotero/xpcom/attachments.js b/chrome/content/zotero/xpcom/attachments.js index 441d62cf5f..6a14ec60a8 100644 --- a/chrome/content/zotero/xpcom/attachments.js +++ b/chrome/content/zotero/xpcom/attachments.js @@ -681,7 +681,7 @@ Zotero.Attachments = new function () { */ this.createURLAttachmentFromTemporaryStorageDirectory = async function (options) { if (!options.directory) throw new Error("'directory' not provided"); - if (!options.libraryID) throw new Error("'libraryID' not provided"); + if (!options.libraryID && !options.parentItemID) throw new Error("'libraryID' or 'parentItemID' not provided"); if (!options.filename) throw new Error("'filename' not provided"); if (!options.url) throw new Error("'directory' not provided"); if (!options.contentType) throw new Error("'contentType' not provided"); @@ -977,6 +977,90 @@ Zotero.Attachments = new function () { return attachmentItem; }); + + /** + * Save an attachment from a nsIInputStream + * + * @param {Object} options + * @param {String} options.url + * @param {nsIStream} options.stream - Stream with data + * @param {Integer} options.byteCount - Number of bytes in the stream, usually from the + * 'Content-Length' HTTP header. + * @param {String} options.contentType - Expected content type + * @param {Integer} [options.libraryID] Parent item ID if child attachment + * @param {Integer} [options.parentItemID] Parent item ID if child attachment + * Either options.libraryID or options.parentItemID are mandatory + * @param {Array} [options.collections] Collection ids or keys + * @param {String} [options.title] + * @param {Object} [options.saveOptions] - Options to pass to Zotero.Item::save() + * @return {Promise} - A promise for the created attachment item + */ + this.importFromNetworkStream = async (options) => { + if (!options.url) throw new Error("'url' not provided"); + if (!options.stream) throw new Error("'stream' not provided"); + if (!options.byteCount) throw new Error("'byteCount' not provided"); + if (!options.contentType) throw new Error("'contentType' not provided"); + Zotero.debug("Importing attachment item from network stream"); + + let url = options.url; + let stream = options.stream; + let contentType = options.contentType; + let libraryID = options.libraryID; + let parentItemID = options.parentItemID; + let collections = options.collections; + let title = options.title; + let saveOptions = options.saveOptions; + + if (!title) { + // TODO Better attachment name + title = Zotero.getString('itemFields.attachmentPDF'); + } + + if (parentItemID && collections) { + throw new Error("parentItemID and collections cannot both be provided"); + } + + let tmpDirectory = (await this.createTemporaryStorageDirectory()).path; + let destDirectory; + let attachmentItem; + try { + let filename = Zotero.File.truncateFileName(this._getFileNameFromURL(url, contentType), 100); + let tmpFile = OS.Path.join(tmpDirectory, filename); + await Zotero.File.putNetworkStream(tmpFile, stream, options.byteCount); + + attachmentItem = await this.createURLAttachmentFromTemporaryStorageDirectory({ + directory: tmpDirectory, + libraryID, + parentItemID, + title, + filename, + url, + contentType, + collections, + saveOptions + }); + } + catch (e) { + Zotero.debug(e, 1); + + // Clean up + try { + if (tmpDirectory) { + await OS.File.removeDir(tmpDirectory, { ignoreAbsent: true }); + } + if (destDirectory) { + await OS.File.removeDir(destDirectory, { ignoreAbsent: true }); + } + } + catch (e) { + Zotero.debug(e, 1); + } + + throw e; + } + + return attachmentItem; + }; /** diff --git a/chrome/content/zotero/xpcom/file.js b/chrome/content/zotero/xpcom/file.js index 3427a6e2cc..f59120414a 100644 --- a/chrome/content/zotero/xpcom/file.js +++ b/chrome/content/zotero/xpcom/file.js @@ -446,6 +446,68 @@ Zotero.File = new function(){ }); }; + /** + * Asynchronously writes data from an nsIAsyncInputStream to a file. + * + * Designed to handle input streams where data may not be + * immediately or fully available, such as network streams. + * + * @param {nsIInputStream} inputStream - The input stream to read from. This + * stream should implement nsIAsyncInputStream. + * @param {string} path - The file path where the data will be written. + * @param {number} byteCount - The expected number of bytes to write. + * + * @returns {Promise} A promise that resolves with the number of bytes + * written when the operation is complete, or rejects with an error + * if any issues occur during reading or writing. + */ + this.putNetworkStream = async function (path, stream, byteCount) { + return new Promise((resolve, reject) => { + let bytesRead = 0; + var os = FileUtils.openSafeFileOutputStream(new FileUtils.File(path)); + + let binaryInputStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(Ci.nsIBinaryInputStream); + binaryInputStream.setInputStream(stream); + + let readNextChunk = () => { + stream.asyncWait({ + onInputStreamReady: (input) => { + try { + // Check available data in the stream + let available = input.available(); + if (available > 0) { + os.write(binaryInputStream.readBytes(available), available); + bytesRead += available; + + if (bytesRead < byteCount) { + // Continue reading + readNextChunk(); + } + else { + // Finished writing all expected bytes + FileUtils.closeSafeFileOutputStream(os); + resolve(bytesRead); + } + } + else { + // No more data, finish the stream + FileUtils.closeSafeFileOutputStream(os); + resolve(bytesRead); + } + } + catch (e) { + os.close(); + reject(new Components.Exception("File write operation failed", e)); + } + } + }, 0, 0, null); + }; + + // Start reading the first chunk of data + readNextChunk(); + }); + }; + this.download = async function (uri, path) { var uriStr = uri.spec || uri; diff --git a/chrome/content/zotero/xpcom/server/saveSession.js b/chrome/content/zotero/xpcom/server/saveSession.js new file mode 100644 index 0000000000..8d91d06b21 --- /dev/null +++ b/chrome/content/zotero/xpcom/server/saveSession.js @@ -0,0 +1,312 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2024 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 ***** +*/ + +Zotero.Server.Connector.SessionManager = { + _sessions: new Map(), + + get: function (id) { + return this._sessions.get(id); + }, + + create: function (id, action, requestData) { + if (typeof id === 'undefined') { + id = Zotero.Utilities.randomString(); + } + if (this._sessions.has(id)) { + throw new Error(`Session ID ${id} exists`); + } + Zotero.debug(`Creating connector save session ${id}`); + var session = new Zotero.Server.Connector.SaveSession(id, action, requestData); + this._sessions.set(id, session); + this.gc(); + return session; + }, + + gc: function () { + // Delete sessions older than 10 minutes, or older than 1 minute if more than 10 sessions + var ttl = this._sessions.size >= 10 ? 60 : 600; + var deleteBefore = new Date() - ttl * 1000; + + for (let session of this._sessions) { + if (session.created < deleteBefore) { + this._session.delete(session.id); + } + } + } +}; + + + +Zotero.Server.Connector.SaveSession = class { + constructor(id, action, requestData) { + this.id = id; + this.created = new Date(); + this._action = action; + this._requestData = requestData; + this._items = {}; + + this._progressItems = {}; + this._orderedProgressItems = []; + } + + async saveItems(target) { + var { library, collection } = Zotero.Server.Connector.resolveTarget(target); + var data = this._requestData.data; + var headers = this._requestData.headers; + var cookieSandbox = data.uri + ? new Zotero.CookieSandbox( + null, + data.uri, + data.detailedCookies ? "" : data.cookie || "", + headers["User-Agent"] + ) + : null; + if (cookieSandbox && data.detailedCookies) { + cookieSandbox.addCookiesFromHeader(data.detailedCookies); + } + + var proxy = data.proxy && new Zotero.Proxy(data.proxy); + + this.itemSaver = new Zotero.Translate.ItemSaver({ + libraryID: library.libraryID, + collections: collection ? [collection.id] : undefined, + // All attachments come from the Connector + attachmentMode: Zotero.Translate.ItemSaver.ATTACHMENT_MODE_IGNORE, + forceTagType: 1, + referrer: data.uri, + cookieSandbox, + proxy + }); + let items = await this.itemSaver.saveItems(data.items, () => 0, () => 0); + // If more itemSaver calls are made, it means we are saving attachments explicitly (like + // a snapshot) and we don't want to ignore those. + this.itemSaver.attachmentMode = Zotero.Translate.ItemSaver.ATTACHMENT_MODE_DOWNLOAD; + items.forEach((item, index) => { + this.addItem(data.items[index].id, item); + }); + + return items; + } + + async saveSnapshot(target) { + var { library, collection } = Zotero.Server.Connector.resolveTarget(target); + var libraryID = library.libraryID; + var data = this._requestData.data; + + let title = data.title || data.url; + + // Create new webpage item + let item = new Zotero.Item("webpage"); + item.libraryID = libraryID; + item.setField("title", title); + item.setField("url", data.url); + item.setField("accessDate", "CURRENT_TIMESTAMP"); + if (collection) { + item.setCollections([collection.id]); + } + await item.saveTx(); + + // SingleFile snapshot may be coming later + this.addItem(data.url, item); + + return item; + } + + async addItem(key, item) { + return this.addItems({ [key]: item }); + } + + async addItems(items) { + this._items = Object.assign(this._items, items); + + // Update the items with the current target data, in case it changed since the save began + await this._updateItems(items); + } + + getItemByConnectorKey(key) { + return this._items[key]; + } + + // documentRecognizer doesn't return recognized items and it's complicated to make it + // do it, so we just retrieve the parent item which is a little hacky but does the job + getRecognizedItem() { + try { + return Object.values(this._items)[0].parentItem; + } + catch (_) {} + } + + remove() { + delete Zotero.Server.Connector.SessionManager._sessions[this.id]; + } + + /** + * Change the target data for this session and update any items that have already been saved + */ + async update(targetID, tags) { + var previousTargetID = this._currentTargetID; + this._currentTargetID = targetID; + this._currentTags = tags || ""; + + // Select new destination in collections pane + var zp = Zotero.getActiveZoteroPane(); + if (zp && zp.collectionsView) { + await zp.collectionsView.selectByID(targetID); + } + // If window is closed, select target collection re-open + else { + Zotero.Prefs.set('lastViewedFolder', targetID); + } + + // If moving from a non-filesEditable library to a filesEditable library, resave from + // original data, since there might be files that weren't saved or were removed + if (previousTargetID && previousTargetID != targetID) { + let { library: oldLibrary } = Zotero.Server.Connector.resolveTarget(previousTargetID); + let { library: newLibrary } = Zotero.Server.Connector.resolveTarget(targetID); + if (oldLibrary != newLibrary && !oldLibrary.filesEditable && newLibrary.filesEditable) { + // TODO + throw new Error("Changing from non-filesEditables to editable libraries not supported") + Zotero.debug("Resaving items to filesEditable library"); + if (this._action == 'saveItems' || this._action == 'saveSnapshot') { + // Delete old items + for (let item of Object.values(this._items)) { + await item.eraseTx(); + } + let actionUC = Zotero.Utilities.capitalize(this._action); + // saveItems has a different signature with the session as the first argument + let params = [targetID, this._requestData]; + if (this._action == 'saveItems') { + params.unshift(this); + } + let newItems = await Zotero.Server.Connector[actionUC].prototype[this._action].apply( + Zotero.Server.Connector[actionUC], params + ); + // saveSnapshot only returns a single item + if (this._action == 'saveSnapshot') { + newItems = [newItems]; + } + this._items = new Set(newItems); + } + } + } + + await this._updateItems(this._items); + + // If a single item was saved, select it (or its parent, if it now has one) + if (zp && zp.collectionsView && Object.values(this._items).length == 1) { + let item = Object.values(this._items)[0]; + item = item.isTopLevelItem() ? item : item.parentItem; + // Don't select if in trash + if (!item.deleted) { + await zp.selectItem(item.id); + } + } + } + + /** + * Update the passed items with the current target and tags + */ + _updateItems = Zotero.serial(async function (items) { + if (Object.values(items).length == 0) { + return; + } + + var { library, collection } = Zotero.Server.Connector.resolveTarget(this._currentTargetID); + var libraryID = library.libraryID; + + var tags = this._currentTags.trim(); + tags = tags ? tags.split(/\s*,\s*/).filter(x => x) : []; + + Zotero.debug("Updating items for connector save session " + this.id); + + for (let key in items) { + let item = items[key]; + if (item.libraryID != libraryID) { + let newItem = await item.moveToLibrary(libraryID); + this._items[key] = newItem; + } + + // If the item is now a child item (e.g., from Retrieve Metadata), update the + // parent item instead + if (!item.isTopLevelItem()) { + item = item.parentItem; + } + // Skip deleted items + if (!Zotero.Items.exists(item.id)) { + Zotero.debug(`Item ${item.id} in save session no longer exists`); + continue; + } + // Keep automatic tags + let originalTags = item.getTags().filter(tag => tag.type == 1); + item.setTags(originalTags.concat(tags)); + item.setCollections(collection ? [collection.id] : []); + await item.saveTx(); + } + + this._updateRecents(); + }); + + + _updateRecents() { + var targetID = this._currentTargetID; + try { + let numRecents = 7; + let recents = Zotero.Prefs.get('recentSaveTargets') || '[]'; + recents = JSON.parse(recents); + // If there's already a target from this session in the list, update it + for (let recent of recents) { + if (recent.sessionID == this.id) { + recent.id = targetID; + break; + } + } + // If a session is found with the same target, move it to the end without changing + // the sessionID. This could be the current session that we updated above or a different + // one. (We need to leave the old sessionID for the same target or we'll end up removing + // the previous target from the history if it's changed in the current one.) + let pos = recents.findIndex(r => r.id == targetID); + if (pos != -1) { + recents = [ + ...recents.slice(0, pos), + ...recents.slice(pos + 1), + recents[pos] + ]; + } + // Otherwise just add this one to the end + else { + recents = recents.concat([{ + id: targetID, + sessionID: this.id + }]); + } + recents = recents.slice(-1 * numRecents); + Zotero.Prefs.set('recentSaveTargets', JSON.stringify(recents)); + } + catch (e) { + Zotero.logError(e); + Zotero.Prefs.clear('recentSaveTargets'); + } + } +}; \ No newline at end of file diff --git a/chrome/content/zotero/xpcom/server/server.js b/chrome/content/zotero/xpcom/server/server.js index 4988777a2d..e3787a2f64 100755 --- a/chrome/content/zotero/xpcom/server/server.js +++ b/chrome/content/zotero/xpcom/server/server.js @@ -127,7 +127,7 @@ Zotero.Server.Headers = class { if (typeof name !== 'string') { return Reflect.has(target, name, receiver); } - return Reflect.has(target, name.toLowerCase(), receiver); + return Reflect.has(target, name.toLowerCase(), receiver); }, set(target, name, value, receiver) { return Reflect.set(target, name.toLowerCase(), value, receiver); @@ -137,6 +137,12 @@ Zotero.Server.Headers = class { }; +Zotero.Server.networkStreamToString = function (stream, length) { + let data = NetUtil.readInputStreamToString(stream, length); + return Zotero.Utilities.Internal.decodeUTF8(data); +}; + + Zotero.Server.RequestHandler = function (request, response) { this.body = ""; this.bodyLength = 0; @@ -159,8 +165,7 @@ Zotero.Server.RequestHandler.prototype._bodyData = function () { let data = null; if (this.bodyLength > 0) { if (PLAIN_TEXT_CONTENT_TYPES.has(this.contentType)) { - data = NetUtil.readInputStreamToString(this.request.bodyInputStream, this.bodyLength); - data = this.body = Zotero.Utilities.Internal.decodeUTF8(this.body); + this.body = data = Zotero.Server.networkStreamToString(this.request.bodyInputStream, this.bodyLength); } else if (this.contentType === 'multipart/form-data') { data = NetUtil.readInputStreamToString(this.request.bodyInputStream, this.bodyLength); @@ -227,19 +232,23 @@ Zotero.Server.RequestHandler.prototype.handleRequest = async function () { // Tell httpd that we will be constructing our own response // without its custom methods, asynchronously response.seizePower(); - + + let requestDebug = `${request.method} ${request.path} HTTP/${request.httpVersion}\n` // Parse headers into this.headers with lowercase names this.headers = new Zotero.Server.Headers(); for (let { data: name } of request.headers) { + requestDebug += `${name}: ${request.getHeader(name)}\n`; this.headers[name.toLowerCase()] = request.getHeader(name); } + Zotero.debug(requestDebug, 5); + if (this.headers.origin) { this.origin = this.headers.origin; } this.pathname = request.path; - this.query = "?" + request.queryString; + this.query = request.queryString; // get content-type var contentType = this.headers['content-type']; @@ -340,7 +349,7 @@ Zotero.Server.RequestHandler.prototype._processEndpoint = async function (method return; } - var decodedData = null; + var data = null; if (method === 'POST' && this.contentType) { // check that endpoint supports contentType var supportedDataTypes = endpoint.supportedDataTypes; @@ -353,7 +362,7 @@ Zotero.Server.RequestHandler.prototype._processEndpoint = async function (method // decode content-type post data if (this.contentType === "application/json") { try { - decodedData = JSON.parse(postData); + data = JSON.parse(postData); } catch(e) { this._requestFinished(this._generateResponse(400, "text/plain", "Invalid JSON provided\n")); @@ -361,13 +370,13 @@ Zotero.Server.RequestHandler.prototype._processEndpoint = async function (method } } else if (this.contentType === "application/x-www-form-urlencoded") { - decodedData = Zotero.Server.decodeQueryString(postData); + data = Zotero.Server.decodeQueryString(postData); } else if (postData) { - decodedData = postData; + data = postData; } else { - decodedData = this.request.bodyInputStream; + data = this.request.bodyInputStream; } } @@ -393,9 +402,9 @@ Zotero.Server.RequestHandler.prototype._processEndpoint = async function (method method, pathname: this.pathname, pathParams: this.pathParams, - searchParams: new URLSearchParams(this.query ? this.query.substring(1) : ''), + searchParams: new URLSearchParams(this.query || ''), headers: this.headers, - data: decodedData + data }); let result; if (maybePromise.then) { @@ -413,16 +422,16 @@ Zotero.Server.RequestHandler.prototype._processEndpoint = async function (method } // Two-parameter endpoint takes data and a callback else if (endpoint.init.length === 2) { - endpoint.init(decodedData, sendResponseCallback); + endpoint.init(data, sendResponseCallback); } // Three-parameter endpoint takes a URL, data, and a callback else { var url = { pathname: this.pathname, - searchParams: new URLSearchParams(this.query ? this.query.substring(1) : ''), + searchParams: new URLSearchParams(this.query || ''), userAgent: this.headers['user-agent'] }; - endpoint.init(url, decodedData, sendResponseCallback); + endpoint.init(url, data, sendResponseCallback); } } catch(e) { Zotero.debug(e); diff --git a/chrome/content/zotero/xpcom/server/server_connector.js b/chrome/content/zotero/xpcom/server/server_connector.js index 13c4eeef8c..71a93b9dd4 100644 --- a/chrome/content/zotero/xpcom/server/server_connector.js +++ b/chrome/content/zotero/xpcom/server/server_connector.js @@ -110,14 +110,15 @@ Zotero.Server.Connector = { return { library, collection, editable }; } }; +/* Zotero.Server.Connector.SessionManager = { _sessions: new Map(), - + get: function (id) { return this._sessions.get(id); }, - + create: function (id, action, requestData) { // Legacy connector if (!id) { @@ -133,12 +134,12 @@ Zotero.Server.Connector.SessionManager = { this.gc(); return session; }, - + gc: function () { // Delete sessions older than 10 minutes, or older than 1 minute if more than 10 sessions var ttl = this._sessions.size >= 10 ? 60 : 600; var deleteBefore = new Date() - ttl * 1000; - + for (let session of this._sessions) { if (session.created < deleteBefore) { this._session.delete(session.id); @@ -156,7 +157,7 @@ Zotero.Server.Connector.SaveSession = function (id, action, requestData) { this._action = action; this._requestData = requestData; this._items = new Set(); - + this._progressItems = {}; this._orderedProgressItems = []; }; @@ -171,7 +172,7 @@ Zotero.Server.Connector.SaveSession.prototype.onProgress = function (item, progr if (item.id === null || item.id === undefined) { throw new Error("ID not provided"); } - + // Child item if (item.parent) { let progressItem = this._progressItems[item.parent]; @@ -191,7 +192,7 @@ Zotero.Server.Connector.SaveSession.prototype.onProgress = function (item, progr a.progress = progress; return; } - + // Top-level item var o = this._progressItems[item.id]; if (!o) { @@ -239,7 +240,7 @@ Zotero.Server.Connector.SaveSession.prototype.addItems = async function (items) for (let item of items) { this._items.add(item); } - + // Update the items with the current target data, in case it changed since the save began await this._updateItems(items); }; @@ -248,14 +249,14 @@ Zotero.Server.Connector.SaveSession.prototype.remove = function () { delete Zotero.Server.Connector.SessionManager._sessions[this.id]; } -/** +/!** * Change the target data for this session and update any items that have already been saved - */ + *!/ Zotero.Server.Connector.SaveSession.prototype.update = async function (targetID, tags) { var previousTargetID = this._currentTargetID; this._currentTargetID = targetID; this._currentTags = tags || ""; - + // Select new destination in collections pane var zp = Zotero.getActiveZoteroPane(); if (zp && zp.collectionsView) { @@ -265,7 +266,7 @@ Zotero.Server.Connector.SaveSession.prototype.update = async function (targetID, else { Zotero.Prefs.set('lastViewedFolder', targetID); } - + // If moving from a non-filesEditable library to a filesEditable library, resave from // original data, since there might be files that weren't saved or were removed if (previousTargetID && previousTargetID != targetID) { @@ -295,9 +296,9 @@ Zotero.Server.Connector.SaveSession.prototype.update = async function (targetID, } } } - + await this._updateItems(this._items); - + // If a single item was saved, select it (or its parent, if it now has one) if (zp && zp.collectionsView && this._items.size == 1) { let item = Array.from(this._items)[0]; @@ -309,25 +310,25 @@ Zotero.Server.Connector.SaveSession.prototype.update = async function (targetID, } }; -/** +/!** * Update the passed items with the current target and tags - */ + *!/ Zotero.Server.Connector.SaveSession.prototype._updateItems = Zotero.serial(async function (items) { if (items.length == 0) { return; } - + var { library, collection, editable } = Zotero.Server.Connector.resolveTarget(this._currentTargetID); var libraryID = library.libraryID; - + var tags = this._currentTags.trim(); - tags = tags ? tags.split(/\s*,\s*/).filter(x => x) : []; - + tags = tags ? tags.split(/\s*,\s*!/).filter(x => x) : []; + Zotero.debug("Updating items for connector save session " + this.id); - + for (let item of items) { let newLibrary = Zotero.Libraries.get(library.libraryID); - + if (item.libraryID != libraryID) { let newItem = await item.moveToLibrary(libraryID); // Check pending attachments and switch parent ID @@ -340,7 +341,7 @@ Zotero.Server.Connector.SaveSession.prototype._updateItems = Zotero.serial(async this._items.delete(item); this._items.add(newItem); } - + // If the item is now a child item (e.g., from Retrieve Metadata), update the // parent item instead if (!item.isTopLevelItem()) { @@ -357,7 +358,7 @@ Zotero.Server.Connector.SaveSession.prototype._updateItems = Zotero.serial(async item.setCollections(collection ? [collection.id] : []); await item.saveTx(); } - + this._updateRecents(); }); @@ -403,6 +404,7 @@ Zotero.Server.Connector.SaveSession.prototype._updateRecents = function () { } }; +*/ /** * Lists all available translators, including code for translators that should be run on every page @@ -726,7 +728,7 @@ Zotero.Server.Connector.SaveItems.prototype = { * Either loads HTML into a hidden browser and initiates translation, or saves items directly * to the database */ - init: Zotero.Promise.coroutine(function* (requestData) { + init: async function (requestData) { var data = requestData.data; var { library, collection, editable } = Zotero.Server.Connector.getSaveTarget(); @@ -741,9 +743,10 @@ Zotero.Server.Connector.SaveItems.prototype = { ); } catch (e) { + Zotero.debug(e); return [409, "application/json", JSON.stringify({ error: "SESSION_EXISTS" })]; } - yield session.update(targetID); + await session.update(targetID); // Shouldn't happen as long as My Library exists if (!library.editable) { @@ -751,161 +754,193 @@ Zotero.Server.Connector.SaveItems.prototype = { return [500, "application/json", JSON.stringify({ libraryEditable: false })]; } - return new Zotero.Promise((resolve) => { - try { - this.saveItems( - session, - targetID, - requestData, - function (jsonItems, items) { - session.addItems(items); - // Only return the properties the connector needs - jsonItems = jsonItems.map((item) => { - let o = { - id: item.id, - title: item.title, - itemType: item.itemType, - contentType: item.mimeType, - mimeType: item.mimeType, // TODO: Remove - }; - if (item.attachments) { - o.attachments = item.attachments.map((attachment) => { - return { - id: session.id + '_' + attachment.id, // TODO: Remove prefix - title: attachment.title, - contentType: attachment.contentType, - mimeType: attachment.mimeType, // TODO: Remove - }; - }); - }; - return o; - }); - resolve([201, "application/json", JSON.stringify({ items: jsonItems })]); - } - ) - // Add items to session once all attachments have been saved - .then(function (items) { - session.addItems(items); - }); - } - catch (e) { - Zotero.logError(e); - session.remove(); - resolve(500); - } - }); - }), - - saveItems: async function (session, target, requestData, onTopLevelItemsDone) { - var { library, collection, editable } = Zotero.Server.Connector.resolveTarget(target); - var data = requestData.data; - var cookieSandbox = data.uri - ? new Zotero.CookieSandbox( - null, - data.uri, - data.detailedCookies ? "" : data.cookie || "", - requestData.headers["User-Agent"] - ) - : null; - if (cookieSandbox && data.detailedCookies) { - cookieSandbox.addCookiesFromHeader(data.detailedCookies); + try { + await session.saveItems(targetID); + return [201, "application/json"]; } - - var id = 1; - for (let item of data.items) { - if (!item.id) { - item.id = id++; - } - - if (item.attachments) { - for (let attachment of item.attachments) { - attachment.id = id++; - attachment.parent = item.id; - } - } - - // Add parent item to session progress without attachments, which are added later if - // they're saved. - let progressItem = Object.assign( - {}, - item, - { - attachments: [] - } - ); - session.onProgress(progressItem, 0); + catch (e) { + Zotero.logError(e); + session.remove(); + return 500; } - - var proxy = data.proxy && new Zotero.Proxy(data.proxy); - - // Save items - var itemSaver = new Zotero.Translate.ItemSaver({ - libraryID: library.libraryID, - collections: collection ? [collection.id] : undefined, - attachmentMode: Zotero.Translate.ItemSaver.ATTACHMENT_MODE_DOWNLOAD, - forceTagType: 1, - referrer: data.uri, - cookieSandbox, - proxy - }); - // This is a bit tricky. When saving items, the callback `onTopLevelItemsDone` will - // return the HTTP request to the connector. Then it may spend some time fetching - // PDFs. In the meantime, the connector will create a snapshot and send it along to - // the `saveSingleFile` endpoint, which quickly adds the data to the session and - // then saves the pending attachments, without removing them (we need them in case - // the session switches libraries and we need to save again). So the pending - // attachments exist and have already been saved by the time this `saveItems` - // promise resolves and we continue executing. So we save the number of existing - // attachments before that to prevent double saving. - let hadPendingAttachments = session.pendingAttachments.length > 0; - if (hadPendingAttachments) { - // If we have pending attachments then we are saving again by switching to - // a `filesEditable` library. So we clear the pendingAttachments since they - // get added again right below here in `saveItems` - session.pendingAttachments = []; - } - let items = await itemSaver.saveItems( - data.items, - function (attachment, progress, error) { - session.onProgress(attachment, progress, error); - }, - (itemsJSON, items) => { - itemsJSON.forEach(item => session.onProgress(item, 100)); - if (onTopLevelItemsDone) onTopLevelItemsDone(itemsJSON, items); - }, - function (parentItemID, attachment) { - session.pendingAttachments.push([parentItemID, attachment]); - } - ); - if (hadPendingAttachments) { - // If the session has snapshotContent already (from switching to a `filesEditable` library - // then we can save `pendingAttachments` now - if (data.snapshotContent) { - await itemSaver.saveSnapshotAttachments( - session.pendingAttachments, - data.snapshotContent, - function (attachment, progress, error) { - session.onProgress(attachment, progress, error); - }, - ); - } - // This means SingleFile in the Connector failed and we need to just go - // ahead and do our fallback save - else if (data.singleFile === false) { - itemSaver.saveSnapshotAttachments( - session.pendingAttachments, - false, - function (attachment, progress, error) { - session.onProgress(attachment, progress, error); - }, - ); - } - // Otherwise we are still waiting for SingleFile in Connector to finish - } - return items; - } + }, } +/** + * Gets the top-level item created for a standalone attachment + * + * Accepts: + * sessionID - A session ID previously passed to /saveItems + * Returns: + * 200 + */ +Zotero.Server.Connector.GetRecognizedItem = function () {}; +Zotero.Server.Endpoints["/connector/getRecognizedItem"] = Zotero.Server.Connector.GetRecognizedItem; +Zotero.Server.Connector.GetRecognizedItem.prototype = { + supportedMethods: ["POST"], + supportedDataTypes: ["*"], + permitBookmarklet: true, + + init: async function (requestData) { + const sessionID = requestData.data.sessionID; + if (!sessionID) { + return [400, "application/json", JSON.stringify({ error: "SESSION_ID_NOT_PROVIDED" })]; + } + + const session = Zotero.Server.Connector.SessionManager.get(sessionID); + if (!session) { + Zotero.debug("Can't find session " + sessionID, 1); + return [400, "application/json", JSON.stringify({ error: "SESSION_NOT_FOUND" })]; + } + + await session.autoRecognizePromise; + let item = session.getRecognizedItem(); + if (!item) { + return 204; + } + let jsonItem = { + title: item.getDisplayTitle(), + itemType: item.itemType, + }; + return [200, "application/json", JSON.stringify({ ...jsonItem })]; + } +}; + + +/** + * Saves a standalone attachment + * + * URI params: + * sessionID + * Expected headers: + * X-Metadata: + * - parentItemID + * - title + * - url + * Returns: + * 400 - Bad params + * 200 - Non-writable library + * 201 - Created + */ +Zotero.Server.Connector.SaveStandaloneAttachment = function () {}; +Zotero.Server.Endpoints["/connector/saveStandaloneAttachment"] = Zotero.Server.Connector.SaveStandaloneAttachment; +Zotero.Server.Connector.SaveStandaloneAttachment.prototype = { + supportedMethods: ["POST"], + supportedDataTypes: ["*"], + permitBookmarklet: true, + + init: async function (requestData) { + // Retrieve payload + if (!requestData.headers['X-Metadata']) { + return [400, "application/json", JSON.stringify({ error: "METADATA_NOT_PROVIDED" })]; + } + const metadata = JSON.parse(requestData.headers['X-Metadata']); + + const sessionID = metadata.sessionID || requestData.searchParams.get('sessionID'); + if (!sessionID) { + return [400, "application/json", JSON.stringify({ error: "SESSION_ID_NOT_PROVIDED" })]; + } + var { library, collection } = Zotero.Server.Connector.getSaveTarget(); + if (!library.filesEditable) { + return [200, 'text/plain', 'Library is not editable.']; + } + var libraryID = library.libraryID; + var targetID = collection ? collection.treeViewID : library.treeViewID; + + try { + var session = Zotero.Server.Connector.SessionManager.create( + sessionID, + 'saveStandaloneAttachment', + requestData + ); + } + catch (e) { + return [409, "application/json", JSON.stringify({ error: "SESSION_EXISTS" })]; + } + await session.update(targetID); + + // Save standalone attachment from stream + let item = await Zotero.Attachments.importFromNetworkStream({ + url: metadata.url, + libraryID, + collections: collection ? [collection.id] : undefined, + title: metadata.title, + contentType: requestData.headers['Content-Type'], + stream: requestData.data, + byteCount: requestData.headers['Content-Length'], + }); + session.addItem(metadata.url, item); + + let canRecognize = Zotero.RecognizeDocument.canRecognize(item); + if (canRecognize) { + // Automatically recognize PDF/EPUB + session.autoRecognizePromise = Zotero.RecognizeDocument.autoRecognizeItems([item]); + } + return [201, "application/json", JSON.stringify({ canRecognize })]; + } +}; + +/** + * Attaches an PDF/EPUB attachment to an item saved with /saveItems or /saveSnapshot + * + * URI params: + * sessionID + * Expected headers: + * X-Metadata: + * - parentItemID + * - title + * - url + * Returns: + * 400 - Bad params + * 200 - Non-writable library + * 201 - Created + */ +Zotero.Server.Connector.SaveAttachment = function () {}; +Zotero.Server.Endpoints["/connector/saveAttachment"] = Zotero.Server.Connector.SaveAttachment; +Zotero.Server.Connector.SaveAttachment.prototype = { + supportedMethods: ["POST"], + supportedDataTypes: ["*"], + permitBookmarklet: true, + + init: async function (requestData) { + // Retrieve payload + if (!requestData.headers['X-Metadata']) { + return [400, "application/json", JSON.stringify({ error: "METADATA_NOT_PROVIDED" })]; + } + const metadata = JSON.parse(requestData.headers['X-Metadata']); + + const sessionID = metadata.sessionID || requestData.searchParams.get('sessionID'); + if (!sessionID) { + return [400, "application/json", JSON.stringify({ error: "SESSION_ID_NOT_PROVIDED" })]; + } + + let session = Zotero.Server.Connector.SessionManager.get(sessionID); + if (!session) { + Zotero.debug("Can't find session " + sessionID, 1); + return [400, "application/json", JSON.stringify({ error: "SESSION_NOT_FOUND" })]; + } + + let { library } = Zotero.Server.Connector.getSaveTarget(); + if (!library.filesEditable) { + return [200, 'text/plain', 'Library files are not editable.']; + } + + // Save attachment based on provided parent id from stream + let parentItem = session.getItemByConnectorKey(metadata.parentItemID); + await Zotero.Attachments.importFromNetworkStream({ + url: metadata.url, + parentItemID: parentItem.id, + title: metadata.title, + contentType: requestData.headers['Content-Type'], + stream: requestData.data, + byteCount: requestData.headers['Content-Length'], + }); + + return 201; + } +}; + + /** * Attaches a singlefile attachment to an item saved with /saveItems or /saveSnapshot * If data.snapshotContent is empty, it means the save failed in the Connector @@ -933,8 +968,6 @@ Zotero.Server.Connector.SaveSingleFile.prototype = { * Save SingleFile snapshot to pending attachments */ init: async function (requestData) { - const { HiddenBrowser } = ChromeUtils.import('chrome://zotero/content/HiddenBrowser.jsm'); - // Retrieve payload let data = requestData.data; @@ -948,78 +981,33 @@ Zotero.Server.Connector.SaveSingleFile.prototype = { return [400, "application/json", JSON.stringify({ error: "SESSION_NOT_FOUND" })]; } - let snapshotContent = data.snapshotContent; - - if (!snapshotContent) { - // Connector SingleFile has failed so if we re-save attachments (via - // updateSession) then we want to inform saveItems and saveSnapshot that they - // do not need to use pendingAttachments because those have failed. - session._requestData.data.singleFile = false; - - for (let [_parentItemID, attachment] of session.pendingAttachments) { - session.onProgress(attachment, false); - } - - return [200, 'text/plain', 'No snapshot content attached.']; - } - - // Add to session data, in case `saveSnapshot` is called again by the session - session.addSnapshotContent(snapshotContent); - // We do this after adding to session because if we switch to a `filesEditable` // library we need to have access to the snapshotContent. - let { library, collection } = Zotero.Server.Connector.getSaveTarget(); + let { library } = Zotero.Server.Connector.getSaveTarget(); if (!library.filesEditable) { - session.savingDone = true; - - return [200, 'text/plain', 'Library is not editable.']; + return [200, 'text/plain', 'Library files are not editable.']; } - // Retrieve all items in the session that need a snapshot + // We only save the snapshot in single-item cases if (session._action === 'saveSnapshot') { - await Zotero.Promise.all( - session.pendingAttachments.map((pendingAttachment) => { - return Zotero.Attachments.importFromSnapshotContent({ - title: data.title, - url: data.url, - parentItemID: pendingAttachment[0], - snapshotContent - }); - }) - ); + const parentItemID = session.getItemByConnectorKey(data.url).id; + // Just saves the snapshot straight up + await Zotero.Attachments.importFromSnapshotContent({ + title: data.title, + url: data.url, + parentItemID, + snapshotContent: data.snapshotContent + }); } else if (session._action === 'saveItems') { - var cookieSandbox = data.uri - ? new Zotero.CookieSandbox( - null, - data.uri, - data.detailedCookies ? "" : data.cookie || "", - requestData.headers["User-Agent"] - ) - : null; - if (cookieSandbox && data.detailedCookies) { - cookieSandbox.addCookiesFromHeader(data.detailedCookies); - } - - let proxy = data.proxy && new Zotero.Proxy(data.proxy); - - let itemSaver = new Zotero.Translate.ItemSaver({ - libraryID: library.libraryID, - collections: collection ? [collection.id] : undefined, - attachmentMode: Zotero.Translate.ItemSaver.ATTACHMENT_MODE_DOWNLOAD, - forceTagType: 1, - referrer: data.uri, - cookieSandbox, - proxy + const parentItemID = session.getItemByConnectorKey(data.items[0].id).id; + // Deproxifies and does some other attachment preprocessing + await session.itemSaver.saveSnapshotAttachments({ + title: data.title, + url: data.url, + parentItemID, + snapshotContent: data.snapshotContent }); - - await itemSaver.saveSnapshotAttachments( - session.pendingAttachments, - snapshotContent, - function (attachment, progress, error) { - session.onProgress(attachment, progress, error); - }, - ); } return 201; @@ -1027,7 +1015,7 @@ Zotero.Server.Connector.SaveSingleFile.prototype = { }; /** - * Either creates a webpage item or a PDF/EPUB top-level item in Zotero + * Creates a webpage item top-level item in Zotero * Called by the Connector when no translators are detected on the page * * Accepts: @@ -1050,7 +1038,7 @@ Zotero.Server.Connector.SaveSnapshot.prototype = { init: async function (requestData) { var data = requestData.data; - var { library, collection, editable } = Zotero.Server.Connector.getSaveTarget(); + var { library, collection } = Zotero.Server.Connector.getSaveTarget(); var targetID = collection ? collection.treeViewID : library.treeViewID; try { @@ -1061,6 +1049,7 @@ Zotero.Server.Connector.SaveSnapshot.prototype = { ); } catch (e) { + Zotero.debug(e); return [409, "application/json", JSON.stringify({ error: "SESSION_EXISTS" })]; } await session.update(collection ? collection.treeViewID : library.treeViewID); @@ -1072,125 +1061,85 @@ Zotero.Server.Connector.SaveSnapshot.prototype = { } try { - var item = await this.saveSnapshot(targetID, requestData); - await session.addItem(item); + await session.saveSnapshot(targetID); } catch (e) { Zotero.logError(e); return 500; } - let attachments = []; - let hasAttachments = !item.isAttachment() && item.getAttachments().length; - if (hasAttachments) { - attachments = [{mimeType: "text/html", title: data.title, url: data.url}]; - } - - return [201, - "application/json", - JSON.stringify({ saveSingleFile: !data.skipSnapshot && !data.pdf && data.singleFile, attachments })]; - }, - - /* - * Perform saving the snapshot - * - * Note: this function signature cannot change because it can also be called by - * updateSession (`Zotero.Server.Connector.SaveSession.prototype.update`). - */ - saveSnapshot: async function (target, requestData) { - var { library, collection, editable } = Zotero.Server.Connector.resolveTarget(target); - var libraryID = library.libraryID; - var data = requestData.data; - - var cookieSandbox = data.url - ? new Zotero.CookieSandbox( - null, - data.url, - data.detailedCookies ? "" : data.cookie || "", - requestData.headers["User-Agent"] - ) - : null; - if (cookieSandbox && data.detailedCookies) { - cookieSandbox.addCookiesFromHeader(data.detailedCookies); - } - - if (data.pdf && library.filesEditable) { - let item = await Zotero.Attachments.importFromURL({ - libraryID, - url: data.url, - referrer: data.referrer, - collections: collection ? [collection.id] : undefined, - contentType: "application/pdf", - cookieSandbox - }); - - // Automatically recognize PDF/EPUB - Zotero.RecognizeDocument.autoRecognizeItems([item]); - - return item; - } - - if (data.html) { - var parser = new DOMParser(); - var doc = parser.parseFromString(`${data.html}`, 'text/html'); - doc = Zotero.HTTP.wrapDocument(doc, data.url); - var title = doc.title; - } else { - title = data.title || data.url; - } - - // Create new webpage item - let item = new Zotero.Item("webpage"); - item.libraryID = libraryID; - item.setField("title", title); - item.setField("url", data.url); - item.setField("accessDate", "CURRENT_TIMESTAMP"); - if (collection) { - item.setCollections([collection.id]); - } - var itemID = await item.saveTx(); - - // Save snapshot - if (!data.skipSnapshot) { - // If called from session update, requestData may already have SingleFile data - if (library.filesEditable && data.snapshotContent) { - await Zotero.Attachments.importFromSnapshotContent({ - title: data.title, - url: data.url, - parentItemID: itemID, - snapshotContent: data.snapshotContent - }); - } - // Otherwise, connector will POST SingleFile data at later time - // We want this data regardless of `library.filesEditable` because if we - // start on a non-filesEditable library and switch to one, we won't have a - // pending attachment - else if (data.hasOwnProperty('singleFile')) { - let session = Zotero.Server.Connector.SessionManager.get(data.sessionID); - session.pendingAttachments = [ - [itemID, { title: data.title, url: data.url }] - ]; - } - else if (library.filesEditable) { - // Old connector will not use SingleFile so importFromURL now - await Zotero.Attachments.importFromURL({ - libraryID, - url: data.url, - referrer: data.referrer, - title, - parentItemID: itemID, - contentType: "text/html", - cookieSandbox - }); - } - } - - return item; + return [201, "application/json"]; } }; + /** + * Checks if the item has OA attachments (in case PDF saving in connector failed). + * Also checks custom resolvers. * + * Accepts: + * sessionID - A session ID previously passed to /saveItems + * itemID - The ID of the item to save alternative attachment for + */ +Zotero.Server.Connector.HasOAAttachments = function () {}; +Zotero.Server.Endpoints["/connector/hasOAAttachments"] = Zotero.Server.Connector.HasOAAttachments; +Zotero.Server.Connector.HasOAAttachments.prototype = { + supportedMethods: ["POST"], + supportedDataTypes: ["application/json"], + permitBookmarklet: true, + init: async function (requestData) { + let data = requestData.data; + let session = Zotero.Server.Connector.SessionManager.get(data.sessionID); + if (!session) { + Zotero.debug("Can't find session " + data.sessionID, 1); + return [400, "application/json", JSON.stringify({ error: "SESSION_NOT_FOUND" })]; + } + let item = session.getItemByConnectorKey(data.itemID); + let resolvers = Zotero.Attachments.getFileResolvers(item, ['oa', 'custom'], true); + return [200, "application/json", JSON.stringify(resolvers.length > 0)]; + } +} + + +/** + * Accepts: + * sessionID - A session ID previously passed to /saveItems + * itemID - The ID of the item to save alternative attachment for + * + * Returns: + * 400 - Bad params + * 201 - Created and attachment title + * 503 - Failed to save + */ +Zotero.Server.Connector.SaveOAAttachment = function () {}; +Zotero.Server.Endpoints["/connector/saveOAAttachment"] = Zotero.Server.Connector.SaveOAAttachment; +Zotero.Server.Connector.SaveOAAttachment.prototype = { + supportedMethods: ["POST"], + supportedDataTypes: ["application/json"], + permitBookmarklet: true, + init: async function (requestData) { + let data = requestData.data; + let session = Zotero.Server.Connector.SessionManager.get(data.sessionID); + if (!session) { + Zotero.debug("Can't find session " + data.sessionID, 1); + return [400, "application/json", JSON.stringify({ error: "SESSION_NOT_FOUND" })]; + } + let item = session.getItemByConnectorKey(data.itemID); + let resolvers = Zotero.Attachments.getFileResolvers(item, ['oa', 'custom'], true); + + let attachment = await Zotero.Attachments.addFileFromURLs(item, resolvers); + + if (attachment) { + return [201, "text/plain", attachment.getDisplayTitle()]; + } + else { + return [503, "text/plain", "Failed to save an attachment"]; + } + } +} + +/** + * * * Accepts: * sessionID - A session ID previously passed to /saveItems @@ -1238,55 +1187,6 @@ Zotero.Server.Connector.UpdateSession.prototype = { } }; -Zotero.Server.Connector.SessionProgress = function() {}; -Zotero.Server.Endpoints["/connector/sessionProgress"] = Zotero.Server.Connector.SessionProgress; -Zotero.Server.Connector.SessionProgress.prototype = { - supportedMethods: ["POST"], - supportedDataTypes: ["application/json"], - permitBookmarklet: true, - - init: async function (requestData) { - var data = requestData.data - - if (!data.sessionID) { - return [400, "application/json", JSON.stringify({ error: "SESSION_ID_NOT_PROVIDED" })]; - } - - var session = Zotero.Server.Connector.SessionManager.get(data.sessionID); - if (!session) { - Zotero.debug("Can't find session " + data.sessionID, 1); - return [400, "application/json", JSON.stringify({ error: "SESSION_NOT_FOUND" })]; - } - - return [ - 200, - "application/json", - JSON.stringify({ - items: session.getAllProgress() - .map((item) => { - var newItem = Object.assign({}, item); - if (item.attachments) { - newItem.attachments = item.attachments.map((attachment) => { - return Object.assign( - {}, - attachment, - // Prefix id with 'sessionID_' - // TODO: Remove this once support for /attachmentProgress is - // removed and we stop prefixing the ids in the /saveItems - // response - { - id: session.id + '_' + attachment.id - } - ); - }); - } - return newItem; - }), - done: session.isSavingDone() - }) - ]; - } -}; Zotero.Server.Connector.DelaySync = function () {}; Zotero.Server.Endpoints["/connector/delaySync"] = Zotero.Server.Connector.DelaySync; @@ -1315,8 +1215,12 @@ Zotero.Server.Connector.Import.prototype = { permitBookmarklet: false, init: async function (requestData) { + let dataString = requestData.data; + if (requestData.data instanceof Ci.nsIInputStream) { + dataString = Zotero.Server.networkStreamToString(dataString, requestData.headers['content-length']); + } let translate = new Zotero.Translate.Import(); - translate.setString(requestData.data); + translate.setString(dataString); let translators = await translate.getTranslators(); if (!translators || !translators.length) { return 400; @@ -1335,6 +1239,7 @@ Zotero.Server.Connector.Import.prototype = { var session = Zotero.Server.Connector.SessionManager.create(requestData.searchParams.get('session')); } catch (e) { + Zotero.debug(e); return [409, "application/json", JSON.stringify({ error: "SESSION_EXISTS" })]; } await session.update(collection ? collection.treeViewID : library.treeViewID); @@ -1348,7 +1253,9 @@ Zotero.Server.Connector.Import.prototype = { skipSelect: false } }); - session.addItems(items); + items.forEach((item, index) => { + session.addItem(items[index].id, item); + }); return [201, "application/json", JSON.stringify(items)]; } @@ -1369,9 +1276,13 @@ Zotero.Server.Connector.InstallStyle.prototype = { permitBookmarklet: false, init: Zotero.Promise.coroutine(function* (requestData) { + let dataString = requestData.data; + if (requestData.data instanceof Ci.nsIInputStream) { + dataString = Zotero.Server.networkStreamToString(dataString, requestData.headers['content-length']); + } try { - var { styleTitle, styleID } = yield Zotero.Styles.install( - requestData.data, requestData.searchParams.get('origin') || null, true + var { styleTitle } = yield Zotero.Styles.install( + dataString, requestData.searchParams.get('origin') || null, true ); } catch (e) { return [400, "text/plain", e.message]; @@ -1409,7 +1320,8 @@ Zotero.Server.Connector.GetTranslatorCode.prototype = { } /** - * Get selected collection + * Returns the full serialized collection tree (excluding non-editable libraries) + * and the selected collection tree item. * * Accepts: * Nothing @@ -1437,6 +1349,7 @@ Zotero.Server.Connector.GetSelectedCollection.prototype = { libraryID: library.libraryID, libraryName: library.name, libraryEditable: library.editable, + filesEditable: library.filesEditable, editable }; @@ -1460,11 +1373,13 @@ Zotero.Server.Connector.GetSelectedCollection.prototype = { { id: library.treeViewID, name: library.name, + filesEditable: library.filesEditable, level: 0 }, ...Zotero.Collections.getByLibrary(library.libraryID, true).map(c => ({ id: c.treeViewID, name: c.name, + filesEditable: library.filesEditable, level: c.level + 1 || 1 // Added by Zotero.Collections._getByContainer() })) ); @@ -1599,6 +1514,8 @@ Zotero.Server.Connector.Ping.prototype = { let response = { prefs: { automaticSnapshots: Zotero.Prefs.get('automaticSnapshots'), + downloadAssociatedFiles: Zotero.Prefs.get("downloadAssociatedFiles"), + supportsAttachmentUpload: true, googleDocsAddNoteEnabled: true, googleDocsCitationExplorerEnabled: false, translatorsHash, diff --git a/chrome/content/zotero/xpcom/translation/translate_item.js b/chrome/content/zotero/xpcom/translation/translate_item.js index eed6160695..924b687193 100644 --- a/chrome/content/zotero/xpcom/translation/translate_item.js +++ b/chrome/content/zotero/xpcom/translation/translate_item.js @@ -59,6 +59,7 @@ Zotero.Translate.ItemSaver = function(options) { this._referrer = options.referrer; this._cookieSandbox = options.cookieSandbox; this._proxy = options.proxy; + this._itemToJSONItem = new Map(); // the URI to which other URIs are assumed to be relative if(typeof options.baseURI === "object" && options.baseURI instanceof Components.interfaces.nsIURI) { @@ -82,6 +83,7 @@ Zotero.Translate.ItemSaver.PRIMARY_ATTACHMENT_TYPES = new Set([ ]); Zotero.Translate.ItemSaver.prototype = { + /** * Saves items to Standalone or the server * @param {Object[]} jsonItems - Items in Zotero.Item.toArray() format @@ -90,21 +92,17 @@ Zotero.Translate.ItemSaver.prototype = { * on failure or attachmentCallback(attachment, progressPercent) periodically during saving. * @param {Function} [itemsDoneCallback] A callback that is called once all top-level items are * done saving with a list of items. Will include saved notes, but exclude attachments. - * @param {Function} [pendingAttachmentsCallback] A callback that is called for every - * pending attachment to an item. pendingAttachmentsCallback(parentItemID, jsonAttachment) */ - saveItems: async function (jsonItems, attachmentCallback, itemsDoneCallback, pendingAttachmentsCallback) { + saveItems: async function (jsonItems, attachmentCallback, itemsDoneCallback) { var items = []; var standaloneAttachments = []; var childAttachments = []; - var jsonByItem = new Map(); await Zotero.DB.executeTransaction(async function () { for (let jsonItem of jsonItems) { jsonItem = Object.assign({}, jsonItem); let item; - let itemID; // Type defaults to "webpage" let type = jsonItem.itemType || "webpage"; @@ -121,84 +119,25 @@ Zotero.Translate.ItemSaver.prototype = { continue; } else { - item = new Zotero.Item(type); - item.libraryID = this._libraryID; - if (jsonItem.creators) this._cleanCreators(jsonItem.creators); - if (jsonItem.tags) jsonItem.tags = this._cleanTags(jsonItem.tags); - - if (jsonItem.accessDate == 'CURRENT_TIMESTAMP') { - jsonItem.accessDate = Zotero.Date.dateToISO(new Date()); - } - - item.fromJSON(this._copyJSONItemForImport(jsonItem)); - - // deproxify url - if (this._proxy && jsonItem.url) { - let url = this._proxy.toProper(jsonItem.url); - Zotero.debug(`Deproxifying item url ${jsonItem.url} with scheme ${this._proxy.scheme} to ${url}`, 5); - item.setField('url', url); - } - - if (this._collections) { - item.setCollections(this._collections); - } - - // save item - itemID = await item.save(this._saveOptions); - - // handle notes - if (jsonItem.notes) { - for (let note of jsonItem.notes) { - await this._saveNote(note, itemID); - } - } + item = await this._saveItem(jsonItem, type); - // handle attachments - if (jsonItem.attachments) { - let attachmentsToSave = []; - let foundPrimary = false; - for (let jsonAttachment of jsonItem.attachments) { - if (!this._canSaveAttachment(jsonAttachment)) { - continue; - } - - // The first PDF/EPUB is the primary one. If that one fails to download, - // we might check for an open-access PDF below. - if (Zotero.Translate.ItemSaver.PRIMARY_ATTACHMENT_TYPES.has(jsonAttachment.mimeType) - && !foundPrimary) { - jsonAttachment.isPrimary = true; - foundPrimary = true; - } - attachmentsToSave.push(jsonAttachment); - attachmentCallback(jsonAttachment, 0); - if (jsonAttachment.singleFile) { - // SingleFile attachments are saved in 'saveSingleFile' - // connector endpoint - if (pendingAttachmentsCallback) { - pendingAttachmentsCallback(itemID, jsonAttachment); - } - continue; - } - childAttachments.push([jsonAttachment, itemID]); - } - jsonItem.attachments = attachmentsToSave; - } - - // handle see also - this._handleRelated(jsonItem, item); + // process attachments + let attachments = this._processChildAttachments(jsonItem, attachmentCallback); + attachments.forEach(attachment => childAttachments.push([attachment, item.id])); } // Add to new item list items.push(item); - jsonByItem.set(item, jsonItem); + this._itemToJSONItem.set(item, jsonItem); } }.bind(this)); + // Done saving top-level items. Call the callback so that UI code can update if (itemsDoneCallback) { - itemsDoneCallback(items.map(item => jsonByItem.get(item)), items); + itemsDoneCallback(items.map(item => this._itemToJSONItem.get(item)), items); } - // Save standalone attachments + // Download standalone attachments for (let jsonItem of standaloneAttachments) { let item = await this._saveAttachment(jsonItem, null, attachmentCallback); if (item) { @@ -210,45 +149,21 @@ Zotero.Translate.ItemSaver.prototype = { // open-access PDFs. There's no guarantee that either translated PDFs or OA PDFs will // successfully download, but this lets us update the progress window sooner with // possible downloads. - // + this._openAccessPDFURLs = new Map(); + // TODO: Separate pref? var shouldDownloadOAPDF = this.attachmentMode == Zotero.Translate.ItemSaver.ATTACHMENT_MODE_DOWNLOAD - && Zotero.Prefs.get('downloadAssociatedFiles'); - var openAccessPDFURLs = new Map(); + && Zotero.Prefs.get('downloadAssociatedFiles'); if (shouldDownloadOAPDF) { for (let item of items) { - let jsonItem = jsonByItem.get(item); - - // Skip items with translated PDF attachments - if (jsonItem.attachments - && jsonItem.attachments.some(x => Zotero.Translate.ItemSaver.PRIMARY_ATTACHMENT_TYPES.has(x.mimeType))) { - continue; - } - - try { - let resolvers = Zotero.Attachments.getPDFResolvers(item, ['oa']); - if (!resolvers.length) { - openAccessPDFURLs.set(item, []); - continue; - } - let urlObjects = await resolvers[0](); - openAccessPDFURLs.set(item, urlObjects); - // If there are possible URLs, create a status line for the PDF - if (urlObjects.length) { - let title = Zotero.getString('findPDF.openAccessPDF'); - let jsonAttachment = this._makeJSONAttachment(jsonItem.id, title); - if (!jsonItem.attachments) jsonItem.attachments = []; - jsonItem.attachments.push(jsonAttachment); - attachmentCallback(jsonAttachment, 0); - } - } - catch (e) { - Zotero.logError(e); + let urlObjects = await this._getOpenAccessPDFURLs(item, attachmentCallback); + if (urlObjects) { + this._openAccessPDFURLs.set(item, urlObjects); } } } - // Save translated child attachments, and keep track of whether the save was successful + // Save translated child attachments var itemIDsWithPrimaryAttachments = new Set(); for (let [jsonAttachment, parentItemID] of childAttachments) { let attachment = await this._saveAttachment( @@ -267,93 +182,15 @@ Zotero.Translate.ItemSaver.prototype = { } } - // If a translated PDF attachment wasn't saved successfully, either because there wasn't - // one or there was but it failed, look for another PDF (if enabled) if (shouldDownloadOAPDF) { + // If a translated PDF attachment wasn't saved successfully, either because there wasn't + // one or there was but it failed, look for another PDF (if enabled) for (let item of items) { // Already have a primary attachment from translation if (itemIDsWithPrimaryAttachments.has(item.id)) { continue; } - - let jsonItem = jsonByItem.get(item); - // Reuse the existing status line if there is one. This could be a failed - // translator attachment or a possible OA PDF found above. - // Explicitly check that the attachment is a PDF, not just any primary type, - // since we're reusing it for a PDF attachment. - let jsonAttachment = jsonItem.attachments && jsonItem.attachments.find( - x => x.mimeType == 'application/pdf' && x.isPrimary - ); - - // If no translated, no OA, and no custom, don't show a line - // If no translated and potential OA, show "Open-Access PDF" - // If no translated, no OA, but custom, show custom when it starts - // If translated fails and potential OA, show "Open-Access PDF" - // If translated fails, no OA, no custom, fail original - // If translated fails, no OA, but custom, change to custom when it starts - let resolvers = openAccessPDFURLs.get(item); - // No translated PDF, so we checked for OA PDFs above - if (resolvers) { - // Add custom resolvers - resolvers.push(...Zotero.Attachments.getPDFResolvers(item, ['custom'], true)); - - // No translated, no OA, no custom, no status line - if (!resolvers.length) { - continue; - } - - // No translated, no OA, just potential custom, so create a status line - if (!jsonAttachment) { - jsonAttachment = this._makeJSONAttachment( - jsonItem.id, Zotero.getString('findPDF.searchingForAvailableFiles') - ); - } - } - // There was a translated PDF, so we didn't check for OA PDFs yet and didn't - // update the status line - else { - // Look for OA PDFs now - resolvers = Zotero.Attachments.getPDFResolvers(item, ['oa']); - if (resolvers.length) { - resolvers = await resolvers[0](); - } - - // Add custom resolvers - resolvers.push(...Zotero.Attachments.getPDFResolvers(item, ['custom'], true)); - - // Failed translated, no OA, no custom, so fail the existing translator line - if (!resolvers.length) { - attachmentCallback(jsonAttachment, false); - continue; - } - } - - let attachment; - try { - attachment = await Zotero.Attachments.addFileFromURLs( - item, - resolvers, - { - // When a new access method starts, update the status line - onAccessMethodStart: (method) => { - jsonAttachment.title = this._getPDFTitleForAccessMethod(method); - attachmentCallback(jsonAttachment, 0); - } - } - ); - } - catch (e) { - Zotero.logError(e); - attachmentCallback(jsonAttachment, false, e); - continue; - } - - if (attachment) { - attachmentCallback(jsonAttachment, 100); - } - else { - attachmentCallback(jsonAttachment, false, "PDF not found"); - } + await this.saveOpenAccessAttachment(item, attachmentCallback); } } @@ -364,23 +201,231 @@ Zotero.Translate.ItemSaver.prototype = { /** * Save pending snapshot attachments to disk and library * - * @param {Array} pendingAttachments - A list of snapshot attachments - * @param {Object} content - Snapshot content from SingleFile - * @param {Function} attachmentCallback - Callback with progress of attachments + * @param {Object} options - A list of snapshot attachments + * - title {String} + * - url {String} + * - parentItemID {Number} + * - snapshotContent {String} */ - saveSnapshotAttachments: Zotero.Promise.coroutine(function* (pendingAttachments, snapshotContent, attachmentCallback) { - for (let [parentItemID, attachment] of pendingAttachments) { - Zotero.debug('Saving pending attachment: ' + JSON.stringify(attachment)); - if (snapshotContent) { - attachment.snapshotContent = snapshotContent; - } - yield this._saveAttachment( + saveSnapshotAttachments: async function (options) { + let { title, url, parentItemID, snapshotContent } = options; + let attachment = { title, url }; + Zotero.debug('Saving pending attachment: ' + JSON.stringify(attachment)); + if (snapshotContent) { + attachment.snapshotContent = snapshotContent; + } + new Promise(async (resolve, reject) => { + await this._saveAttachment( attachment, parentItemID, - attachmentCallback + (attachment, progress, e) => { + if (e) reject(e); + resolve(progress); + } + ); + }); + }, + + + async _saveItem(jsonItem, type) { + let itemID; + let item = new Zotero.Item(type); + item.libraryID = this._libraryID; + if (jsonItem.creators) this._cleanCreators(jsonItem.creators); + if (jsonItem.tags) jsonItem.tags = this._cleanTags(jsonItem.tags); + + if (jsonItem.accessDate == 'CURRENT_TIMESTAMP') { + jsonItem.accessDate = Zotero.Date.dateToISO(new Date()); + } + + item.fromJSON(this._copyJSONItemForImport(jsonItem)); + + // deproxify url + if (this._proxy && jsonItem.url) { + let url = this._proxy.toProper(jsonItem.url); + Zotero.debug(`Deproxifying item url ${jsonItem.url} with scheme ${this._proxy.scheme} to ${url}`, 5); + item.setField('url', url); + } + + // save item + if (this._collections) { + item.setCollections(this._collections); + } + itemID = await item.save(this._saveOptions); + + // handle notes + if (jsonItem.notes) { + for (let note of jsonItem.notes) { + await this._saveNote(note, itemID); + } + } + + // handle see also + this._handleRelated(jsonItem, item); + return item; + }, + + + /** + * Processes attachments to be saved either via Zotero or externally (Connector) + * + * Calls attachment callbacks for initial attachment progress (0) + */ + _processChildAttachments(jsonItem, attachmentCallback) { + let childAttachments = []; + + let foundPrimary = false; + // Attachments to be saved within Zotero + if (jsonItem.attachments) { + let attachmentsToSave = []; + for (let jsonAttachment of jsonItem.attachments) { + if (!this._canSaveAttachment(jsonAttachment)) { + continue; + } + + // The first PDF/EPUB is the primary one. If that one fails to download, + // we might check for an open-access PDF below. + if (Zotero.Translate.ItemSaver.PRIMARY_ATTACHMENT_TYPES.has(jsonAttachment.mimeType) + && !foundPrimary) { + jsonAttachment.isPrimary = true; + foundPrimary = true; + } + attachmentsToSave.push(jsonAttachment); + attachmentCallback(jsonAttachment, 0); + childAttachments.push(jsonAttachment); + } + + jsonItem.attachments = attachmentsToSave; + } + return childAttachments; + }, + + /** + * Gets a list of OA PDF URLs for items that did not receive a PDF attachment + * from the translator + * + * Calls attachmentCallback to update UI + * @param items + * @param attachmentCallback + * @returns {Promise>} + * @private + */ + async _getOpenAccessPDFURLs(item, attachmentCallback) { + let jsonItem = this._itemToJSONItem.get(item); + let urlObjects = []; + + // Has a primary attachment or a pending (from Connector) primary attachment + if (jsonItem.attachments?.some(x => Zotero.Translate.ItemSaver.PRIMARY_ATTACHMENT_TYPES.has(x.mimeType)) + || jsonItem.pendingPrimaryAttachment) { + return null; + } + + // If no primary attachments available look for an OA one and call attachmentCallback to update UI + try { + let resolvers = Zotero.Attachments.getPDFResolvers(item, ['oa']); + if (!resolvers.length) { + return urlObjects; + } + urlObjects = await resolvers[0](); + // If there are possible URLs, create a status line for the PDF + if (urlObjects.length) { + let title = Zotero.getString('findPDF.openAccessPDF'); + let jsonAttachment = this._makeJSONAttachment(jsonItem.id, title); + if (!jsonItem.attachments) jsonItem.attachments = []; + jsonItem.attachments.push(jsonAttachment); + attachmentCallback(jsonAttachment, 0); + } + } + catch (e) { + Zotero.logError(e); + } + return urlObjects; + }, + + async saveOpenAccessAttachment(item, attachmentCallback) { + let jsonItem = this._itemToJSONItem.get(item); + // Reuse the existing status line if there is one. This could be a failed + // translator attachment or a possible OA PDF found above. + // Explicitly check that the attachment is a PDF, not just any primary type, + // since we're reusing it for a PDF attachment. + let jsonAttachment = jsonItem.attachments && jsonItem.attachments.find( + x => x.mimeType == 'application/pdf' && x.isPrimary + ); + + + // If no translated, no OA, no custom, don't show a line + // If translated fails, no OA, no custom, fail original + + // If no translated, potential OA, show "Open-Access PDF" (set in _getOpenAccessPDFURLs()) + // If translated fails, potential OA, show "Open-Access PDF" (set here) + + // If no translated + // or translated fails, no OA, but custom, show custom when it starts + + let resolvers = this._openAccessPDFURLs.get(item); + // We checked for OA PDFs in _getOpenAccessPDFURLs() so there was no translated pdf + if (resolvers) { + // Add custom resolvers + resolvers.push(...Zotero.Attachments.getPDFResolvers(item, ['custom'], true)); + + // No translated, no OA, no custom, no status line + if (!resolvers.length) { + return; + } + + // No translated, no OA, just potential custom, so create a status line + if (!jsonAttachment) { + jsonAttachment = this._makeJSONAttachment( + jsonItem.id, Zotero.getString('findPDF.searchingForAvailableFiles') + ); + } + } + else { + // Translated attachment failed, so we didn't check for OA PDFs yet and didn't + // update the status line + // Look for OA PDFs now + resolvers = Zotero.Attachments.getPDFResolvers(item, ['oa']); + if (resolvers.length) { + resolvers = await resolvers[0](); + } + + // Add custom resolvers + resolvers.push(...Zotero.Attachments.getPDFResolvers(item, ['custom'], true)); + + // Failed translated, no OA, no custom, so fail the existing translator line + if (!resolvers.length) { + attachmentCallback(jsonAttachment, false); + return + } + } + + let attachment; + try { + attachment = await Zotero.Attachments.addFileFromURLs( + item, + resolvers, + { + // When a new access method starts, update the status line + onAccessMethodStart: (method) => { + jsonAttachment.title = this._getPDFTitleForAccessMethod(method); + attachmentCallback(jsonAttachment, 0); + } + } ); } - }), + catch (e) { + Zotero.logError(e); + attachmentCallback(jsonAttachment, false, e); + return; + } + + if (attachment) { + attachmentCallback(jsonAttachment, 100); + } + else { + attachmentCallback(jsonAttachment, false, "PDF not found"); + } + }, _makeJSONAttachment: function (parentID, title) { @@ -534,15 +579,22 @@ Zotero.Translate.ItemSaver.prototype = { try { let newAttachment; + const isSinglefileSnapshot = !!attachment.snapshotContent; // determine whether to save files and attachments - var isLink = Zotero.MIME.isWebPageType(attachment.mimeType) - // .snapshot coming from most translators, .linkMode coming from RDF - && (attachment.snapshot === false || attachment.linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL); - if (isLink || this.attachmentMode == Zotero.Translate.ItemSaver.ATTACHMENT_MODE_DOWNLOAD) { + // .snapshot coming from most translators, .linkMode coming from RDF + var isLink = attachment.snapshot === false + || attachment.linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL; + + if (isLink || this.attachmentMode === Zotero.Translate.ItemSaver.ATTACHMENT_MODE_IGNORE) { + newAttachment = yield this._saveAttachmentLink.apply(this, arguments); + } + else if (isSinglefileSnapshot || this.attachmentMode == Zotero.Translate.ItemSaver.ATTACHMENT_MODE_DOWNLOAD) { newAttachment = yield this._saveAttachmentDownload.apply(this, arguments); - } else if (this.attachmentMode == Zotero.Translate.ItemSaver.ATTACHMENT_MODE_FILE) { + } + else if (this.attachmentMode == Zotero.Translate.ItemSaver.ATTACHMENT_MODE_FILE) { newAttachment = yield this._saveAttachmentFile.apply(this, arguments); - } else { + } + else { Zotero.debug('Translate: Ignoring attachment due to ATTACHMENT_MODE_IGNORE'); } @@ -821,6 +873,50 @@ Zotero.Translate.ItemSaver.prototype = { return false; }, + _saveAttachmentLink: async function(attachment, parentItemID, attachmentCallback) { + attachment.linkMode = "linked_url"; + let url, mimeType; + if(attachment.document) { + url = attachment.document.location.href; + mimeType = attachment.mimeType || attachment.document.contentType; + } else { + url = attachment.url + mimeType = attachment.mimeType || undefined; + } + + // If no title provided, use "Attachment" as title for progress UI (but not for item) + let title = attachment.title || null; + if(!attachment.title) { + attachment.title = Zotero.getString("itemTypes.attachment"); + } + + if(!mimeType || !title) { + Zotero.debug("Translate: mimeType or title is missing; attaching link to URL will be slower"); + } + + let cleanURI = Zotero.Attachments.cleanAttachmentURI(url); + if (!cleanURI) { + throw new Error("Translate: Invalid attachment URL specified <" + url + ">"); + } + url = Components.classes["@mozilla.org/network/io-service;1"] + .getService(Components.interfaces.nsIIOService) + .newURI(cleanURI, null, null); // This cannot fail, since we check above + + // Only HTTP/HTTPS links are allowed + if(url.scheme != "http" && url.scheme != "https") { + throw new Error("Translate: " + url.scheme + " protocol is not allowed for attachments from translators."); + } + + return Zotero.Attachments.linkFromURL({ + url: cleanURI, + parentItemID, + contentType: mimeType, + title, + collections: !parentItemID ? this._collections : undefined, + saveOptions: this._saveOptions, + }); + }, + _saveAttachmentDownload: Zotero.Promise.coroutine(function* (attachment, parentItemID, attachmentCallback) { Zotero.debug("Translate: Adding attachment", 4); @@ -839,51 +935,11 @@ Zotero.Translate.ItemSaver.prototype = { // Commit to saving attachmentCallback(attachment, 0); - var isLink = attachment.snapshot === false - || attachment.linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL; - if (isLink || this.attachmentMode === Zotero.Translate.ItemSaver.ATTACHMENT_MODE_IGNORE) { - // if snapshot is explicitly set to false, attach as link - attachment.linkMode = "linked_url"; - let url, mimeType; - if(attachment.document) { - url = attachment.document.location.href; - mimeType = attachment.mimeType || attachment.document.contentType; - } else { - url = attachment.url - mimeType = attachment.mimeType || undefined; - } - - if(!mimeType || !title) { - Zotero.debug("Translate: mimeType or title is missing; attaching link to URL will be slower"); - } - - let cleanURI = Zotero.Attachments.cleanAttachmentURI(url); - if (!cleanURI) { - throw new Error("Translate: Invalid attachment URL specified <" + url + ">"); - } - url = Components.classes["@mozilla.org/network/io-service;1"] - .getService(Components.interfaces.nsIIOService) - .newURI(cleanURI, null, null); // This cannot fail, since we check above - - // Only HTTP/HTTPS links are allowed - if(url.scheme != "http" && url.scheme != "https") { - throw new Error("Translate: " + url.scheme + " protocol is not allowed for attachments from translators."); - } - - return Zotero.Attachments.linkFromURL({ - url: cleanURI, - parentItemID, - contentType: mimeType, - title, - collections: !parentItemID ? this._collections : undefined, - saveOptions: this._saveOptions, - }); - } - // Snapshot is not explicitly set to false, import as file attachment + attachment.linkMode = "imported_url"; // Import from document - if(attachment.document) { + if (attachment.document) { Zotero.debug('Importing attachment from document'); attachment.linkMode = "imported_url"; @@ -897,17 +953,6 @@ Zotero.Translate.ItemSaver.prototype = { }); } - let mimeType = attachment.mimeType ? attachment.mimeType : null; - let fileBaseName; - if (parentItemID) { - let parentItem = yield Zotero.Items.getAsync(parentItemID); - fileBaseName = Zotero.Attachments.getFileBaseNameFromItem(parentItem, { attachmentTitle: title }); - } - - attachment.linkMode = "imported_url"; - - attachmentCallback(attachment, 0); - // Import from SingleFile content if (attachment.snapshotContent) { Zotero.debug('Importing attachment from SingleFile'); @@ -924,6 +969,13 @@ Zotero.Translate.ItemSaver.prototype = { } // Import from URL + let mimeType = attachment.mimeType ? attachment.mimeType : null; + let fileBaseName; + if (parentItemID) { + let parentItem = yield Zotero.Items.getAsync(parentItemID); + fileBaseName = Zotero.Attachments.getFileBaseNameFromItem(parentItem, { attachmentTitle: title }); + } + Zotero.debug('Importing attachment from URL'); return Zotero.Attachments.importFromURL({ libraryID: this._libraryID, diff --git a/chrome/content/zotero/zotero.mjs b/chrome/content/zotero/zotero.mjs index 27dcef6c04..c9c3f43108 100644 --- a/chrome/content/zotero/zotero.mjs +++ b/chrome/content/zotero/zotero.mjs @@ -130,6 +130,7 @@ const xpcomFilesLocal = [ 'server/server_connector', 'server/server_connectorIntegration', 'server/server_localAPI', + 'server/saveSession', 'session', 'streamer', 'style', diff --git a/test/tests/serverTest.js b/test/tests/serverTest.js index e4cb8e390c..5dc9b598be 100644 --- a/test/tests/serverTest.js +++ b/test/tests/serverTest.js @@ -287,7 +287,7 @@ describe("Zotero.Server", function () { assert.equal(req.status, 204); }); }); - describe("application/octet-stream", function () { + describe("application/pdf", function () { it('should provide a stream', async function () { let called = false; let endpoint = "/test/" + Zotero.Utilities.randomString(); @@ -298,14 +298,13 @@ describe("Zotero.Server", function () { Zotero.Server.Endpoints[endpoint] = function () {}; Zotero.Server.Endpoints[endpoint].prototype = { supportedMethods: ["POST"], - supportedDataTypes: ["application/octet-stream"], + supportedDataTypes: ["application/pdf"], init: function (options) { called = true; assert.isObject(options); assert.property(options.headers, "Content-Type"); - assert(options.headers["Content-Type"].startsWith("application/octet-stream")); - assert.equal(options.headers['content-disposition'], "attachment; filename=\"test.pdf\""); + assert(options.headers["Content-Type"].startsWith("application/pdf")); assert.isFunction(options.data.available); let data = NetUtil.readInputStreamToString(options.data, options.headers['content-length']); assert.equal(data, contents); @@ -321,8 +320,7 @@ describe("Zotero.Server", function () { serverPath + endpoint, { headers: { - "Content-Type": "application/octet-stream", - "Content-Disposition": "attachment; filename=\"test.pdf\"" + "Content-Type": "application/pdf", }, body: pdf }