Restore locally deleted collections and searches that changed remotely
Also restore items that were in the collections
This commit is contained in:
parent
24b43ae3a7
commit
47741e75fa
5 changed files with 222 additions and 13 deletions
|
@ -463,7 +463,7 @@ Zotero.Sync.Data.Engine.prototype._downloadUpdatedObjects = Zotero.Promise.corou
|
||||||
*
|
*
|
||||||
* @return {Promise<Integer>} - A download result code (this.DOWNLOAD_RESULT_*)
|
* @return {Promise<Integer>} - A download result code (this.DOWNLOAD_RESULT_*)
|
||||||
*/
|
*/
|
||||||
Zotero.Sync.Data.Engine.prototype._downloadObjects = Zotero.Promise.coroutine(function* (objectType, keys) {
|
Zotero.Sync.Data.Engine.prototype._downloadObjects = async function (objectType, keys) {
|
||||||
var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
|
var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
|
||||||
|
|
||||||
var remainingKeys = [...keys];
|
var remainingKeys = [...keys];
|
||||||
|
@ -506,12 +506,13 @@ Zotero.Sync.Data.Engine.prototype._downloadObjects = Zotero.Promise.coroutine(fu
|
||||||
);
|
);
|
||||||
|
|
||||||
var conflicts = [];
|
var conflicts = [];
|
||||||
|
var restored = [];
|
||||||
var num = 0;
|
var num = 0;
|
||||||
|
|
||||||
// Process batches of object data as they're available, one at a time
|
// Process batches of object data as they're available, one at a time
|
||||||
yield Zotero.Promise.map(
|
await Zotero.Promise.map(
|
||||||
json,
|
json,
|
||||||
Zotero.Promise.coroutine(function* (batch) {
|
async function (batch) {
|
||||||
this._failedCheck();
|
this._failedCheck();
|
||||||
|
|
||||||
Zotero.debug(`Processing batch of downloaded ${objectTypePlural} in ${this.library.name}`);
|
Zotero.debug(`Processing batch of downloaded ${objectTypePlural} in ${this.library.name}`);
|
||||||
|
@ -527,7 +528,7 @@ Zotero.Sync.Data.Engine.prototype._downloadObjects = Zotero.Promise.coroutine(fu
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process objects
|
// Process objects
|
||||||
let results = yield Zotero.Sync.Data.Local.processObjectsFromJSON(
|
let results = await Zotero.Sync.Data.Local.processObjectsFromJSON(
|
||||||
objectType,
|
objectType,
|
||||||
this.libraryID,
|
this.libraryID,
|
||||||
batch,
|
batch,
|
||||||
|
@ -563,6 +564,11 @@ Zotero.Sync.Data.Engine.prototype._downloadObjects = Zotero.Promise.coroutine(fu
|
||||||
// If data was processed, remove JSON
|
// If data was processed, remove JSON
|
||||||
if (x.processed) {
|
if (x.processed) {
|
||||||
delete objectData[x.key];
|
delete objectData[x.key];
|
||||||
|
|
||||||
|
// We'll need to add items back to restored collections
|
||||||
|
if (x.restored) {
|
||||||
|
restored.push(x.key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// If object shouldn't be retried, mark as processed
|
// If object shouldn't be retried, mark as processed
|
||||||
if (x.processed || !x.retry) {
|
if (x.processed || !x.retry) {
|
||||||
|
@ -574,12 +580,18 @@ Zotero.Sync.Data.Engine.prototype._downloadObjects = Zotero.Promise.coroutine(fu
|
||||||
});
|
});
|
||||||
remainingKeys = Zotero.Utilities.arrayDiff(remainingKeys, processedKeys);
|
remainingKeys = Zotero.Utilities.arrayDiff(remainingKeys, processedKeys);
|
||||||
conflicts.push(...conflictResults);
|
conflicts.push(...conflictResults);
|
||||||
}.bind(this)),
|
}.bind(this),
|
||||||
{
|
{
|
||||||
concurrency: 1
|
concurrency: 1
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If any locally deleted collections were restored, either add them back to the collection
|
||||||
|
// (if the items still exist) or remove them from the delete log and add them to the sync queue
|
||||||
|
if (restored.length && objectType == 'collection') {
|
||||||
|
await this._restoreRestoredCollectionItems(restored);
|
||||||
|
}
|
||||||
|
|
||||||
this._failedCheck();
|
this._failedCheck();
|
||||||
|
|
||||||
// If all requests were successful, such that we had a chance to see all keys, remove keys we
|
// If all requests were successful, such that we had a chance to see all keys, remove keys we
|
||||||
|
@ -590,7 +602,7 @@ Zotero.Sync.Data.Engine.prototype._downloadObjects = Zotero.Promise.coroutine(fu
|
||||||
Zotero.debug(`Removing ${missingKeys.length} missing `
|
Zotero.debug(`Removing ${missingKeys.length} missing `
|
||||||
+ Zotero.Utilities.pluralize(missingKeys.length, [objectType, objectTypePlural])
|
+ Zotero.Utilities.pluralize(missingKeys.length, [objectType, objectTypePlural])
|
||||||
+ " from sync queue");
|
+ " from sync queue");
|
||||||
yield Zotero.Sync.Data.Local.removeObjectsFromSyncQueue(objectType, this.libraryID, missingKeys);
|
await Zotero.Sync.Data.Local.removeObjectsFromSyncQueue(objectType, this.libraryID, missingKeys);
|
||||||
remainingKeys = Zotero.Utilities.arrayDiff(remainingKeys, missingKeys);
|
remainingKeys = Zotero.Utilities.arrayDiff(remainingKeys, missingKeys);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -602,7 +614,7 @@ Zotero.Sync.Data.Engine.prototype._downloadObjects = Zotero.Promise.coroutine(fu
|
||||||
Zotero.debug(`Queueing ${failedKeys.length} failed `
|
Zotero.debug(`Queueing ${failedKeys.length} failed `
|
||||||
+ Zotero.Utilities.pluralize(failedKeys.length, [objectType, objectTypePlural])
|
+ Zotero.Utilities.pluralize(failedKeys.length, [objectType, objectTypePlural])
|
||||||
+ " for later", 2);
|
+ " for later", 2);
|
||||||
yield Zotero.Sync.Data.Local.addObjectsToSyncQueue(
|
await Zotero.Sync.Data.Local.addObjectsToSyncQueue(
|
||||||
objectType, this.libraryID, failedKeys
|
objectType, this.libraryID, failedKeys
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -629,7 +641,7 @@ Zotero.Sync.Data.Engine.prototype._downloadObjects = Zotero.Promise.coroutine(fu
|
||||||
|
|
||||||
// Show conflict resolution window
|
// Show conflict resolution window
|
||||||
if (conflicts.length) {
|
if (conflicts.length) {
|
||||||
let results = yield Zotero.Sync.Data.Local.processConflicts(
|
let results = await Zotero.Sync.Data.Local.processConflicts(
|
||||||
objectType, this.libraryID, conflicts, this._getOptions()
|
objectType, this.libraryID, conflicts, this._getOptions()
|
||||||
);
|
);
|
||||||
// Keys can be unprocessed if conflict resolution is cancelled
|
// Keys can be unprocessed if conflict resolution is cancelled
|
||||||
|
@ -637,11 +649,77 @@ Zotero.Sync.Data.Engine.prototype._downloadObjects = Zotero.Promise.coroutine(fu
|
||||||
if (!keys.length) {
|
if (!keys.length) {
|
||||||
throw new Zotero.Sync.UserCancelledException();
|
throw new Zotero.Sync.UserCancelledException();
|
||||||
}
|
}
|
||||||
yield Zotero.Sync.Data.Local.removeObjectsFromSyncQueue(objectType, this.libraryID, keys);
|
await Zotero.Sync.Data.Local.removeObjectsFromSyncQueue(objectType, this.libraryID, keys);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.DOWNLOAD_RESULT_CONTINUE;
|
return this.DOWNLOAD_RESULT_CONTINUE;
|
||||||
});
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If a collection is deleted locally but modified remotely between syncs, the local collection is
|
||||||
|
* restored, but collection membership is a property of items, the local items that were previously
|
||||||
|
* in that collection won't be any longer (or they might have been deleted along with the collection),
|
||||||
|
* so we have to get the current collection items from the API and either add them back
|
||||||
|
* (if they exist) or clear them from the delete log and mark them for download.
|
||||||
|
*
|
||||||
|
* Remote items in the trash aren't currently restored and will be removed from the collection when the
|
||||||
|
* local collection-item removal syncs up.
|
||||||
|
*/
|
||||||
|
Zotero.Sync.Data.Engine.prototype._restoreRestoredCollectionItems = async function (collectionKeys) {
|
||||||
|
for (let collectionKey of collectionKeys) {
|
||||||
|
let { keys: itemKeys } = await this.apiClient.getKeys(
|
||||||
|
this.library.libraryType,
|
||||||
|
this.libraryTypeID,
|
||||||
|
{
|
||||||
|
target: `collections/${collectionKey}/items/top`,
|
||||||
|
format: 'keys'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (itemKeys.length) {
|
||||||
|
let collection = Zotero.Collections.getByLibraryAndKey(this.libraryID, collectionKey);
|
||||||
|
let addToCollection = [];
|
||||||
|
let addToQueue = [];
|
||||||
|
for (let itemKey of itemKeys) {
|
||||||
|
let o = Zotero.Items.getByLibraryAndKey(this.libraryID, itemKey);
|
||||||
|
if (o) {
|
||||||
|
addToCollection.push(o.id);
|
||||||
|
// Remove item from trash if it's there, since it's not in the trash remotely.
|
||||||
|
// (This would happen if items were moved to the trash along with the collection
|
||||||
|
// deletion.)
|
||||||
|
if (o.deleted) {
|
||||||
|
o.deleted = false
|
||||||
|
await o.saveTx();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
addToQueue.push(itemKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (addToCollection.length) {
|
||||||
|
Zotero.debug(`Restoring ${addToCollection.length} `
|
||||||
|
+ `${Zotero.Utilities.pluralize(addToCollection.length, ['item', 'items'])} `
|
||||||
|
+ `to restored collection ${collection.libraryKey}`);
|
||||||
|
await Zotero.DB.executeTransaction(function* () {
|
||||||
|
yield collection.addItems(addToCollection);
|
||||||
|
}.bind(this));
|
||||||
|
}
|
||||||
|
if (addToQueue.length) {
|
||||||
|
Zotero.debug(`Restoring ${addToQueue.length} deleted `
|
||||||
|
+ `${Zotero.Utilities.pluralize(addToQueue.length, ['item', 'items'])} `
|
||||||
|
+ `in restored collection ${collection.libraryKey}`);
|
||||||
|
await Zotero.Sync.Data.Local.removeObjectsFromDeleteLog(
|
||||||
|
'item', this.libraryID, addToQueue
|
||||||
|
);
|
||||||
|
await Zotero.Sync.Data.Local.addObjectsToSyncQueue(
|
||||||
|
'item', this.libraryID, addToQueue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -665,6 +665,7 @@ Zotero.Sync.Data.Local = {
|
||||||
* {Boolean} processed
|
* {Boolean} processed
|
||||||
* {Object} [error]
|
* {Object} [error]
|
||||||
* {Boolean} [retry]
|
* {Boolean} [retry]
|
||||||
|
* {Boolean} [restored=false] - Locally deleted object was added back
|
||||||
* {Boolean} [conflict=false]
|
* {Boolean} [conflict=false]
|
||||||
* {Object} [left] - Local JSON data for conflict (or .deleted and .dateDeleted)
|
* {Object} [left] - Local JSON data for conflict (or .deleted and .dateDeleted)
|
||||||
* {Object} [right] - Remote JSON data for conflict
|
* {Object} [right] - Remote JSON data for conflict
|
||||||
|
@ -783,6 +784,7 @@ Zotero.Sync.Data.Local = {
|
||||||
let obj = yield objectsClass.getByLibraryAndKeyAsync(
|
let obj = yield objectsClass.getByLibraryAndKeyAsync(
|
||||||
libraryID, objectKey, { noCache: true }
|
libraryID, objectKey, { noCache: true }
|
||||||
);
|
);
|
||||||
|
let restored = false;
|
||||||
if (obj) {
|
if (obj) {
|
||||||
Zotero.debug("Matching local " + objectType + " exists", 4);
|
Zotero.debug("Matching local " + objectType + " exists", 4);
|
||||||
|
|
||||||
|
@ -921,13 +923,14 @@ Zotero.Sync.Data.Local = {
|
||||||
// Auto-restore some locally deleted objects that have changed remotely
|
// Auto-restore some locally deleted objects that have changed remotely
|
||||||
case 'collection':
|
case 'collection':
|
||||||
case 'search':
|
case 'search':
|
||||||
|
Zotero.debug(`${ObjectType} ${objectKey} was modified remotely `
|
||||||
|
+ '-- restoring');
|
||||||
yield this.removeObjectsFromDeleteLog(
|
yield this.removeObjectsFromDeleteLog(
|
||||||
objectType,
|
objectType,
|
||||||
libraryID,
|
libraryID,
|
||||||
[objectKey]
|
[objectKey]
|
||||||
);
|
);
|
||||||
|
restored = true;
|
||||||
throw new Error("Unimplemented");
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
@ -946,6 +949,9 @@ Zotero.Sync.Data.Local = {
|
||||||
}
|
}
|
||||||
|
|
||||||
let saveResults = yield this._saveObjectFromJSON(obj, jsonObject, saveOptions);
|
let saveResults = yield this._saveObjectFromJSON(obj, jsonObject, saveOptions);
|
||||||
|
if (restored) {
|
||||||
|
saveResults.restored = true;
|
||||||
|
}
|
||||||
results.push(saveResults);
|
results.push(saveResults);
|
||||||
if (!saveResults.processed) {
|
if (!saveResults.processed) {
|
||||||
throw saveResults.error;
|
throw saveResults.error;
|
||||||
|
|
|
@ -393,7 +393,7 @@ function createUnsavedDataObject(objectType, params = {}) {
|
||||||
var itemType;
|
var itemType;
|
||||||
if (objectType == 'item' || objectType == 'feedItem') {
|
if (objectType == 'item' || objectType == 'feedItem') {
|
||||||
itemType = params.itemType || 'book';
|
itemType = params.itemType || 'book';
|
||||||
allowedParams.push('dateAdded', 'dateModified');
|
allowedParams.push('deleted', 'dateAdded', 'dateModified');
|
||||||
}
|
}
|
||||||
if (objectType == 'item') {
|
if (objectType == 'item') {
|
||||||
allowedParams.push('inPublications');
|
allowedParams.push('inPublications');
|
||||||
|
|
|
@ -2188,6 +2188,99 @@ describe("Zotero.Sync.Data.Engine", function () {
|
||||||
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue(objectType, libraryID);
|
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue(objectType, libraryID);
|
||||||
assert.sameMembers(keys, ['BBBBBBBB']);
|
assert.sameMembers(keys, ['BBBBBBBB']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it("should add items that exist remotely in a locally deleted, remotely modified collection back to collection", async function () {
|
||||||
|
({ engine, client, caller } = await setup({
|
||||||
|
stopOnError: false
|
||||||
|
}));
|
||||||
|
var libraryID = Zotero.Libraries.userLibraryID;
|
||||||
|
|
||||||
|
var collection = await createDataObject('collection');
|
||||||
|
var collectionKey = collection.key;
|
||||||
|
await collection.eraseTx();
|
||||||
|
var item1 = await createDataObject('item');
|
||||||
|
var item2 = await createDataObject('item', { deleted: true });
|
||||||
|
|
||||||
|
var headers = {
|
||||||
|
"Last-Modified-Version": 5
|
||||||
|
};
|
||||||
|
setResponse({
|
||||||
|
method: "GET",
|
||||||
|
url: `users/1/collections?format=json&collectionKey=${collectionKey}`,
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
json: [
|
||||||
|
makeCollectionJSON({
|
||||||
|
key: collectionKey,
|
||||||
|
version: 5,
|
||||||
|
name: "A"
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
setResponse({
|
||||||
|
method: "GET",
|
||||||
|
url: `users/1/collections/${collectionKey}/items/top?format=keys`,
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
text: item1.key + "\n" + item2.key + "\n"
|
||||||
|
});
|
||||||
|
await engine._downloadObjects('collection', [collectionKey]);
|
||||||
|
|
||||||
|
var collection = Zotero.Collections.getByLibraryAndKey(libraryID, collectionKey);
|
||||||
|
assert.sameMembers(collection.getChildItems(true), [item1.id, item2.id]);
|
||||||
|
// Item should be removed from trash
|
||||||
|
assert.isFalse(item2.deleted);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it("should add locally deleted items that exist remotely in a locally deleted, remotely modified collection to sync queue and remove from delete log", async function () {
|
||||||
|
({ engine, client, caller } = await setup({
|
||||||
|
stopOnError: false
|
||||||
|
}));
|
||||||
|
var libraryID = Zotero.Libraries.userLibraryID;
|
||||||
|
|
||||||
|
var collection = await createDataObject('collection');
|
||||||
|
var collectionKey = collection.key;
|
||||||
|
await collection.eraseTx();
|
||||||
|
var item = await createDataObject('item');
|
||||||
|
await item.eraseTx();
|
||||||
|
|
||||||
|
var headers = {
|
||||||
|
"Last-Modified-Version": 5
|
||||||
|
};
|
||||||
|
setResponse({
|
||||||
|
method: "GET",
|
||||||
|
url: `users/1/collections?format=json&collectionKey=${collectionKey}`,
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
json: [
|
||||||
|
makeCollectionJSON({
|
||||||
|
key: collectionKey,
|
||||||
|
version: 5,
|
||||||
|
name: "A"
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
setResponse({
|
||||||
|
method: "GET",
|
||||||
|
url: `users/1/collections/${collectionKey}/items/top?format=keys`,
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
text: item.key + "\n"
|
||||||
|
});
|
||||||
|
await engine._downloadObjects('collection', [collectionKey]);
|
||||||
|
|
||||||
|
var collection = Zotero.Collections.getByLibraryAndKey(libraryID, collectionKey);
|
||||||
|
|
||||||
|
assert.sameMembers(
|
||||||
|
await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID),
|
||||||
|
[item.key]
|
||||||
|
);
|
||||||
|
assert.isFalse(
|
||||||
|
await Zotero.Sync.Data.Local.getDateDeleted('item', libraryID, item.key)
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -494,6 +494,38 @@ describe("Zotero.Sync.Data.Local", function() {
|
||||||
assert.isFalse(obj.synced);
|
assert.isFalse(obj.synced);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should restore locally deleted collections and searches that changed remotely", async function () {
|
||||||
|
var libraryID = Zotero.Libraries.userLibraryID;
|
||||||
|
|
||||||
|
for (let type of ['collection', 'search']) {
|
||||||
|
let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
|
||||||
|
let obj = await createDataObject(type, { version: 1 });
|
||||||
|
let data = obj.toJSON();
|
||||||
|
|
||||||
|
await obj.eraseTx();
|
||||||
|
|
||||||
|
data.key = obj.key;
|
||||||
|
data.version = 2;
|
||||||
|
let json = {
|
||||||
|
key: obj.key,
|
||||||
|
version: 2,
|
||||||
|
data
|
||||||
|
};
|
||||||
|
let results = await Zotero.Sync.Data.Local.processObjectsFromJSON(
|
||||||
|
type, libraryID, [json], { stopOnError: true }
|
||||||
|
);
|
||||||
|
assert.isTrue(results[0].processed);
|
||||||
|
assert.notOk(results[0].conflict);
|
||||||
|
assert.isTrue(results[0].restored);
|
||||||
|
assert.isUndefined(results[0].changes);
|
||||||
|
assert.isUndefined(results[0].conflicts);
|
||||||
|
obj = objectsClass.getByLibraryAndKey(libraryID, data.key);
|
||||||
|
assert.equal(obj.version, 2);
|
||||||
|
assert.isTrue(obj.synced);
|
||||||
|
assert.isFalse(await Zotero.Sync.Data.Local.getDateDeleted(type, libraryID, data.key));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("should delete older versions in sync cache after processing", function* () {
|
it("should delete older versions in sync cache after processing", function* () {
|
||||||
var libraryID = Zotero.Libraries.userLibraryID;
|
var libraryID = Zotero.Libraries.userLibraryID;
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue