Close #975, Process conflicts for all batches together

This commit is contained in:
Dan Stillman 2016-05-03 23:09:38 -04:00
parent f8a0f9ad1d
commit 391f525a75
4 changed files with 577 additions and 480 deletions

View File

@ -326,7 +326,7 @@ Zotero.Sync.Data.Engine.prototype._startDownload = Zotero.Promise.coroutine(func
}
return 0;
});
var mergeData = Zotero.Sync.Data.Local.resolveConflicts(conflicts);
var mergeData = Zotero.Sync.Data.Local.showConflictResolutionWindow(conflicts);
if (mergeData) {
let concurrentObjects = 50;
yield Zotero.Utilities.Internal.forEachChunkAsync(
@ -562,6 +562,8 @@ Zotero.Sync.Data.Engine.prototype._downloadObjects = Zotero.Promise.coroutine(fu
+ " in " + this.library.name
);
var conflicts = [];
// Process batches as soon as they're available
yield Zotero.Promise.map(
json,
@ -592,6 +594,7 @@ Zotero.Sync.Data.Engine.prototype._downloadObjects = Zotero.Promise.coroutine(fu
)
.then(function (results) {
let processedKeys = [];
let conflictResults = [];
results.forEach(x => {
// If data was processed, remove JSON
if (x.processed) {
@ -601,8 +604,12 @@ Zotero.Sync.Data.Engine.prototype._downloadObjects = Zotero.Promise.coroutine(fu
if (x.processed || !x.retry) {
processedKeys.push(x.key);
}
if (x.conflict) {
conflictResults.push(x);
}
});
keys = Zotero.Utilities.arrayDiff(keys, processedKeys);
conflicts.push(...conflictResults);
}.bind(this));
}.bind(this)
);
@ -630,7 +637,7 @@ Zotero.Sync.Data.Engine.prototype._downloadObjects = Zotero.Promise.coroutine(fu
this.failedItems = [];
}
}
return;
break;
}
lastLength = keys.length;
@ -638,6 +645,16 @@ Zotero.Sync.Data.Engine.prototype._downloadObjects = Zotero.Promise.coroutine(fu
var remainingObjectDesc = `${keys.length == 1 ? objectType : objectTypePlural}`;
Zotero.debug(`Retrying ${keys.length} remaining ${remainingObjectDesc}`);
}
// Show conflict resolution window
if (conflicts.length) {
let results = yield Zotero.Sync.Data.Local.processConflicts(
objectType, this.libraryID, conflicts, this._getOptions()
);
// Keys can be unprocessed if conflict resolution is cancelled
let keys = results.filter(x => x.processed).map(x => x.key);
yield Zotero.Sync.Data.Local.removeObjectsFromSyncQueue(objectType, this.libraryID, keys);
}
});

View File

