Support for connector-based save target selection

- Updates /saveItems and /saveSnapshot to take a sessionID
- Provides a list of editable collections in the current library
- Adds an /updateSession method that takes a sessionID and updates the
  collection and tags of any items saved via that operation (and changes
  the currently selected collection)

Cross-library changes are not yet supported
This commit is contained in:
Dan Stillman 2018-02-06 01:40:38 -05:00
parent 4731b8f905
commit c8cf9b9e6f
2 changed files with 435 additions and 7 deletions

View file

@ -72,6 +72,136 @@ Zotero.Server.Connector = {
}
};
Zotero.Server.Connector.Data = {};
Zotero.Server.Connector.SessionManager = {
_sessions: new Map(),
get: function (id) {
return this._sessions.get(id);
},
create: function (id) {
// Legacy client
if (!id) {
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);
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 = function (id) {
this.id = id;
this.created = new Date();
this._objects = {};
};
Zotero.Server.Connector.SaveSession.prototype.addItem = async function (item) {
return this._addObjects('item', [item]);
};
Zotero.Server.Connector.SaveSession.prototype.addItems = async function (items) {
return this._addObjects('item', items);
};
Zotero.Server.Connector.SaveSession.prototype.update = async function (libraryID, collectionID, tags) {
this._currentLibraryID = libraryID;
this._currentCollectionID = collectionID;
this._currentTags = tags || "";
// Select new destination in collections pane
var win = Zotero.getActiveZoteroPane();
if (win && win.collectionsView) {
if (collectionID) {
var targetID = "C" + collectionID;
}
else {
var targetID = "L" + libraryID;
}
await win.collectionsView.selectByID(targetID);
}
await this._updateObjects(this._objects);
// TODO: Update active item saver
// If a single item was saved, select it
if (win && win.collectionsView) {
if (this._objects && this._objects.item) {
let items = Array.from(this._objects.item).filter(item => item.isTopLevelItem());
if (items.length == 1) {
await win.selectItem(items[0].id);
}
}
}
};
Zotero.Server.Connector.SaveSession.prototype._addObjects = async function (objectType, objects) {
if (!this._objects[objectType]) {
this._objects[objectType] = new Set();
}
// If target has changed since the save began, update the objects
await this._updateObjects({
[objectType]: objects
});
for (let object of objects) {
this._objects[objectType].add(object);
}
};
Zotero.Server.Connector.SaveSession.prototype._updateObjects = async function (objects) {
if (Object.keys(objects).every(type => objects[type].length == 0)) {
return;
}
var libraryID = this._currentLibraryID;
var collectionID = this._currentCollectionID;
var tags = this._currentTags.trim();
tags = tags ? tags.split(/\s*,\s*/) : [];
Zotero.debug("Updating objects for connector save session " + this.id);
return Zotero.DB.executeTransaction(async function () {
for (let objectType in objects) {
for (let object of objects[objectType]) {
Zotero.debug(object.libraryID);
Zotero.debug(libraryID);
if (object.libraryID != libraryID) {
throw new Error("Can't move objects between libraries");
}
// Assign tags and collections to top-level items
if (objectType == 'item' && object.isTopLevelItem()) {
object.setTags(tags);
object.setCollections(collectionID ? [collectionID] : []);
await object.save();
}
}
}
});
};
Zotero.Server.Connector.AttachmentProgressManager = new function() {
var attachmentsInProgress = new WeakMap(),
attachmentProgress = {},
@ -172,7 +302,6 @@ Zotero.Server.Connector.GetTranslators.prototype = {
*/
Zotero.Server.Connector.Detect = function() {};
Zotero.Server.Endpoints["/connector/detect"] = Zotero.Server.Connector.Detect;
Zotero.Server.Connector.Data = {};
Zotero.Server.Connector.Detect.prototype = {
supportedMethods: ["POST"],
supportedDataTypes: ["application/json"],
@ -373,6 +502,15 @@ Zotero.Server.Connector.SaveItem.prototype = {
var { library, collection, editable } = Zotero.Server.Connector.getSaveTarget();
var libraryID = library.libraryID;
try {
var session = Zotero.Server.Connector.SessionManager.create(data.sessionID);
}
catch (e) {
return [409, "application/json", JSON.stringify({ error: "SESSION_EXISTS" })];
}
yield session.update(libraryID, collection ? collection.id : false);
// TODO: Default to My Library root, since it's changeable
if (!library.editable) {
Zotero.logError("Can't add item to read-only library " + library.name);
return [500, "application/json", JSON.stringify({ libraryEditable: false })];
@ -420,9 +558,12 @@ Zotero.Server.Connector.SaveItem.prototype = {
}
}
deferred.resolve([201, "application/json", JSON.stringify({items: data.items})]);
deferred.resolve([201, "application/json", JSON.stringify({items: data.items})]);
}
);
)
.then(function (items) {
session.addItems(items);
});
return deferred.promise;
}
catch (e) {
@ -460,6 +601,15 @@ Zotero.Server.Connector.SaveSnapshot.prototype = {
var { library, collection, editable } = Zotero.Server.Connector.getSaveTarget();
var libraryID = library.libraryID;
try {
var session = Zotero.Server.Connector.SessionManager.create(data.sessionID);
}
catch (e) {
return [409, "application/json", JSON.stringify({ error: "SESSION_EXISTS" })];
}
yield session.update(libraryID, collection ? collection.id : false);
// TODO: Default to My Library root, since it's changeable
if (!library.editable) {
Zotero.logError("Can't add item to read-only library " + library.name);
return [500, "application/json", JSON.stringify({ libraryEditable: false })];
@ -491,13 +641,14 @@ Zotero.Server.Connector.SaveSnapshot.prototype = {
delete Zotero.Server.Connector.Data[data.url];
try {
yield Zotero.Attachments.importFromURL({
let item = yield Zotero.Attachments.importFromURL({
libraryID,
url: data.url,
collections: collection ? [collection.id] : undefined,
contentType: "application/pdf",
cookieSandbox
});
yield session.addItem(item);
return 201;
}
catch (e) {
@ -523,6 +674,7 @@ Zotero.Server.Connector.SaveSnapshot.prototype = {
item.setCollections([collection.id]);
}
var itemID = yield item.saveTx();
yield session.addItem(item);
// save snapshot
if (filesEditable && !data.skipSnapshot) {
@ -580,6 +732,60 @@ Zotero.Server.Connector.SelectItems.prototype = {
}
}
/**
*
*
* Accepts:
* sessionID - A session ID previously passed to /saveItems
* target - A treeViewID (L1, C23, etc.) for the library or collection to save to
* tags - A string of tags separated by commas
*
* Returns:
* 200 response on successful change
* 400 on error with 'error' property in JSON
*/
Zotero.Server.Connector.UpdateSession = function() {};
Zotero.Server.Endpoints["/connector/updateSession"] = Zotero.Server.Connector.UpdateSession;
Zotero.Server.Connector.UpdateSession.prototype = {
supportedMethods: ["POST"],
supportedDataTypes: ["application/json"],
permitBookmarklet: true,
init: async function (options) {
var data = options.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) {
return [400, "application/json", JSON.stringify({ error: "SESSION_NOT_FOUND" })];
}
// Parse treeViewID
var [type, id] = [data.target[0], parseInt(data.target.substr(1))];
var tags = data.tags;
if (type == 'L') {
let library = Zotero.Libraries.get(id);
await session.update(library.libraryID, null, tags);
}
else if (type == 'C') {
let collection = await Zotero.Collections.getAsync(id);
if (!collection) {
return [400, "application/json", JSON.stringify({ error: "COLLECTION_NOT_FOUND" })];
}
await session.update(collection.libraryID, collection.id, tags);
}
else {
throw new Error(`Invalid identifier '${data.target}'`);
}
return [200, "application/json", JSON.stringify({})];
}
};
/**
* Gets progress for an attachment that is currently being saved
*
@ -725,9 +931,6 @@ Zotero.Server.Connector.GetSelectedCollection.prototype = {
editable
};
response.libraryName = library.name;
response.libraryEditable = library.editable;
if(collection && collection.id) {
response.id = collection.id;
response.name = collection.name;
@ -736,6 +939,31 @@ Zotero.Server.Connector.GetSelectedCollection.prototype = {
response.name = response.libraryName;
}
// Get list of editable libraries and collections
var collections = [];
var originalLibraryID = library.libraryID;
for (let library of Zotero.Libraries.getAll()) {
if (!library.editable) continue;
// TEMP: For now, don't allow library changing
if (library.libraryID != originalLibraryID) continue;
// Add recent: true for recent targets
collections.push(
{
id: library.treeViewID,
name: library.name
},
...Zotero.Collections.getByLibrary(library.libraryID, true).map(c => ({
id: c.treeViewID,
name: c.name,
level: c.level + 1 || 1 // Added by Zotero.Collections._getByContainer()
}))
);
}
response.targets = collections;
// TODO: Limit debug size
sendResponseCallback(200, "application/json", JSON.stringify(response));
}
}

View file

@ -427,6 +427,206 @@ describe("Connector Server", function () {
});
});
describe("/connector/updateSession", function () {
it("should update collections and tags of item saved via /saveItems", async function () {
var collection1 = await createDataObject('collection');
var collection2 = await createDataObject('collection');
await waitForItemsLoad(win);
var sessionID = Zotero.Utilities.randomString();
var body = {
sessionID,
items: [
{
itemType: "newspaperArticle",
title: "Title",
creators: [
{
firstName: "First",
lastName: "Last",
creatorType: "author"
}
],
attachments: [
{
title: "Attachment",
url: `${testServerPath}/attachment`,
mimeType: "text/html"
}
]
}
],
uri: "http://example.com"
};
httpd.registerPathHandler(
"/attachment",
{
handle: function (request, response) {
response.setStatusLine(null, 200, "OK");
response.write("<html><head><title>Title</title><body>Body</body></html>");
}
}
);
var reqPromise = Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}
);
var ids = await waitForItemEvent('add');
var item = Zotero.Items.get(ids[0]);
assert.isTrue(collection2.hasItem(item.id));
await waitForItemEvent('add');
// Wait until indexing is done
await waitForItemEvent('refresh');
var req = await reqPromise;
assert.equal(req.status, 201);
// Update saved item
var req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: collection1.treeViewID,
tags: "A, B"
})
}
);
assert.equal(req.status, 200);
assert.isTrue(collection1.hasItem(item.id));
assert.isTrue(item.hasTag("A"));
assert.isTrue(item.hasTag("B"));
});
it("should update collections and tags of PDF saved via /saveSnapshot", async function () {
var sessionID = Zotero.Utilities.randomString();
var collection1 = await createDataObject('collection');
var collection2 = await createDataObject('collection');
await waitForItemsLoad(win);
var file = getTestDataDirectory();
file.append('test.pdf');
httpd.registerFile("/test.pdf", file);
var ids;
var promise = waitForItemEvent('add');
var reqPromise = Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSnapshot",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
url: testServerPath + "/test.pdf",
pdf: true
})
}
);
var ids = await promise;
var item = Zotero.Items.get(ids[0]);
assert.isTrue(collection2.hasItem(item.id));
// Wait until indexing is done
await waitForItemEvent('refresh');
var req = await reqPromise;
assert.equal(req.status, 201);
// Update saved item
var req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: collection1.treeViewID,
tags: "A, B"
})
}
);
assert.equal(req.status, 200);
assert.isTrue(collection1.hasItem(item.id));
assert.isTrue(item.hasTag("A"));
assert.isTrue(item.hasTag("B"));
});
it("should update collections and tags of webpage saved via /saveSnapshot", async function () {
var sessionID = Zotero.Utilities.randomString();
var collection1 = await createDataObject('collection');
var collection2 = await createDataObject('collection');
await waitForItemsLoad(win);
// saveSnapshot saves parent and child before returning
var ids1, ids2;
var promise = waitForItemEvent('add').then(function (ids) {
ids1 = ids;
return waitForItemEvent('add').then(function (ids) {
ids2 = ids;
});
});
await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/saveSnapshot",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
url: "http://example.com",
html: "<html><head><title>Title</title><body>Body</body></html>"
})
}
);
assert.isTrue(promise.isFulfilled());
var item = Zotero.Items.get(ids1[0]);
// Update saved item
var req = await Zotero.HTTP.request(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: collection1.treeViewID,
tags: "A, B"
})
}
);
assert.equal(req.status, 200);
assert.isTrue(collection1.hasItem(item.id));
assert.isTrue(item.hasTag("A"));
assert.isTrue(item.hasTag("B"));
});
});
describe('/connector/installStyle', function() {
var endpoint;