Simple attachment saving via Connector (instead of Zotero) (#5148)

This commit is contained in:
Adomas Venčkauskas 2024-11-20 14:43:34 +02:00 • committed by Dan Stillman
parent d781151b9a
commit 5e18337cc6
8 changed files with 1118 additions and 683 deletions

View file

@ -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<String|Integer>} [options.collections] Collection ids or keys
* @param {String} [options.title]
* @param {Object} [options.saveOptions] - Options to pass to Zotero.Item::save()
* @return {Promise<Zotero.Item>} - 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;
};
/**

View file

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

View file

@ -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 <http://www.gnu.org/licenses/>.
***** 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');
}
}
};

View file

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

View file

@ -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(`<html>${data.html}</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,

View file

@ -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<Map<any, any>>}
* @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,

View file

@ -130,6 +130,7 @@ const xpcomFilesLocal = [
'server/server_connector',
'server/server_connectorIntegration',
'server/server_localAPI',
'server/saveSession',
'session',
'streamer',
'style',

View file

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