@ -485,11 +485,17 @@ Zotero.Sync.Data.Local = {
/**
* Process downloaded JSON and update local objects
*
* @return {Promise<Array<Object>>} - Promise for an array of objects with the following properties:
* {String} key
* {Boolean} processed
* {Object} [error]
* {Boolean} [retry]
* @return {Promise<Object[]>} - Promise for an array of objects with the following properties:
* {String} key
* {Boolean} processed
* {Object} [error]
* {Boolean} [retry]
* {Boolean} [conflict=false]
* {Object} [left] - Local JSON data for conflict (or .deleted and .dateDeleted)
* {Object} [right] - Remote JSON data for conflict
* {Object[]} [changes] - An array of operations to apply locally to resolve conflicts,
* as returned by _reconcileChanges()
* {Object[]} [conflicts] - An array of conflicting fields that can't be resolved automatically
*/
processObjectsFromJSON: Zotero.Promise.coroutine(function* (objectType, libraryID, json, options = {}) {
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
@ -508,7 +514,6 @@ Zotero.Sync.Data.Local = {
+ " for " + libraryName);
var results = [];
var conflicts = [];
if (!json.length) {
return results;
@ -658,7 +663,10 @@ Zotero.Sync.Data.Local = {
Zotero.debug(jsonDataLocal);
Zotero.debug(jsonData);
Zotero.debug(result);
conflicts.push({
results.push({
key: objectKey,
processed: false,
conflict: true,
left: jsonDataLocal,
right: jsonData,
changes: result.changes,
@ -699,7 +707,10 @@ Zotero.Sync.Data.Local = {
switch (objectType) {
case 'item':
conflicts.push({
results.push({
key: objectKey,
processed: false,
conflict: true,
left: {
deleted: true,
dateDeleted: Zotero.Date.dateToSQL(dateDeleted, true)
@ -783,147 +794,6 @@ Zotero.Sync.Data.Local = {
}
}
//
// Conflict resolution
//
if (conflicts.length) {
// Sort conflicts by local Date Modified/Deleted
conflicts.sort(function (a, b) {
var d1 = a.left.dateDeleted || a.left.dateModified;
var d2 = b.left.dateDeleted || b.left.dateModified;
if (d1 > d2) {
return 1
}
if (d1 < d2) {
return -1;
}
return 0;
})
var mergeData = this.resolveConflicts(conflicts);
if (mergeData) {
Zotero.debug("Processing resolved conflicts");
let batchSize = 50;
let notifierQueues = [];
try {
for (let i = 0; i < mergeData.length; i++) {
// Batch notifier updates
if (notifierQueues.length == batchSize) {
yield Zotero.Notifier.commit(notifierQueues);
notifierQueues = [];
}
let notifierQueue = new Zotero.Notifier.Queue;
let json = mergeData[i];
let saveOptions = {};
Object.assign(saveOptions, options);
// Tell _saveObjectFromJSON to save as unsynced
saveOptions.saveAsChanged = true;
saveOptions.notifierQueue = notifierQueue;
// Errors have to be thrown in order to roll back the transaction, so catch
// those here and continue
try {
yield Zotero.DB.executeTransaction(function* () {
let obj = yield objectsClass.getByLibraryAndKeyAsync(
libraryID, json.key, { noCache: true }
);
// Update object with merge data
if (obj) {
// Delete local object
if (json.deleted) {
try {
yield obj.erase({
notifierQueue
});
}
catch (e) {
results.push({
key: json.key,
processed: false,
error: e,
retry: false
});
throw e;
}
results.push({
key: json.key,
processed: true
});
return;
}
// Save merged changes below
}
// If no local object and merge wanted a delete, we're good
else if (json.deleted) {
results.push({
key: json.key,
processed: true
});
return;
}
// Recreate locally deleted object
else {
obj = new Zotero[ObjectType];
obj.libraryID = libraryID;
obj.key = json.key;
yield obj.loadPrimaryData();
// Don't cache new items immediately,
// which skips reloading after save
saveOptions.skipCache = true;
}
let saveResults = yield this._saveObjectFromJSON(
obj, json, saveOptions
);
results.push(saveResults);
if (!saveResults.processed) {
throw saveResults.error;
}
}.bind(this));
if (notifierQueue.size) {
notifierQueues.push(notifierQueue);
}
}
catch (e) {
Zotero.logError(e);
if (options.onError) {
options.onError(e);
}
if (options.stopOnError) {
throw e;
}
}
}
}
finally {
if (notifierQueues.length) {
yield Zotero.Notifier.commit(notifierQueues);
}
}
}
else {
Zotero.debug("Conflict resolution was cancelled", 2);
for (let conflict of conflicts) {
results.push({
// Use key from either, in case one side is deleted
key: conflict.left.key || conflict.right.key,
processed: false,
retry: false
});
}
}
}
let processed = 0;
let skipped = 0;
results.forEach(x => x.processed ? processed++ : skipped++);
@ -1049,7 +919,153 @@ Zotero.Sync.Data.Local = {
},
resolveConflicts: function (conflicts) {
processConflicts: Zotero.Promise.coroutine(function* (objectType, libraryID, conflicts, options = {}) {
if (!conflicts.length) return [];
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
var ObjectType = Zotero.Utilities.capitalize(objectType);
// Sort conflicts by local Date Modified/Deleted
conflicts.sort(function (a, b) {
var d1 = a.left.dateDeleted || a.left.dateModified;
var d2 = b.left.dateDeleted || b.left.dateModified;
if (d1 > d2) {
return 1
}
if (d1 < d2) {
return -1;
}
return 0;
})
var results = [];
var mergeData = this.showConflictResolutionWindow(conflicts);
if (!mergeData) {
Zotero.debug("Conflict resolution was cancelled", 2);
for (let conflict of conflicts) {
results.push({
// Use key from either, in case one side is deleted
key: conflict.left.key || conflict.right.key,
processed: false,
retry: false
});
}
return results;
}
Zotero.debug("Processing resolved conflicts");
let batchSize = 50;
let notifierQueues = [];
try {
for (let i = 0; i < mergeData.length; i++) {
// Batch notifier updates
if (notifierQueues.length == batchSize) {
yield Zotero.Notifier.commit(notifierQueues);
notifierQueues = [];
}
let notifierQueue = new Zotero.Notifier.Queue;
let json = mergeData[i];
let saveOptions = {};
Object.assign(saveOptions, options);
// Tell _saveObjectFromJSON to save as unsynced
saveOptions.saveAsChanged = true;
saveOptions.notifierQueue = notifierQueue;
// Errors have to be thrown in order to roll back the transaction, so catch
// those here and continue
try {
yield Zotero.DB.executeTransaction(function* () {
let obj = yield objectsClass.getByLibraryAndKeyAsync(
libraryID, json.key, { noCache: true }
);
// Update object with merge data
if (obj) {
// Delete local object
if (json.deleted) {
try {
yield obj.erase({
notifierQueue
});
}
catch (e) {
results.push({
key: json.key,
processed: false,
error: e,
retry: false
});
throw e;
}
results.push({
key: json.key,
processed: true
});
return;
}
// Save merged changes below
}
// If no local object and merge wanted a delete, we're good
else if (json.deleted) {
results.push({
key: json.key,
processed: true
});
return;
}
// Recreate locally deleted object
else {
obj = new Zotero[ObjectType];
obj.libraryID = libraryID;
obj.key = json.key;
yield obj.loadPrimaryData();
// Don't cache new items immediately,
// which skips reloading after save
saveOptions.skipCache = true;
}
let saveResults = yield this._saveObjectFromJSON(
obj, json, saveOptions
);
results.push(saveResults);
if (!saveResults.processed) {
throw saveResults.error;
}
}.bind(this));
if (notifierQueue.size) {
notifierQueues.push(notifierQueue);
}
}
catch (e) {
Zotero.logError(e);
if (options.onError) {
options.onError(e);
}
if (options.stopOnError) {
throw e;
}
}
}
}
finally {
if (notifierQueues.length) {
yield Zotero.Notifier.commit(notifierQueues);
}
}
return results;
}),
showConflictResolutionWindow: function (conflicts) {
Zotero.debug("Showing conflict resolution window");
var io = {

View File

@ -102,6 +102,12 @@ describe("Zotero.Sync.Data.Engine", function () {
assert.propertyVal(cacheObject, 'key', obj.key);
});
var assertNotInCache = Zotero.Promise.coroutine(function* (obj) {
assert.isFalse(yield Zotero.Sync.Data.Local.getCacheObject(
obj.objectType, obj.libraryID, obj.key, obj.version
));
});
//
// Tests
//
@ -1636,6 +1642,392 @@ describe("Zotero.Sync.Data.Engine", function () {
});
})
describe("Conflict Resolution", function () {
beforeEach(function* () {
yield Zotero.DB.queryAsync("DELETE FROM syncCache");
})
after(function* () {
yield Zotero.DB.queryAsync("DELETE FROM syncCache");
})
it("should show conflict resolution window on item conflicts", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
var type = 'item';
var objects = [];
var values = [];
var dateAdded = Date.now() - 86400000;
var responseJSON = [];
for (let i = 0; i < 2; i++) {
values.push({
left: {},
right: {}
});
// Create local object
let obj = objects[i] = yield createDataObject(
type,
{
version: 10,
dateAdded: Zotero.Date.dateToSQL(new Date(dateAdded), true),
// Set Date Modified values one minute apart to enforce order
dateModified: Zotero.Date.dateToSQL(
new Date(dateAdded + (i * 60000)), true
)
}
);
let jsonData = obj.toJSON();
jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
// Save original version in cache
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Create updated JSON for download
values[i].right.title = jsonData.title = Zotero.Utilities.randomString();
values[i].right.version = json.version = jsonData.version = 15;
responseJSON.push(json);
// Modify object locally
yield modifyDataObject(obj, undefined, { skipDateModifiedUpdate: true });
values[i].left.title = obj.getField('title');
values[i].left.version = obj.getField('version');
}
setResponse({
method: "GET",
url: `users/1/items?format=json&itemKey=${objects.map(o => o.key).join('%2C')}`
+ `&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
// 1 (remote)
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
wizard.getButton('next').click();
// 2 (local)
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
// Select local object
mergeGroup.leftpane.click();
assert.equal(mergeGroup.leftpane.getAttribute('selected'), 'true');
if (Zotero.isMac) {
assert.isTrue(wizard.getButton('next').hidden);
assert.isFalse(wizard.getButton('finish').hidden);
}
else {
// TODO
}
wizard.getButton('finish').click();
})
yield engine._downloadObjects('item', objects.map(o => o.key));
assert.equal(objects[0].getField('title'), values[0].right.title);
assert.equal(objects[1].getField('title'), values[1].left.title);
assert.equal(objects[0].getField('version'), values[0].right.version);
assert.equal(objects[1].getField('version'), values[1].left.version);
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
});
it("should resolve all remaining conflicts with one side", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
var type = 'item';
var objects = [];
var values = [];
var responseJSON = [];
var dateAdded = Date.now() - 86400000;
for (let i = 0; i < 3; i++) {
values.push({
left: {},
right: {}
});
// Create object in cache
let obj = objects[i] = yield createDataObject(
type,
{
version: 10,
dateAdded: Zotero.Date.dateToSQL(new Date(dateAdded), true),
// Set Date Modified values one minute apart to enforce order
dateModified: Zotero.Date.dateToSQL(
new Date(dateAdded + (i * 60000)), true
)
}
);
let jsonData = obj.toJSON();
jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
// Save original version in cache
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Create new version in cache, simulating a download
values[i].right.title = jsonData.title = Zotero.Utilities.randomString();
values[i].right.version = json.version = jsonData.version = 15;
responseJSON.push(json);
// Modify object locally
yield modifyDataObject(obj, undefined, { skipDateModifiedUpdate: true });
values[i].left.title = obj.getField('title');
values[i].left.version = obj.getField('version');
}
setResponse({
method: "GET",
url: `users/1/items?format=json&itemKey=${objects.map(o => o.key).join('%2C')}`
+ `&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
var resolveAll = doc.getElementById('resolve-all');
// 1 (remote)
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
assert.equal(
resolveAll.label,
Zotero.getString('sync.conflict.resolveAllRemoteFields')
);
wizard.getButton('next').click();
// 2 (local and Resolve All checkbox)
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
mergeGroup.leftpane.click();
assert.equal(
resolveAll.label,
Zotero.getString('sync.conflict.resolveAllLocalFields')
);
resolveAll.click();
if (Zotero.isMac) {
assert.isTrue(wizard.getButton('next').hidden);
assert.isFalse(wizard.getButton('finish').hidden);
}
else {
// TODO
}
wizard.getButton('finish').click();
})
yield engine._downloadObjects('item', objects.map(o => o.key));
assert.equal(objects[0].getField('title'), values[0].right.title);
assert.equal(objects[0].getField('version'), values[0].right.version);
assert.equal(objects[1].getField('title'), values[1].left.title);
assert.equal(objects[1].getField('version'), values[1].left.version);
assert.equal(objects[2].getField('title'), values[2].left.title);
assert.equal(objects[2].getField('version'), values[2].left.version);
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
})
it("should handle local item deletion, keeping deletion", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var responseJSON = [];
// Create object, generate JSON, and delete
var obj = yield createDataObject(type, { version: 10 });
var jsonData = obj.toJSON();
var key = jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
// Delete object locally
yield obj.eraseTx();
json.version = jsonData.version = 15;
jsonData.title = Zotero.Utilities.randomString();
responseJSON.push(json);
setResponse({
method: "GET",
url: `users/1/items?format=json&itemKey=${obj.key}&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
var windowOpened = false;
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
windowOpened = true;
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
assert.ok(mergeGroup.leftpane.pane.onclick);
// Select local deleted version
mergeGroup.leftpane.pane.click();
wizard.getButton('finish').click();
})
yield engine._downloadObjects('item', [obj.key]);
assert.isTrue(windowOpened);
obj = objectsClass.getByLibraryAndKey(libraryID, key);
assert.isFalse(obj);
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
})
it("should restore locally deleted item", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var responseJSON = [];
// Create object, generate JSON, and delete
var obj = yield createDataObject(type, { version: 10 });
var jsonData = obj.toJSON();
var key = jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
yield obj.eraseTx();
json.version = jsonData.version = 15;
jsonData.title = Zotero.Utilities.randomString();
responseJSON.push(json);
setResponse({
method: "GET",
url: `users/1/items?format=json&itemKey=${key}&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
assert.isTrue(doc.getElementById('resolve-all').hidden);
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
wizard.getButton('finish').click();
})
yield engine._downloadObjects('item', [key]);
obj = objectsClass.getByLibraryAndKey(libraryID, key);
assert.ok(obj);
assert.equal(obj.getField('title'), jsonData.title);
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
})
it("should handle note conflict", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var responseJSON = [];
var noteText1 = "<p>A</p>";
var noteText2 = "<p>B</p>";
// Create object in cache
var obj = new Zotero.Item('note');
obj.setNote("");
obj.version = 10;
yield obj.saveTx();
var jsonData = obj.toJSON();
var key = jsonData.key = obj.key;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Create new version in cache, simulating a download
json.version = jsonData.version = 15;
json.data.note = noteText2;
responseJSON.push(json);
// Modify local version
obj.setNote(noteText1);
setResponse({
method: "GET",
url: `users/1/items?format=json&itemKey=${key}&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
wizard.getButton('finish').click();
})
yield engine._downloadObjects('item', [key]);
obj = objectsClass.getByLibraryAndKey(libraryID, key);
assert.ok(obj);
assert.equal(obj.getNote(), noteText2);
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
})
});
describe("#_upgradeCheck()", function () {
it("should upgrade a library last synced with the classic sync architecture", function* () {
var userLibraryID = Zotero.Libraries.userLibraryID;

View File

@ -547,334 +547,6 @@ describe("Zotero.Sync.Data.Local", function() {
});
});
describe("Conflict Resolution", function () {
beforeEach(function* () {
yield Zotero.DB.queryAsync("DELETE FROM syncCache");
})
after(function* () {
yield Zotero.DB.queryAsync("DELETE FROM syncCache");
})
it("should show conflict resolution window on item conflicts", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
var type = 'item';
var objects = [];
var values = [];
var dateAdded = Date.now() - 86400000;
var downloadedJSON = [];
for (let i = 0; i < 2; i++) {
values.push({
left: {},
right: {}
});
// Create object in cache
let obj = objects[i] = yield createDataObject(
type,
{
version: 10,
dateAdded: Zotero.Date.dateToSQL(new Date(dateAdded), true),
// Set Date Modified values one minute apart to enforce order
dateModified: Zotero.Date.dateToSQL(
new Date(dateAdded + (i * 60000)), true
)
}
);
let jsonData = obj.toJSON();
jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Create updated JSON, simulating a download
values[i].right.title = jsonData.title = Zotero.Utilities.randomString();
values[i].right.version = json.version = jsonData.version = 15;
downloadedJSON.push(json);
// Modify object locally
yield modifyDataObject(obj, undefined, { skipDateModifiedUpdate: true });
values[i].left.title = obj.getField('title');
values[i].left.version = obj.getField('version');
}
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
// 1 (remote)
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
wizard.getButton('next').click();
// 2 (local)
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
// Select local object
mergeGroup.leftpane.click();
assert.equal(mergeGroup.leftpane.getAttribute('selected'), 'true');
if (Zotero.isMac) {
assert.isTrue(wizard.getButton('next').hidden);
assert.isFalse(wizard.getButton('finish').hidden);
}
else {
// TODO
}
wizard.getButton('finish').click();
})
yield Zotero.Sync.Data.Local.processObjectsFromJSON(
type, libraryID, downloadedJSON, { stopOnError: true }
);
assert.equal(objects[0].getField('title'), values[0].right.title);
assert.equal(objects[1].getField('title'), values[1].left.title);
assert.equal(objects[0].getField('version'), values[0].right.version);
assert.equal(objects[1].getField('version'), values[1].left.version);
})
it("should resolve all remaining conflicts with one side", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
var type = 'item';
var objects = [];
var values = [];
var downloadedJSON = [];
var dateAdded = Date.now() - 86400000;
for (let i = 0; i < 3; i++) {
values.push({
left: {},
right: {}
});
// Create object in cache
let obj = objects[i] = yield createDataObject(
type,
{
version: 10,
dateAdded: Zotero.Date.dateToSQL(new Date(dateAdded), true),
// Set Date Modified values one minute apart to enforce order
dateModified: Zotero.Date.dateToSQL(
new Date(dateAdded + (i * 60000)), true
)
}
);
let jsonData = obj.toJSON();
jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Create new version in cache, simulating a download
values[i].right.title = jsonData.title = Zotero.Utilities.randomString();
values[i].right.version = json.version = jsonData.version = 15;
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
downloadedJSON.push(json);
// Modify object locally
yield modifyDataObject(obj, undefined, { skipDateModifiedUpdate: true });
values[i].left.title = obj.getField('title');
values[i].left.version = obj.getField('version');
}
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
var resolveAll = doc.getElementById('resolve-all');
// 1 (remote)
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
assert.equal(
resolveAll.label,
Zotero.getString('sync.conflict.resolveAllRemoteFields')
);
wizard.getButton('next').click();
// 2 (local and Resolve All checkbox)
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
mergeGroup.leftpane.click();
assert.equal(
resolveAll.label,
Zotero.getString('sync.conflict.resolveAllLocalFields')
);
resolveAll.click();
if (Zotero.isMac) {
assert.isTrue(wizard.getButton('next').hidden);
assert.isFalse(wizard.getButton('finish').hidden);
}
else {
// TODO
}
wizard.getButton('finish').click();
})
yield Zotero.Sync.Data.Local.processObjectsFromJSON(
type, libraryID, downloadedJSON, { stopOnError: true }
);
assert.equal(objects[0].getField('title'), values[0].right.title);
assert.equal(objects[0].getField('version'), values[0].right.version);
assert.equal(objects[1].getField('title'), values[1].left.title);
assert.equal(objects[1].getField('version'), values[1].left.version);
assert.equal(objects[2].getField('title'), values[2].left.title);
assert.equal(objects[2].getField('version'), values[2].left.version);
})
it("should handle local item deletion, keeping deletion", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var downloadedJSON = [];
// Create object, generate JSON, and delete
var obj = yield createDataObject(type, { version: 10 });
var jsonData = obj.toJSON();
var key = jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
// Delete object locally
yield obj.eraseTx();
// Create new version in cache, simulating a download
json.version = jsonData.version = 15;
jsonData.title = Zotero.Utilities.randomString();
downloadedJSON.push(json);
var windowOpened = false;
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
windowOpened = true;
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
assert.ok(mergeGroup.leftpane.pane.onclick);
// Select local deleted version
mergeGroup.leftpane.pane.click();
wizard.getButton('finish').click();
})
yield Zotero.Sync.Data.Local.processObjectsFromJSON(
type, libraryID, downloadedJSON, { stopOnError: true }
);
assert.isTrue(windowOpened);
obj = objectsClass.getByLibraryAndKey(libraryID, key);
assert.isFalse(obj);
})
it("should restore locally deleted item", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var downloadedJSON = [];
// Create object, generate JSON, and delete
var obj = yield createDataObject(type, { version: 10 });
var jsonData = obj.toJSON();
var key = jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
yield obj.eraseTx();
// Create new version in cache, simulating a download
json.version = jsonData.version = 15;
jsonData.title = Zotero.Utilities.randomString();
downloadedJSON.push(json);
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
assert.isTrue(doc.getElementById('resolve-all').hidden);
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
wizard.getButton('finish').click();
})
yield Zotero.Sync.Data.Local.processObjectsFromJSON(
type, libraryID, downloadedJSON, { stopOnError: true }
);
obj = objectsClass.getByLibraryAndKey(libraryID, key);
assert.ok(obj);
assert.equal(obj.getField('title'), jsonData.title);
})
it("should handle note conflict", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var downloadedJSON = [];
var noteText1 = "<p>A</p>";
var noteText2 = "<p>B</p>";
// Create object in cache
var obj = new Zotero.Item('note');
obj.setNote("");
obj.version = 10;
yield obj.saveTx();
var jsonData = obj.toJSON();
var key = jsonData.key = obj.key;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Create new version in cache, simulating a download
json.version = jsonData.version = 15;
json.data.note = noteText2;
downloadedJSON.push(json);
// Modify local version
obj.setNote(noteText1);
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
wizard.getButton('finish').click();
})
yield Zotero.Sync.Data.Local.processObjectsFromJSON(
type, libraryID, downloadedJSON, { stopOnError: true }
);
obj = objectsClass.getByLibraryAndKey(libraryID, key);
assert.ok(obj);
assert.equal(obj.getNote(), noteText2);
})
})
describe("#_reconcileChanges()", function () {
describe("items", function () {