API-based "Restore to Online Library"

Restores the "Restore to Zotero Server" functionality, now using the
API:

1. Get all remote keys and send `DELETE` for any that don't exist
   locally.
2. Upload all local objects in full (non-patch) mode using only library
   version so that the remotes are overwritten.
3. Reset file sync history, causing all files to be uploaded (or, more
   likely, reassociated with existing remote files).

Since these are treated as regular updates on the server, they'll sync
down to other clients normally. Unsynced changes by other clients might
still trigger conflicts.

This and Reset File Sync History can also now be run on group libraries,
with a library selector in the Reset pane (which I forgot to do with
React).

The full sync option is now removed from the Reset pane, since there
wasn't ever really a reason to run it manually.

We should be able to reimplement Restore from Online Library (#1386)
using the inverse of this approach.

Closes #914
This commit is contained in:
Dan Stillman 2017-12-08 00:27:29 -05:00
parent 885ed6039f
commit f353b7ca61
14 changed files with 697 additions and 225 deletions

View file

@ -4033,5 +4033,236 @@ describe("Zotero.Sync.Data.Engine", function () {
assert.strictEqual(objects[type][1].synced, false);
}
});
})
});
describe("#_restoreToServer()", function () {
it("should delete remote objects that don't exist locally and upload all local objects", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 10;
library.libraryVersion = library.storageVersion = lastLibraryVersion;
await library.saveTx();
lastLibraryVersion = 20;
var postData = {};
var deleteData = {};
var types = Zotero.DataObjectUtilities.getTypes();
var objects = {};
var objectJSON = {};
for (let type of types) {
objectJSON[type] = [];
}
var obj;
for (let type of types) {
objects[type] = [null];
// Create JSON for object that exists remotely and not locally,
// which should be deleted
objectJSON[type].push(makeJSONFunctions[type]({
key: Zotero.DataObjectUtilities.generateKey(),
version: lastLibraryVersion,
name: Zotero.Utilities.randomString()
}));
// All other objects should be uploaded
// Object with outdated version
obj = await createDataObject(type, { synced: true, version: 5 });
objects[type].push(obj);
objectJSON[type].push(makeJSONFunctions[type]({
key: obj.key,
version: lastLibraryVersion,
name: Zotero.Utilities.randomString()
}));
// Object marked as synced that doesn't exist remotely
obj = await createDataObject(type, { synced: true, version: 10 });
objects[type].push(obj);
objectJSON[type].push(makeJSONFunctions[type]({
key: obj.key,
version: lastLibraryVersion,
name: Zotero.Utilities.randomString()
}));
// Object marked as synced that doesn't exist remotely
// but is in the remote delete log
obj = await createDataObject(type, { synced: true, version: 10 });
objects[type].push(obj);
objectJSON[type].push(makeJSONFunctions[type]({
key: obj.key,
version: lastLibraryVersion,
name: Zotero.Utilities.randomString()
}));
}
// Child attachment
obj = await importFileAttachment(
'test.png',
{
parentID: objects.item[1].id,
synced: true,
version: 5
}
);
obj.attachmentSyncedModificationTime = new Date().getTime();
obj.attachmentSyncedHash = 'b32e33f529942d73bea4ed112310f804';
obj.attachmentSyncState = Zotero.Sync.Storage.Local.SYNC_STATE_IN_SYNC;
await obj.saveTx();
objects.item.push(obj);
objectJSON.item.push(makeJSONFunctions.item({
key: obj.key,
version: lastLibraryVersion,
name: Zotero.Utilities.randomString(),
itemType: 'attachment'
}));
for (let type of types) {
let plural = Zotero.DataObjectUtilities.getObjectTypePlural(type);
let suffix = type == 'item' ? '&includeTrashed=1' : '';
let json = {};
json[objectJSON[type][0].key] = objectJSON[type][0].version;
json[objectJSON[type][1].key] = objectJSON[type][1].version;
setResponse({
method: "GET",
url: `users/1/${plural}?format=versions${suffix}`,
status: 200,
headers: {
"Last-Modified-Version": lastLibraryVersion
},
json
});
deleteData[type] = {
expectedVersion: lastLibraryVersion++,
keys: [objectJSON[type][0].key]
};
}
await Zotero.SyncedSettings.set(libraryID, "testSetting", { foo: 2 });
var settingsJSON = { testSetting: { value: { foo: 2 } } }
postData.setting = {
expectedVersion: lastLibraryVersion++
};
for (let type of types) {
postData[type] = {
expectedVersion: lastLibraryVersion++
};
}
server.respond(function (req) {
try {
let plural = req.url.match(/users\/\d+\/([a-z]+e?s)/)[1];
let type = Zotero.DataObjectUtilities.getObjectTypeSingular(plural);
// Deletions
if (req.method == "DELETE") {
let data = deleteData[type];
let version = data.expectedVersion + 1;
if (req.url == baseURL + `users/1/${plural}?${type}Key=${data.keys.join(',')}`) {
req.respond(
204,
{
"Last-Modified-Version": version
},
""
);
}
}
// Settings
else if (req.method == "POST" && req.url.match(/users\/\d+\/settings/)) {
let data = postData.setting;
assert.equal(
req.requestHeaders["If-Unmodified-Since-Version"],
data.expectedVersion
);
let version = data.expectedVersion + 1;
let json = JSON.parse(req.requestBody);
assert.deepEqual(json, settingsJSON);
req.respond(
204,
{
"Last-Modified-Version": version
},
""
);
}
// Uploads
else if (req.method == "POST") {
let data = postData[type];
assert.equal(
req.requestHeaders["If-Unmodified-Since-Version"],
data.expectedVersion
);
let version = data.expectedVersion + 1;
let json = JSON.parse(req.requestBody);
let o1 = json.find(o => o.key == objectJSON[type][1].key);
assert.notProperty(o1, 'version');
let o2 = json.find(o => o.key == objectJSON[type][2].key);
assert.notProperty(o2, 'version');
let o3 = json.find(o => o.key == objectJSON[type][3].key);
assert.notProperty(o3, 'version');
let response = {
successful: {
"0": Object.assign(objectJSON[type][1], { version }),
"1": Object.assign(objectJSON[type][2], { version }),
"2": Object.assign(objectJSON[type][3], { version })
},
unchanged: {},
failed: {}
};
if (type == 'item') {
let o = json.find(o => o.key == objectJSON.item[4].key);
assert.notProperty(o, 'version');
// Attachment items should include storage properties
assert.propertyVal(o, 'mtime', objects.item[4].attachmentSyncedModificationTime);
assert.propertyVal(o, 'md5', objects.item[4].attachmentSyncedHash);
response.successful["3"] = Object.assign(objectJSON[type][4], { version })
}
req.respond(
200,
{
"Last-Modified-Version": version
},
JSON.stringify(response)
);
}
}
catch (e) {
Zotero.logError(e);
throw e;
}
});
await engine._restoreToServer();
// Check settings
var setting = Zotero.SyncedSettings.get(libraryID, "testSetting");
assert.deepEqual(setting, { foo: 2 });
var settingMetadata = Zotero.SyncedSettings.getMetadata(libraryID, "testSetting");
assert.equal(settingMetadata.version, postData.setting.expectedVersion + 1);
assert.isTrue(settingMetadata.synced);
// Objects should all be marked as synced and in the cache
for (let type of types) {
let version = postData[type].expectedVersion + 1;
for (let i = 1; i <= 3; i++) {
assert.equal(objects[type][i].version, version);
assert.isTrue(objects[type][i].synced);
await assertInCache(objects[type][i]);
}
}
// Files should be marked as unsynced
assert.equal(
objects.item[4].attachmentSyncState,
Zotero.Sync.Storage.Local.SYNC_STATE_TO_UPLOAD
);
});
});
})