Overhaul object downloading/processing during data syncs

Previously, objects were first downloaded and saved to the sync cache,
which was then processed separately to create/update local objects. This
meant that a server bug could result in invalid data in the sync cache
that would never be processed. Now, objects are saved as they're
downloaded and only added to the sync cache after being successfully
saved. The keys of objects that fail are added to a queue, and those
objects are refetched and retried on a backoff schedule or when a new
client version is installed (in case of a client bug or a client with
outdated data model support).

An alternative would be to save to the sync cache first and evict
objects that fail and add them to the queue, but that requires more
complicated logic, and it probably makes more sense just to buffer a few
downloads ahead so that processing is never waiting for downloads to
finish.
This commit is contained in:
Dan Stillman 2016-02-29 04:23:00 -05:00
parent 6ac35c75c1
commit a1ce85decb
17 changed files with 1251 additions and 485 deletions

View file

@ -164,12 +164,9 @@ var Zotero_Merge_Window = new function () {
_merged.forEach(function (x, i, a) { _merged.forEach(function (x, i, a) {
// Add key // Add key
x.data.key = _conflicts[i].left.key || _conflicts[i].right.key; x.data.key = _conflicts[i].left.key || _conflicts[i].right.key;
// If selecting right item, add back version // Add back version
if (x.data && x.selected == 'right') { if (x.data) {
x.data.version = _conflicts[i].right.version; x.data.version = _conflicts[i][x.selected].version;
}
else {
delete x.data.version;
} }
a[i] = x.data; a[i] = x.data;
}) })

View file

@ -671,22 +671,20 @@ Zotero.DataObject.prototype._requireData = function (dataType) {
* Loads data for a given data type * Loads data for a given data type
* @param {String} dataType * @param {String} dataType
* @param {Boolean} reload * @param {Boolean} reload
* @param {Promise}
*/ */
Zotero.DataObject.prototype._loadDataType = function (dataType, reload) { Zotero.DataObject.prototype._loadDataType = function (dataType, reload) {
return this._ObjectsClass._loadDataType(dataType, this.libraryID, [this.id]); return this._ObjectsClass._loadDataType(dataType, this.libraryID, [this.id]);
} }
Zotero.DataObject.prototype.loadAllData = function (reload) { Zotero.DataObject.prototype.loadAllData = Zotero.Promise.coroutine(function* (reload) {
let loadPromises = new Array(this._dataTypes.length);
for (let i=0; i<this._dataTypes.length; i++) { for (let i=0; i<this._dataTypes.length; i++) {
let type = this._dataTypes[i]; let type = this._dataTypes[i];
if (!this._skipDataTypeLoad[type]) { if (!this._skipDataTypeLoad[type]) {
loadPromises[i] = this._loadDataType(type, reload); yield this._loadDataType(type, reload);
} }
} }
});
return Zotero.Promise.all(loadPromises);
}
Zotero.DataObject.prototype._markAllDataTypeLoadStates = function (loaded) { Zotero.DataObject.prototype._markAllDataTypeLoadStates = function (loaded) {
for (let i = 0; i < this._dataTypes.length; i++) { for (let i = 0; i < this._dataTypes.length; i++) {
@ -865,7 +863,7 @@ Zotero.DataObject.prototype.save = Zotero.Promise.coroutine(function* (options)
env.options.errorHandler(e); env.options.errorHandler(e);
} }
else { else {
Zotero.debug(e, 1); Zotero.logError(e);
} }
throw e; throw e;
}) })

View file

@ -340,6 +340,43 @@ Zotero.DataObjects.prototype.getNewer = Zotero.Promise.method(function (libraryI
}); });
/**
* Gets the latest version for each object of a given type in the given library
*
* @return {Promise<Object>} - A promise for an object with object keys as keys and versions
* as properties
*/
Zotero.DataObjects.prototype.getObjectVersions = Zotero.Promise.coroutine(function* (libraryID, keys = null) {
var versions = {};
if (keys) {
yield Zotero.Utilities.Internal.forEachChunkAsync(
keys,
Zotero.DB.MAX_BOUND_PARAMETERS - 1,
Zotero.Promise.coroutine(function* (chunk) {
var sql = "SELECT key, version FROM " + this._ZDO_table
+ " WHERE libraryID=? AND key IN (" + chunk.map(key => '?').join(', ') + ")";
var rows = yield Zotero.DB.queryAsync(sql, [libraryID].concat(chunk));
for (let i = 0; i < rows.length; i++) {
let row = rows[i];
versions[row.key] = row.version;
}
}.bind(this))
);
}
else {
let sql = "SELECT key, version FROM " + this._ZDO_table + " WHERE libraryID=?";
let rows = yield Zotero.DB.queryAsync(sql, [libraryID]);
for (let i = 0; i < rows.length; i++) {
let row = rows[i];
versions[row.key] = row.version;
}
}
return versions;
});
/** /**
* Loads data for a given data type * Loads data for a given data type
* @param {String} dataType * @param {String} dataType

View file

@ -3914,6 +3914,11 @@ Zotero.Item.prototype.fromJSON = function (json) {
} }
let itemTypeID = Zotero.ItemTypes.getID(json.itemType); let itemTypeID = Zotero.ItemTypes.getID(json.itemType);
if (!itemTypeID) {
let e = new Error(`Invalid item type '${json.itemType}'`);
e.name = "ZoteroUnknownTypeError";
throw e;
}
this.setType(itemTypeID); this.setType(itemTypeID);
var isValidForType = {}; var isValidForType = {};

View file

@ -178,6 +178,9 @@ Zotero.Schema = new function(){
} }
} }
// Reset sync queue tries if new version
yield _checkClientVersion();
Zotero.initializationPromise Zotero.initializationPromise
.then(1000) .then(1000)
.then(function () { .then(function () {
@ -1383,6 +1386,8 @@ Zotero.Schema = new function(){
yield Zotero.DB.queryAsync(sql, [welcomeMsg, welcomeTitle]); yield Zotero.DB.queryAsync(sql, [welcomeMsg, welcomeTitle]);
}*/ }*/
yield _updateLastClientVersion();
self.dbInitialized = true; self.dbInitialized = true;
}) })
.catch(function (e) { .catch(function (e) {
@ -1434,13 +1439,46 @@ Zotero.Schema = new function(){
} }
yield Zotero.DB.queryAsync( yield Zotero.DB.queryAsync(
"REPLACE INTO settings VALUES (?, ?, ?)", "REPLACE INTO settings VALUES ('client', 'lastCompatibleVersion', ?)", [Zotero.version]
['client', 'lastCompatibleVersion', Zotero.version]
); );
yield _updateDBVersion('compatibility', version); yield _updateDBVersion('compatibility', version);
}); });
function _checkClientVersion() {
return Zotero.DB.executeTransaction(function* () {
var lastVersion = yield _getLastClientVersion();
var currentVersion = Zotero.version;
if (currentVersion == lastVersion) {
return false;
}
Zotero.debug(`Client version has changed from ${lastVersion} to ${currentVersion}`);
// Retry all queued objects immediately on upgrade
yield Zotero.Sync.Data.Local.resetSyncQueueTries();
// Update version
yield _updateLastClientVersion();
return true;
}.bind(this));
}
function _getLastClientVersion() {
var sql = "SELECT value FROM settings WHERE setting='client' AND key='lastVersion'";
return Zotero.DB.valueQueryAsync(sql);
}
function _updateLastClientVersion() {
var sql = "REPLACE INTO settings (setting, key, value) VALUES ('client', 'lastVersion', ?)";
return Zotero.DB.queryAsync(sql, Zotero.version);
}
/** /**
* Process the response from the repository * Process the response from the repository
* *
@ -2168,7 +2206,7 @@ Zotero.Schema = new function(){
} }
if (i == 81) { else if (i == 81) {
yield _updateCompatibility(2); yield _updateCompatibility(2);
yield Zotero.DB.queryAsync("ALTER TABLE libraries RENAME TO librariesOld"); yield Zotero.DB.queryAsync("ALTER TABLE libraries RENAME TO librariesOld");
@ -2179,7 +2217,7 @@ Zotero.Schema = new function(){
yield Zotero.DB.queryAsync("DELETE FROM version WHERE schema LIKE ?", "storage_%"); yield Zotero.DB.queryAsync("DELETE FROM version WHERE schema LIKE ?", "storage_%");
} }
if (i == 82) { else if (i == 82) {
yield Zotero.DB.queryAsync("DELETE FROM itemTypeFields WHERE itemTypeID=17 AND orderIndex BETWEEN 3 AND 9"); yield Zotero.DB.queryAsync("DELETE FROM itemTypeFields WHERE itemTypeID=17 AND orderIndex BETWEEN 3 AND 9");
yield Zotero.DB.queryAsync("INSERT INTO itemTypeFields VALUES (17, 44, NULL, 3)"); yield Zotero.DB.queryAsync("INSERT INTO itemTypeFields VALUES (17, 44, NULL, 3)");
yield Zotero.DB.queryAsync("INSERT INTO itemTypeFields VALUES (17, 96, NULL, 4)"); yield Zotero.DB.queryAsync("INSERT INTO itemTypeFields VALUES (17, 96, NULL, 4)");
@ -2190,13 +2228,17 @@ Zotero.Schema = new function(){
yield Zotero.DB.queryAsync("INSERT INTO itemTypeFields VALUES (17, 42, NULL, 9)"); yield Zotero.DB.queryAsync("INSERT INTO itemTypeFields VALUES (17, 42, NULL, 9)");
} }
if (i == 83) { else if (i == 83) {
// Feeds // Feeds
yield Zotero.DB.queryAsync("DROP TABLE IF EXISTS feeds"); yield Zotero.DB.queryAsync("DROP TABLE IF EXISTS feeds");
yield Zotero.DB.queryAsync("DROP TABLE IF EXISTS feedItems"); yield Zotero.DB.queryAsync("DROP TABLE IF EXISTS feedItems");
yield Zotero.DB.queryAsync("CREATE TABLE feeds (\n libraryID INTEGER PRIMARY KEY,\n name TEXT NOT NULL,\n url TEXT NOT NULL UNIQUE,\n lastUpdate TIMESTAMP,\n lastCheck TIMESTAMP,\n lastCheckError TEXT,\n cleanupAfter INT,\n refreshInterval INT,\n FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE\n)"); yield Zotero.DB.queryAsync("CREATE TABLE feeds (\n libraryID INTEGER PRIMARY KEY,\n name TEXT NOT NULL,\n url TEXT NOT NULL UNIQUE,\n lastUpdate TIMESTAMP,\n lastCheck TIMESTAMP,\n lastCheckError TEXT,\n cleanupAfter INT,\n refreshInterval INT,\n FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE\n)");
yield Zotero.DB.queryAsync("CREATE TABLE feedItems (\n itemID INTEGER PRIMARY KEY,\n guid TEXT NOT NULL UNIQUE,\n readTime TIMESTAMP,\n translatedTime TIMESTAMP,\n FOREIGN KEY (itemID) REFERENCES items(itemID) ON DELETE CASCADE\n)"); yield Zotero.DB.queryAsync("CREATE TABLE feedItems (\n itemID INTEGER PRIMARY KEY,\n guid TEXT NOT NULL UNIQUE,\n readTime TIMESTAMP,\n translatedTime TIMESTAMP,\n FOREIGN KEY (itemID) REFERENCES items(itemID) ON DELETE CASCADE\n)");
} }
else if (i == 84) {
yield Zotero.DB.queryAsync("CREATE TABLE syncQueue (\n libraryID INT NOT NULL,\n key TEXT NOT NULL,\n syncObjectTypeID INT NOT NULL,\n lastCheck TIMESTAMP,\n tries INT,\n PRIMARY KEY (libraryID, key, syncObjectTypeID),\n FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE,\n FOREIGN KEY (syncObjectTypeID) REFERENCES syncObjectTypes(syncObjectTypeID) ON DELETE CASCADE\n)");
}
} }
yield _updateDBVersion('userdata', toVersion); yield _updateDBVersion('userdata', toVersion);

View file

@ -279,7 +279,9 @@ Zotero.Search.prototype.addCondition = function (condition, operator, value, req
this._requireData('conditions'); this._requireData('conditions');
if (!Zotero.SearchConditions.hasOperator(condition, operator)){ if (!Zotero.SearchConditions.hasOperator(condition, operator)){
throw new Error("Invalid operator '" + operator + "' for condition " + condition); let e = new Error("Invalid operator '" + operator + "' for condition " + condition);
e.name = "ZoteroUnknownFieldError";
throw e;
} }
// Shortcut to add a condition on every table -- does not return an id // Shortcut to add a condition on every table -- does not return an id
@ -412,7 +414,9 @@ Zotero.Search.prototype.updateCondition = function (searchConditionID, condition
} }
if (!Zotero.SearchConditions.hasOperator(condition, operator)){ if (!Zotero.SearchConditions.hasOperator(condition, operator)){
throw new Error("Invalid operator '" + operator + "' for condition " + condition); let e = new Error("Invalid operator '" + operator + "' for condition " + condition);
e.name = "ZoteroUnknownFieldError";
throw e;
} }
var [condition, mode] = Zotero.SearchConditions.parseCondition(condition); var [condition, mode] = Zotero.SearchConditions.parseCondition(condition);
@ -2323,7 +2327,7 @@ Zotero.SearchConditions = new function(){
} }
if (!_conditions[condition]){ if (!_conditions[condition]){
var e = new Error("Invalid condition '" + condition + "' in hasOperator()"); let e = new Error("Invalid condition '" + condition + "' in hasOperator()");
e.name = "ZoteroUnknownFieldError"; e.name = "ZoteroUnknownFieldError";
throw e; throw e;
} }

View file

@ -38,6 +38,7 @@ Zotero.Sync.APIClient = function (options) {
this.caller = options.caller; this.caller = options.caller;
this.failureDelayIntervals = [2500, 5000, 10000, 20000, 40000, 60000, 120000, 240000, 300000]; this.failureDelayIntervals = [2500, 5000, 10000, 20000, 40000, 60000, 120000, 240000, 300000];
this.failureDelayMax = 60 * 60 * 1000; // 1 hour
} }
Zotero.Sync.APIClient.prototype = { Zotero.Sync.APIClient.prototype = {
@ -270,11 +271,10 @@ Zotero.Sync.APIClient.prototype = {
}.bind(this)) }.bind(this))
// Return the error without failing the whole chain // Return the error without failing the whole chain
.catch(function (e) { .catch(function (e) {
Zotero.logError(e);
if (e instanceof Zotero.HTTP.UnexpectedStatusException && e.is4xx()) { if (e instanceof Zotero.HTTP.UnexpectedStatusException && e.is4xx()) {
Zotero.logError(e);
throw e; throw e;
} }
Zotero.logError(e);
return e; return e;
}) })
]; ];
@ -591,13 +591,13 @@ Zotero.Sync.APIClient.prototype = {
if (!failureDelayGenerator) { if (!failureDelayGenerator) {
// Keep trying for up to an hour // Keep trying for up to an hour
failureDelayGenerator = Zotero.Utilities.Internal.delayGenerator( failureDelayGenerator = Zotero.Utilities.Internal.delayGenerator(
this.failureDelayIntervals, 60 * 60 * 1000 this.failureDelayIntervals, this.failureDelayMax
); );
} }
let keepGoing = yield failureDelayGenerator.next().value; let keepGoing = yield failureDelayGenerator.next().value;
if (!keepGoing) { if (!keepGoing) {
Zotero.logError("Failed too many times"); Zotero.logError("Failed too many times");
throw lastError; throw e;
} }
return false; return false;
} }

View file

@ -50,27 +50,24 @@ Zotero.Sync.Data.Engine = function (options) {
this.libraryID = options.libraryID; this.libraryID = options.libraryID;
this.library = Zotero.Libraries.get(options.libraryID); this.library = Zotero.Libraries.get(options.libraryID);
this.libraryTypeID = this.library.libraryTypeID; this.libraryTypeID = this.library.libraryTypeID;
this.setStatus = options.setStatus || function () {};
this.onError = options.onError || function (e) {};
this.stopOnError = options.stopOnError;
this.requests = []; this.requests = [];
this.uploadBatchSize = 25; this.uploadBatchSize = 25;
this.uploadDeletionBatchSize = 50; this.uploadDeletionBatchSize = 50;
this.failed = false; this.failed = false;
this.failedItems = [];
this.options = { // Options to pass through to processing functions
setStatus: this.setStatus, this.optionNames = ['setStatus', 'onError', 'stopOnError', 'background', 'firstInSession'];
stopOnError: this.stopOnError, this.options = {};
onError: this.onError this.optionNames.forEach(x => {
} // Create dummy functions if not set
if (x == 'setStatus' || x == 'onError') {
Components.utils.import("resource://zotero/concurrentCaller.js"); this[x] = options[x] || function () {};
this.syncCacheProcessor = new ConcurrentCaller({ }
id: "Sync Cache Processor", else {
numConcurrent: 1, this[x] = options[x];
logger: Zotero.debug, }
stopOnError: this.stopOnError
}); });
}; };
@ -162,9 +159,6 @@ Zotero.Sync.Data.Engine.prototype.start = Zotero.Promise.coroutine(function* ()
} }
} }
Zotero.debug("Waiting for sync cache to be processed");
yield this.syncCacheProcessor.wait();
yield Zotero.Libraries.updateLastSyncTime(this.libraryID); yield Zotero.Libraries.updateLastSyncTime(this.libraryID);
Zotero.debug("Done syncing " + this.library.name); Zotero.debug("Done syncing " + this.library.name);
@ -213,7 +207,6 @@ Zotero.Sync.Data.Engine.prototype._startDownload = Zotero.Promise.coroutine(func
// //
for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(this.libraryID)) { for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(this.libraryID)) {
this._failedCheck(); this._failedCheck();
this._processCache(objectType);
// For items, fetch top-level items first // For items, fetch top-level items first
// //
@ -245,9 +238,6 @@ Zotero.Sync.Data.Engine.prototype._startDownload = Zotero.Promise.coroutine(func
} }
} }
// Wait for sync process to clear
yield this.syncCacheProcessor.wait();
// //
// Get deleted objects // Get deleted objects
// //
@ -435,6 +425,7 @@ Zotero.Sync.Data.Engine.prototype._downloadSettings = Zotero.Promise.coroutine(f
*/ */
Zotero.Sync.Data.Engine.prototype._downloadUpdatedObjects = Zotero.Promise.coroutine(function* (objectType, libraryVersion, lastLibraryVersion, delayGenerator, options = {}) { Zotero.Sync.Data.Engine.prototype._downloadUpdatedObjects = Zotero.Promise.coroutine(function* (objectType, libraryVersion, lastLibraryVersion, delayGenerator, options = {}) {
var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
// Get versions of all objects updated remotely since the current local library version // Get versions of all objects updated remotely since the current local library version
Zotero.debug(`Checking for updated ${options.top ? 'top-level ' : ''}` Zotero.debug(`Checking for updated ${options.top ? 'top-level ' : ''}`
@ -468,30 +459,58 @@ Zotero.Sync.Data.Engine.prototype._downloadUpdatedObjects = Zotero.Promise.corou
return -1; return -1;
} }
var numObjects = Object.keys(results.versions).length; var numObjects = Object.keys(results.versions).length;
if (!numObjects) { if (numObjects) {
Zotero.debug(numObjects + " " + (numObjects == 1 ? objectType : objectTypePlural)
+ " modified since last check");
}
else {
Zotero.debug("No " + objectTypePlural + " modified remotely since last check"); Zotero.debug("No " + objectTypePlural + " modified remotely since last check");
}
// Get objects that should be retried based on the current time, unless it's top-level items mode.
// (We don't know if the queued items are top-level or not, so we do them with child items.)
let queuedKeys = [];
if (objectType != 'item' || !options.top) {
queuedKeys = yield Zotero.Sync.Data.Local.getObjectsToTryFromSyncQueue(
objectType, this.libraryID
);
if (queuedKeys.length) {
Zotero.debug(`Refetching ${queuedKeys.length} queued `
+ (queuedKeys.length == 1 ? objectType : objectTypePlural))
}
}
if (!numObjects && !queuedKeys.length) {
return false; return false;
} }
Zotero.debug(numObjects + " " + (numObjects == 1 ? objectType : objectTypePlural)
+ " modified since last check");
let keys = []; let keys = [];
let versions = yield Zotero.Sync.Data.Local.getLatestCacheObjectVersions( let versions = yield objectsClass.getObjectVersions(
objectType, this.libraryID, Object.keys(results.versions) this.libraryID, Object.keys(results.versions)
); );
for (let key in results.versions) { for (let key in results.versions) {
// Skip objects that are already up-to-date in the sync cache. Generally all returned // Skip objects that are already up-to-date. Generally all returned objects should have
// objects should have newer version numbers, but there are some situations, such as // newer version numbers, but there are some situations, such as full syncs or
// full syncs or interrupted syncs, where we may get versions for objects that are // interrupted syncs, where we may get versions for objects that are already up-to-date
// already up-to-date locally. // locally.
if (versions[key] == results.versions[key]) { if (versions[key] == results.versions[key]) {
Zotero.debug("Skipping up-to-date " + objectType + " " + this.libraryID + "/" + key); Zotero.debug("Skipping up-to-date " + objectType + " " + this.libraryID + "/" + key);
continue; continue;
} }
keys.push(key); keys.push(key);
} }
// In child-items mode, remove top-level items that just failed
if (objectType == 'item' && !options.top && this.failedItems.length) {
keys = Zotero.Utilities.arrayDiff(keys, this.failedItems);
}
keys.push(...queuedKeys);
if (!keys.length) { if (!keys.length) {
Zotero.debug(`No ${objectTypePlural} to download`);
return false; return false;
} }
@ -502,19 +521,42 @@ Zotero.Sync.Data.Engine.prototype._downloadUpdatedObjects = Zotero.Promise.corou
Zotero.Sync.Data.Engine.prototype._downloadObjects = Zotero.Promise.coroutine(function* (objectType, keys) { Zotero.Sync.Data.Engine.prototype._downloadObjects = Zotero.Promise.coroutine(function* (objectType, keys) {
var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
var failureDelayGenerator = null;
var lastLength = keys.length; var lastLength = keys.length;
var objectData = {};
keys.forEach(key => objectData[key] = null);
while (true) { while (true) {
this._failedCheck(); this._failedCheck();
let lastError = false; let lastError = false;
// Get data we've downloaded in a previous loop but failed to process
var json = [];
let keysToDownload = [];
for (let key in objectData) {
if (objectData[key] === null) {
keysToDownload.push(key);
}
else {
json.push(objectData[key]);
}
}
if (json.length) {
json = [json];
}
// Add promises for batches of downloaded data for remaining keys
json.push(...this.apiClient.downloadObjects(
this.library.libraryType,
this.libraryTypeID,
objectType,
keysToDownload
));
// TODO: localize // TODO: localize
this.setStatus( this.setStatus(
"Downloading " "Downloading "
+ (keys.length == 1 + (keysToDownload.length == 1
? "1 " + objectType ? "1 " + objectType
: Zotero.Utilities.numberFormat(keys.length, 0) + " " + objectTypePlural) : Zotero.Utilities.numberFormat(keys.length, 0) + " " + objectTypePlural)
+ " in " + this.library.name + " in " + this.library.name
@ -522,65 +564,79 @@ Zotero.Sync.Data.Engine.prototype._downloadObjects = Zotero.Promise.coroutine(fu
// Process batches as soon as they're available // Process batches as soon as they're available
yield Zotero.Promise.map( yield Zotero.Promise.map(
this.apiClient.downloadObjects( json,
this.library.libraryType,
this.libraryTypeID,
objectType,
keys
),
function (batch) { function (batch) {
this._failedCheck(); this._failedCheck();
Zotero.debug("MAPPING"); Zotero.debug(`Processing batch of downloaded ${objectTypePlural} in `
+ this.library.name);
if (!Array.isArray(batch)) { if (!Array.isArray(batch)) {
Zotero.debug("WE GOT AN ERROR");
Components.utils.reportError(batch);
Zotero.debug(batch, 1);
this.failed = batch; this.failed = batch;
lastError = batch; lastError = batch;
return; return;
} }
// Save objects to sync cache // Save downloaded JSON for later attempts
return Zotero.Sync.Data.Local.saveCacheObjects( batch.forEach(obj => {
objectType, this.libraryID, batch objectData[obj.key] = obj;
});
// Process objects
return Zotero.Sync.Data.Local.processObjectsFromJSON(
objectType,
this.libraryID,
batch,
this._getOptions()
) )
.then(function () { .then(function (results) {
let processedKeys = batch.map(item => item.key); let processedKeys = [];
results.forEach(x => {
// If data was processed, remove JSON
if (x.processed) {
delete objectData[x.key];
}
// If object shouldn't be retried, mark as processed
if (x.processed || !x.retry) {
processedKeys.push(x.key);
}
});
keys = Zotero.Utilities.arrayDiff(keys, processedKeys); keys = Zotero.Utilities.arrayDiff(keys, processedKeys);
// Create/update objects as they come in
this._processCache(objectType);
}.bind(this)); }.bind(this));
}.bind(this) }.bind(this)
); );
if (!keys.length) { if (!keys.length || keys.length == lastLength) {
Zotero.debug("All " + objectTypePlural + " for library " // Add failed objects to sync queue
+ this.libraryID + " saved to sync cache"); let failedKeys = Object.keys(objectData).filter(key => objectData[key])
break; if (failedKeys.length) {
} let objDesc = `${failedKeys.length == 1 ? objectType : objectTypePlural}`;
Zotero.debug(`Queueing ${failedKeys.length} failed ${objDesc} for later`, 2);
// If we're not making process, delay for increasing amounts of time yield Zotero.Sync.Data.Local.addObjectsToSyncQueue(
// and then keep going objectType, this.libraryID, failedKeys
if (keys.length == lastLength) {
if (!failureDelayGenerator) {
// Keep trying for up to an hour
failureDelayGenerator = Zotero.Utilities.Internal.delayGenerator(
Zotero.Sync.Data.failureDelayIntervals, 60 * 60 * 1000
); );
// Note failed item keys so child items step (if this isn't it) can skip them
if (objectType == 'item') {
this.failedItems = failedKeys;
}
} }
let keepGoing = yield failureDelayGenerator.next(); else {
if (!keepGoing) { Zotero.debug("All " + objectTypePlural + " for "
Zotero.logError("Failed too many times"); + this.library.name + " saved to database");
throw lastError;
if (objectType == 'item') {
this.failedItems = [];
}
} }
} return;
else {
failureDelayGenerator = null;
} }
lastLength = keys.length; lastLength = keys.length;
var remainingObjectDesc = `${keys.length == 1 ? objectType : objectTypePlural}`;
Zotero.debug(`Retrying ${keys.length} remaining ${remainingObjectDesc}`);
} }
}); });
@ -1107,7 +1163,7 @@ Zotero.Sync.Data.Engine.prototype._upgradeCheck = Zotero.Promise.coroutine(funct
* missing or outdated and not up-to-date in the sync cache, download them. If any local objects * missing or outdated and not up-to-date in the sync cache, download them. If any local objects
* are marked as synced but aren't available remotely, mark them as unsynced for later uploading. * are marked as synced but aren't available remotely, mark them as unsynced for later uploading.
* *
* (Technically this isn't a full sync on its own, because objects are only flagged for later * (Technically this isn't a full sync on its own, because local objects are only flagged for later
* upload.) * upload.)
* *
* @param {Object[]} [versionResults] - Objects returned from getVersions(), keyed by objectType * @param {Object[]} [versionResults] - Objects returned from getVersions(), keyed by objectType
@ -1135,9 +1191,6 @@ Zotero.Sync.Data.Engine.prototype._fullSync = Zotero.Promise.coroutine(function*
// TODO: localize // TODO: localize
this.setStatus("Updating " + objectTypePlural + " in " + this.library.name); this.setStatus("Updating " + objectTypePlural + " in " + this.library.name);
// Start processing cached objects while waiting for API
this._processCache(objectType);
let results = {}; let results = {};
// Use provided versions // Use provided versions
if (versionResults) { if (versionResults) {
@ -1174,9 +1227,7 @@ Zotero.Sync.Data.Engine.prototype._fullSync = Zotero.Promise.coroutine(function*
let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
let toDownload = []; let toDownload = [];
let cacheVersions = yield Zotero.Sync.Data.Local.getLatestCacheObjectVersions( let localVersions = yield objectsClass.getObjectVersions(this.libraryID);
objectType, this.libraryID
);
// Queue objects that are out of date or don't exist locally // Queue objects that are out of date or don't exist locally
for (let key in results.versions) { for (let key in results.versions) {
let version = results.versions[key]; let version = results.versions[key];
@ -1184,31 +1235,26 @@ Zotero.Sync.Data.Engine.prototype._fullSync = Zotero.Promise.coroutine(function*
noCache: true noCache: true
}); });
// If object already at latest version, skip // If object already at latest version, skip
if (obj && obj.version === version) { let localVersion = localVersions[key];
if (localVersion && localVersion === version) {
continue; continue;
} }
let cacheVersion = cacheVersions[key];
// If cache already has latest version, skip // This should never happen
if (cacheVersion == version) { if (localVersion > version) {
continue; Zotero.logError(`Local version of ${objectType} ${this.libraryID}/${key} `
} + `is later than remote! (${localVersion} > ${version})`);
// This should never happen, but recover if it does // Delete cache version if it's there
if (cacheVersion > version) {
Zotero.logError("Sync cache had later version than remote for "
+ objectType + " " + this.libraryID + "/" + key
+ " (" + cacheVersion + " > " + version + ") -- deleting");
yield Zotero.Sync.Data.Local.deleteCacheObjectVersions( yield Zotero.Sync.Data.Local.deleteCacheObjectVersions(
objectType, this.libraryID, key, cacheVersion, cacheVersion objectType, this.libraryID, key, localVersion, localVersion
); );
} }
if (obj) { if (obj) {
Zotero.debug(ObjectType + " " + obj.libraryKey Zotero.debug(`${ObjectType} ${obj.libraryKey} is older than remote version`);
+ " is older than version in sync cache");
} }
else { else {
Zotero.debug(ObjectType + " " + this.libraryID + "/" + key Zotero.debug(`${ObjectType} ${this.libraryID}/${key} does not exist locally`);
+ " in sync cache not found locally");
} }
toDownload.push(key); toDownload.push(key);
@ -1263,15 +1309,10 @@ Zotero.Sync.Data.Engine.prototype._fullSync = Zotero.Promise.coroutine(function*
yield objectsClass.updateSynced(toUpload, false); yield objectsClass.updateSynced(toUpload, false);
} }
} }
// Process newly cached objects
this._processCache(objectType);
} }
break; break;
} }
yield this.syncCacheProcessor.wait();
yield Zotero.Libraries.setVersion(this.libraryID, lastLibraryVersion); yield Zotero.Libraries.setVersion(this.libraryID, lastLibraryVersion);
Zotero.debug("Done with full sync for " + this.library.name); Zotero.debug("Done with full sync for " + this.library.name);
@ -1280,27 +1321,10 @@ Zotero.Sync.Data.Engine.prototype._fullSync = Zotero.Promise.coroutine(function*
}); });
/** Zotero.Sync.Data.Engine.prototype._getOptions = function () {
* Chain sync cache processing for a given object type var options = {};
* this.optionNames.forEach(x => options[x] = this[x]);
* On error, check if errors should be fatal and set the .failed flag return options;
*
* @param {String} objectType
*/
Zotero.Sync.Data.Engine.prototype._processCache = function (objectType) {
this.syncCacheProcessor.start(function () {
this._failedCheck();
return Zotero.Sync.Data.Local.processSyncCacheForObjectType(
this.libraryID, objectType, this.options
)
.catch(function (e) {
Zotero.logError(e);
if (this.stopOnError) {
Zotero.debug("WE FAILED!!!");
this.failed = e;
}
}.bind(this));
}.bind(this))
} }

View file

@ -28,6 +28,7 @@ if (!Zotero.Sync.Data) {
} }
Zotero.Sync.Data.Local = { Zotero.Sync.Data.Local = {
_syncQueueIntervals: [0.5, 1, 4, 16, 16, 16, 16, 16, 16, 16, 64], // hours
_loginManagerHost: 'chrome://zotero', _loginManagerHost: 'chrome://zotero',
_loginManagerRealm: 'Zotero Web API', _loginManagerRealm: 'Zotero Web API',
_lastSyncTime: null, _lastSyncTime: null,
@ -383,50 +384,39 @@ Zotero.Sync.Data.Local = {
}), }),
_checkCacheJSON: function (json) { /**
if (json.key === undefined) { * Process downloaded JSON and update local objects
throw new Error("Missing 'key' property in JSON"); *
} * @return {Promise<Array<Object>>} - Promise for an array of objects with the following properties:
if (json.version === undefined) { * {String} key
throw new Error("Missing 'version' property in JSON"); * {Boolean} processed
} * {Object} [error]
// If direct data object passed, wrap in fake response object * {Boolean} [retry]
return json.data === undefined ? { */
key: json.key, processObjectsFromJSON: Zotero.Promise.coroutine(function* (objectType, libraryID, json, options = {}) {
version: json.version,
data: json
} : json;
},
processSyncCache: Zotero.Promise.coroutine(function* (libraryID, options) {
for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(libraryID)) {
yield this.processSyncCacheForObjectType(libraryID, objectType, options);
}
}),
processSyncCacheForObjectType: Zotero.Promise.coroutine(function* (libraryID, objectType, options = {}) {
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
var ObjectType = Zotero.Utilities.capitalize(objectType); var ObjectType = Zotero.Utilities.capitalize(objectType);
var libraryName = Zotero.Libraries.getName(libraryID); var libraryName = Zotero.Libraries.getName(libraryID);
Zotero.debug("Processing " + objectTypePlural + " in sync cache for " + libraryName); var knownErrors = [
'ZoteroUnknownTypeError',
'ZoteroUnknownFieldError',
'ZoteroMissingObjectError'
];
Zotero.debug("Processing " + json.length + " downloaded "
+ (json.length == 1 ? objectType : objectTypePlural)
+ " for " + libraryName);
var results = [];
var conflicts = []; var conflicts = [];
var numSaved = 0;
var numSkipped = 0;
var data = yield this._getUnwrittenData(libraryID, objectType); if (!json.length) {
if (!data.length) { return results;
Zotero.debug("No unwritten " + objectTypePlural + " in sync cache");
return;
} }
Zotero.debug("Processing " + data.length + " " json = json.map(o => this._checkCacheJSON(o));
+ (data.length == 1 ? objectType : objectTypePlural)
+ " in sync cache");
if (options.setStatus) { if (options.setStatus) {
options.setStatus("Processing " + objectTypePlural); // TODO: localize options.setStatus("Processing " + objectTypePlural); // TODO: localize
@ -435,67 +425,79 @@ Zotero.Sync.Data.Local = {
// Sort parent objects first, to avoid retries due to unmet dependencies // Sort parent objects first, to avoid retries due to unmet dependencies
if (objectType == 'item' || objectType == 'collection') { if (objectType == 'item' || objectType == 'collection') {
let parentProp = 'parent' + objectType[0].toUpperCase() + objectType.substr(1); let parentProp = 'parent' + objectType[0].toUpperCase() + objectType.substr(1);
data.sort(function (a, b) { json.sort(function (a, b) {
if (a[parentProp] && !b[parentProp]) return 1; if (a[parentProp] && !b[parentProp]) return 1;
if (b[parentProp] && !a[parentProp]) return -1; if (b[parentProp] && !a[parentProp]) return -1;
return 0; return 0;
}); });
} }
var concurrentObjects = 5; var batchSize = 10;
yield Zotero.Utilities.Internal.forEachChunkAsync( var batchCounter = 0;
data, try {
concurrentObjects, for (let i = 0; i < json.length; i++) {
function (chunk) { // Batch notifier updates
return Zotero.DB.executeTransaction(function* () { if (batchCounter == 0) {
for (let i = 0; i < chunk.length; i++) { Zotero.Notifier.begin();
let json = chunk[i]; }
let jsonData = json.data; else if (batchCounter == batchSize || i == json.length - 1) {
let objectKey = json.key; Zotero.Notifier.commit();
Zotero.Notifier.begin();
Zotero.debug(`Processing ${objectType} ${libraryID}/${objectKey}`); }
Zotero.debug(json);
let jsonObject = json[i];
if (!jsonData) { let jsonData = jsonObject.data;
Zotero.logError(new Error("Missing 'data' object in JSON in sync cache for " let objectKey = jsonObject.key;
+ objectType + " " + libraryID + "/" + objectKey));
let saveOptions = {};
Object.assign(saveOptions, options);
saveOptions.isNewObject = false;
saveOptions.skipCache = false;
saveOptions.storageDetailsChanged = false;
Zotero.debug(`Processing ${objectType} ${libraryID}/${objectKey}`);
Zotero.debug(jsonObject);
// Skip objects with unmet dependencies
if (objectType == 'item' || objectType == 'collection') {
let parentProp = 'parent' + objectType[0].toUpperCase() + objectType.substr(1);
let parentKey = jsonData[parentProp];
if (parentKey) {
let parentObj = yield objectsClass.getByLibraryAndKeyAsync(
libraryID, parentKey, { noCache: true }
);
if (!parentObj) {
let error = new Error("Parent of " + objectType + " "
+ libraryID + "/" + jsonData.key + " not found -- skipping");
error.name = "ZoteroMissingObjectError";
Zotero.debug(error.message);
results.push({
key: objectKey,
processed: false,
error,
retry: true
});
continue; continue;
} }
}
// Skip objects with unmet dependencies
if (objectType == 'item' || objectType == 'collection') { /*if (objectType == 'item') {
let parentProp = 'parent' + objectType[0].toUpperCase() + objectType.substr(1); for (let j = 0; j < jsonData.collections.length; i++) {
let parentKey = jsonData[parentProp]; let parentKey = jsonData.collections[j];
if (parentKey) { let parentCollection = Zotero.Collections.getByLibraryAndKey(
let parentObj = yield objectsClass.getByLibraryAndKeyAsync( libraryID, parentKey, { noCache: true }
libraryID, parentKey, { noCache: true } );
); if (!parentCollection) {
if (!parentObj) { // ???
Zotero.debug("Parent of " + objectType + " "
+ libraryID + "/" + jsonData.key + " not found -- skipping");
// TEMP: Add parent to a queue, in case it's somehow missing
// after retrieving all objects?
numSkipped++;
continue;
}
} }
/*if (objectType == 'item') {
for (let j = 0; j < jsonData.collections.length; i++) {
let parentKey = jsonData.collections[j];
let parentCollection = Zotero.Collections.getByLibraryAndKey(
libraryID, parentKey, { noCache: true }
);
if (!parentCollection) {
// ???
}
}
}*/
} }
}*/
let isNewObject = false; }
let skipCache = false;
let storageDetailsChanged = false; // 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( let obj = yield objectsClass.getByLibraryAndKeyAsync(
libraryID, objectKey, { noCache: true } libraryID, objectKey, { noCache: true }
); );
@ -519,7 +521,7 @@ Zotero.Sync.Data.Local = {
if (objectType == 'item' && obj.isImportedAttachment()) { if (objectType == 'item' && obj.isImportedAttachment()) {
if (jsonDataLocal.mtime != jsonData.mtime if (jsonDataLocal.mtime != jsonData.mtime
|| jsonDataLocal.md5 != jsonData.md5) { || jsonDataLocal.md5 != jsonData.md5) {
storageDetailsChanged = true; saveOptions.storageDetailsChanged = true;
} }
} }
@ -535,19 +537,20 @@ Zotero.Sync.Data.Local = {
if (!result.changes.length && !result.conflicts.length) { if (!result.changes.length && !result.conflicts.length) {
Zotero.debug("No remote changes to apply to local " Zotero.debug("No remote changes to apply to local "
+ objectType + " " + obj.libraryKey); + objectType + " " + obj.libraryKey);
obj.version = json.version;
obj.synced = true;
yield obj.save();
if (objectType == 'item') { let saveResults = yield this._saveObjectFromJSON(
yield this._onItemProcessed( obj,
obj, jsonObject,
jsonData, {
isNewObject, skipData: true
storageDetailsChanged }
); );
results.push(saveResults);
if (!saveResults.processed) {
throw saveResults.error;
} }
continue; batchCounter++;
return;
} }
if (result.conflicts.length) { if (result.conflicts.length) {
@ -564,7 +567,7 @@ Zotero.Sync.Data.Local = {
changes: result.changes, changes: result.changes,
conflicts: result.conflicts conflicts: result.conflicts
}); });
continue; return;
} }
// If no conflicts, apply remote changes automatically // If no conflicts, apply remote changes automatically
@ -606,7 +609,7 @@ Zotero.Sync.Data.Local = {
}, },
right: jsonData right: jsonData
}); });
continue; return;
// Auto-restore some locally deleted objects that have changed remotely // Auto-restore some locally deleted objects that have changed remotely
case 'collection': case 'collection':
@ -632,31 +635,56 @@ Zotero.Sync.Data.Local = {
yield obj.loadPrimaryData(); yield obj.loadPrimaryData();
// Don't cache new items immediately, which skips reloading after save // Don't cache new items immediately, which skips reloading after save
skipCache = true; saveOptions.skipCache = true;
} }
let saved = yield this._saveObjectFromJSON( let saveResults = yield this._saveObjectFromJSON(obj, jsonObject, saveOptions);
obj, jsonData, options, { skipCache } results.push(saveResults);
); if (!saveResults.processed) {
if (saved) { throw saveResults.error;
if (objectType == 'item') {
yield this._onItemProcessed(
obj,
jsonData,
isNewObject,
storageDetailsChanged
);
}
}
if (saved) {
numSaved++;
} }
batchCounter++;
}.bind(this));
}
catch (e) {
// Display nicer debug line for known errors
if (knownErrors.indexOf(e.name) != -1) {
let desc = e.name
.replace(/^Zotero/, "")
// Convert "MissingObjectError" to "missing object error"
.split(/([a-z]+)/).join(' ').trim()
.replace(/([A-Z]) ([a-z]+)/g, "$1$2").toLowerCase();
let msg = Zotero.Utilities.capitalize(desc) + " for "
+ `${objectType} ${jsonObject.key} in ${Zotero.Libraries.get(libraryID).name}`;
Zotero.debug(msg, 2);
Zotero.debug(e, 2);
Components.utils.reportError(msg + ": " + e.message);
} }
}.bind(this)); else {
}.bind(this) Zotero.logError(e);
); }
if (options.onError) {
options.onError(e);
}
if (options.stopOnError) {
throw e;
}
}
}
}
catch (e) {
Zotero.Notifier.reset();
throw e;
}
finally {
Zotero.Notifier.commit();
}
//
// Conflict resolution
//
if (conflicts.length) { if (conflicts.length) {
// Sort conflicts by local Date Modified/Deleted // Sort conflicts by local Date Modified/Deleted
conflicts.sort(function (a, b) { conflicts.sort(function (a, b) {
@ -674,79 +702,163 @@ Zotero.Sync.Data.Local = {
var mergeData = this.resolveConflicts(conflicts); var mergeData = this.resolveConflicts(conflicts);
if (mergeData) { if (mergeData) {
Zotero.debug("Processing resolved conflicts"); Zotero.debug("Processing resolved conflicts");
let mergeOptions = {};
Object.assign(mergeOptions, options); let batchSize = 50;
// Tell _saveObjectFromJSON not to save with 'synced' set to true let batchCounter = 0;
mergeOptions.saveAsChanged = true; try {
let concurrentObjects = 50; for (let i = 0; i < mergeData.length; i++) {
yield Zotero.Utilities.Internal.forEachChunkAsync( // Batch notifier updates
mergeData, if (batchCounter == 0) {
concurrentObjects, Zotero.Notifier.begin();
function (chunk) { }
return Zotero.DB.executeTransaction(function* () { else if (batchCounter == batchSize || i == json.length - 1) {
for (let json of chunk) { Zotero.Notifier.commit();
Zotero.Notifier.begin();
}
let json = mergeData[i];
let saveOptions = {};
Object.assign(saveOptions, options);
// Tell _saveObjectFromJSON to save as unsynced
saveOptions.saveAsChanged = true;
// 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( let obj = yield objectsClass.getByLibraryAndKeyAsync(
libraryID, json.key, { noCache: true } libraryID, json.key, { noCache: true }
); );
// Update object with merge data // Update object with merge data
if (obj) { if (obj) {
// Delete local object
if (json.deleted) { if (json.deleted) {
yield obj.erase(); try {
} yield obj.erase();
else { }
yield this._saveObjectFromJSON(obj, json, mergeOptions); 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
} }
// Recreate deleted object // If no local object and merge wanted a delete, we're good
else if (!json.deleted) { else if (json.deleted) {
results.push({
key: json.key,
processed: true
});
return;
}
// Recreate locally deleted object
else {
obj = new Zotero[ObjectType]; obj = new Zotero[ObjectType];
obj.libraryID = libraryID; obj.libraryID = libraryID;
obj.key = json.key; obj.key = json.key;
yield obj.loadPrimaryData(); yield obj.loadPrimaryData();
let saved = yield this._saveObjectFromJSON(obj, json, options, { // Don't cache new items immediately,
// Don't cache new items immediately, which skips reloading after save // which skips reloading after save
skipCache: true saveOptions.skipCache = true;
});
} }
let saveResults = yield this._saveObjectFromJSON(
obj, json, saveOptions
);
results.push(saveResults);
if (!saveResults.processed) {
throw saveResults.error;
}
}.bind(this));
}
catch (e) {
Zotero.logError(e);
if (options.onError) {
options.onError(e);
} }
}.bind(this));
}.bind(this) if (options.stopOnError) {
); throw e;
}
}
}
}
catch (e) {
Zotero.Notifier.reset();
}
finally {
Zotero.Notifier.commit();
}
} }
} }
// Keep retrying if we skipped any, as long as we're still making progress let processed = 0;
if (numSkipped && numSaved != 0) { let skipped = 0;
Zotero.debug("More " + objectTypePlural + " in cache -- continuing"); results.forEach(x => x.processed ? processed++ : skipped++);
return this.processSyncCacheForObjectType(libraryID, objectType, options);
}
data = yield this._getUnwrittenData(libraryID, objectType); Zotero.debug(`Processed ${processed} `
if (data.length) { + (processed == 1 ? objectType : objectTypePlural)
Zotero.debug(`Skipping ${data.length} ` + (skipped ? ` and skipped ${skipped}` : "")
+ (data.length == 1 ? objectType : objectTypePlural) + " in " + libraryName);
+ " in sync cache");
} return results;
}), }),
_onItemProcessed: Zotero.Promise.coroutine(function* (item, jsonData, isNewObject, storageDetailsChanged) { _checkCacheJSON: function (json) {
// Delete older versions of the item in the cache if (json.key === undefined) {
Zotero.debug(json, 1);
throw new Error("Missing 'key' property in JSON");
}
if (json.version === undefined) {
Zotero.debug(json, 1);
throw new Error("Missing 'version' property in JSON");
}
// If direct data object passed, wrap in fake response object
return json.data === undefined ? {
key: json.key,
version: json.version,
data: json
} : json;
},
_onObjectProcessed: Zotero.Promise.coroutine(function* (obj, jsonObject, isNewObject, storageDetailsChanged) {
var jsonData = jsonObject.data;
// Delete older versions of the object in the cache
yield this.deleteCacheObjectVersions( yield this.deleteCacheObjectVersions(
'item', item.libraryID, jsonData.key, null, jsonData.version - 1 obj.objectType, obj.libraryID, jsonData.key, null, jsonData.version - 1
); );
// Delete from sync queue
yield this._removeObjectFromSyncQueue(obj.objectType, obj.libraryID, jsonData.key);
// Mark updated attachments for download // Mark updated attachments for download
if (item.isImportedAttachment()) { if (obj.objectType == 'item' && obj.isImportedAttachment()) {
// If storage changes were made (attachment mtime or hash), mark // If storage changes were made (attachment mtime or hash), mark
// library as requiring download // library as requiring download
if (isNewObject || storageDetailsChanged) { if (isNewObject || storageDetailsChanged) {
Zotero.Libraries.get(item.libraryID).storageDownloadNeeded = true; Zotero.Libraries.get(obj.libraryID).storageDownloadNeeded = true;
} }
yield this._checkAttachmentForDownload( yield this._checkAttachmentForDownload(
item, jsonData.mtime, isNewObject obj, jsonData.mtime, isNewObject
); );
} }
}), }),
@ -811,7 +923,7 @@ Zotero.Sync.Data.Local = {
sql += " AND version>=?"; sql += " AND version>=?";
params.push(minVersion); params.push(minVersion);
} }
if (maxVersion) { if (maxVersion || maxVersion === 0) {
sql += " AND version<=?"; sql += " AND version<=?";
params.push(maxVersion); params.push(maxVersion);
} }
@ -873,50 +985,44 @@ Zotero.Sync.Data.Local = {
_saveObjectFromJSON: Zotero.Promise.coroutine(function* (obj, json, options) { _saveObjectFromJSON: Zotero.Promise.coroutine(function* (obj, json, options) {
try { try {
obj.fromJSON(json); json = this._checkCacheJSON(json);
var results = {
key: json.key
};
if (!options.skipData) {
obj.fromJSON(json.data);
}
obj.version = json.data.version;
if (!options.saveAsChanged) { if (!options.saveAsChanged) {
obj.version = json.version;
obj.synced = true; obj.synced = true;
} }
Zotero.debug("SAVING " + json.key + " WITH SYNCED");
Zotero.debug(obj.version);
yield obj.save({ yield obj.save({
skipEditCheck: true, skipEditCheck: true,
skipDateModifiedUpdate: true, skipDateModifiedUpdate: true,
skipSelect: true, skipSelect: true,
skipCache: options.skipCache || false,
// Errors are logged elsewhere, so skip in DataObject.save()
errorHandler: function (e) { errorHandler: function (e) {
// Don't log expected errors return;
if (e.name == 'ZoteroUnknownTypeError'
&& e.name == 'ZoteroUnknownFieldError'
&& e.name == 'ZoteroMissingObjectError') {
return;
}
Zotero.debug(e, 1);
} }
}); });
yield this.saveCacheObject(obj.objectType, obj.libraryID, json.data);
results.processed = true;
yield this._onObjectProcessed(
obj,
json,
options.isNewObject,
options.storageDetailsChanged
);
} }
catch (e) { catch (e) {
if (e.name == 'ZoteroUnknownTypeError' // For now, allow sync to proceed after all errors
|| e.name == 'ZoteroUnknownFieldError' results.processed = false;
|| e.name == 'ZoteroMissingObjectError') { results.error = e;
let desc = e.name results.retry = false;
.replace(/^Zotero/, "")
// Convert "MissingObjectError" to "missing object error"
.split(/([a-z]+)/).join(' ').trim()
.replace(/([A-Z]) ([a-z]+)/g, "$1$2").toLowerCase();
Zotero.logError("Ignoring " + desc + " for "
+ obj.objectType + " " + obj.libraryKey, 2);
}
else if (options.stopOnError) {
throw e;
}
else {
Zotero.logError(e);
options.onError(e);
}
return false;
} }
return true; return results;
}), }),
@ -1094,26 +1200,6 @@ Zotero.Sync.Data.Local = {
}, },
/**
* @return {Promise<Object[]>} A promise for an array of JSON objects
*/
_getUnwrittenData: function (libraryID, objectType, max) {
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
// The MAX(version) ensures we get the data from the most recent version of the object,
// thanks to SQLite 3.7.11 (http://www.sqlite.org/releaselog/3_7_11.html)
var sql = "SELECT data, MAX(SC.version) AS version FROM syncCache SC "
+ "LEFT JOIN " + objectsClass.table + " O "
+ "USING (libraryID, key) "
+ "WHERE SC.libraryID=? AND "
+ "syncObjectTypeID IN (SELECT syncObjectTypeID FROM "
+ "syncObjectTypes WHERE name='" + objectType + "') "
// If saved version doesn't have a version or is less than the cache version
+ "AND IFNULL(O.version, 0) < SC.version "
+ "GROUP BY SC.libraryID, SC.key";
return Zotero.DB.queryAsync(sql, [libraryID]).map(row => JSON.parse(row.data));
},
markObjectAsSynced: Zotero.Promise.method(function (obj) { markObjectAsSynced: Zotero.Promise.method(function (obj) {
obj.synced = true; obj.synced = true;
return obj.saveTx({ return obj.saveTx({
@ -1176,5 +1262,127 @@ Zotero.Sync.Data.Local = {
}) })
); );
}.bind(this)); }.bind(this));
},
addObjectsToSyncQueue: Zotero.Promise.coroutine(function* (objectType, libraryID, keys) {
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
var now = Zotero.Date.getUnixTimestamp();
// Default to first try
var keyTries = {};
keys.forEach(key => keyTries[key] = 0);
// Check current try counts
var sql = "SELECT key, tries FROM syncQueue WHERE ";
yield Zotero.Utilities.Internal.forEachChunkAsync(
keys,
Math.floor(Zotero.DB.MAX_BOUND_PARAMETERS / 3),
Zotero.Promise.coroutine(function* (chunk) {
var params = chunk.reduce(
(arr, key) => arr.concat([libraryID, key, syncObjectTypeID]), []
);
var rows = yield Zotero.DB.queryAsync(
sql + Array(chunk.length)
.fill('(libraryID=? AND key=? AND syncObjectTypeID=?)')
.join(' OR '),
params
);
for (let row of rows) {
keyTries[row.key] = row.tries + 1; // increment current count
}
})
);
// Insert or update
yield Zotero.DB.executeTransaction(function* () {
var sql = "INSERT OR REPLACE INTO syncQueue "
+ "(libraryID, key, syncObjectTypeID, lastCheck, tries) VALUES ";
return Zotero.Utilities.Internal.forEachChunkAsync(
keys,
Math.floor(Zotero.DB.MAX_BOUND_PARAMETERS / 3),
function (chunk) {
var params = chunk.reduce(
(arr, key) => arr.concat(
[libraryID, key, syncObjectTypeID, now, keyTries[key]]
), []
);
return Zotero.DB.queryAsync(
sql + Array(chunk.length).fill('(?, ?, ?, ?, ?)').join(', '), params
);
}
);
}.bind(this));
}),
getObjectsFromSyncQueue: function (objectType, libraryID) {
return Zotero.DB.columnQueryAsync(
"SELECT key FROM syncQueue WHERE libraryID=? AND "
+ "syncObjectTypeID IN (SELECT syncObjectTypeID FROM syncObjectTypes WHERE name=?)",
[libraryID, objectType]
);
},
getObjectsToTryFromSyncQueue: Zotero.Promise.coroutine(function* (objectType, libraryID) {
var rows = yield Zotero.DB.queryAsync(
"SELECT key, lastCheck, tries FROM syncQueue WHERE libraryID=? AND "
+ "syncObjectTypeID IN (SELECT syncObjectTypeID FROM syncObjectTypes WHERE name=?)",
[libraryID, objectType]
);
var keysToTry = [];
for (let row of rows) {
let interval = this._syncQueueIntervals[row.tries];
// Keep using last interval if beyond
if (!interval) {
interval = this._syncQueueIntervals[this._syncQueueIntervals.length - 1];
}
let nextCheck = row.lastCheck + interval * 60 * 60;
if (nextCheck <= Zotero.Date.getUnixTimestamp()) {
keysToTry.push(row.key);
}
}
return keysToTry;
}),
removeObjectsFromSyncQueue: function (objectType, libraryID, keys) {
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
var sql = "DELETE FROM syncQueue WHERE libraryID=? AND syncObjectTypeID=? AND key IN (";
return Zotero.DB.executeTransaction(function* () {
return Zotero.Utilities.Internal.forEachChunkAsync(
keys,
Zotero.DB.MAX_BOUND_PARAMETERS - 2,
Zotero.Promise.coroutine(function* (chunk) {
var params = [libraryID, syncObjectTypeID].concat(chunk);
return Zotero.DB.queryAsync(
sql + Array(chunk.length).fill('?').join(',') + ")", params
);
})
);
}.bind(this));
},
_removeObjectFromSyncQueue: function (objectType, libraryID, key) {
return Zotero.DB.queryAsync(
"DELETE FROM syncQueue WHERE libraryID=? AND key=? AND syncObjectTypeID=?",
[
libraryID,
key,
Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType)
]
);
},
resetSyncQueue: function () {
return Zotero.DB.queryAsync("DELETE FROM syncQueue");
},
resetSyncQueueTries: function () {
return Zotero.DB.queryAsync("UPDATE syncQueue SET tries=0");
} }
} }

View file

@ -31,7 +31,7 @@ if (!Zotero.Sync) {
// Initialized as Zotero.Sync.Runner in zotero.js // Initialized as Zotero.Sync.Runner in zotero.js
Zotero.Sync.Runner_Module = function (options = {}) { Zotero.Sync.Runner_Module = function (options = {}) {
const stopOnError = true; const stopOnError = false;
Zotero.defineProperty(this, 'background', { get: () => _background }); Zotero.defineProperty(this, 'background', { get: () => _background });
Zotero.defineProperty(this, 'lastSyncStatus', { get: () => _lastSyncStatus }); Zotero.defineProperty(this, 'lastSyncStatus', { get: () => _lastSyncStatus });

View file

@ -1,4 +1,4 @@
-- 83 -- 84
-- Copyright (c) 2009 Center for History and New Media -- Copyright (c) 2009 Center for History and New Media
-- George Mason University, Fairfax, Virginia, USA -- George Mason University, Fairfax, Virginia, USA
@ -328,6 +328,17 @@ CREATE TABLE syncDeleteLog (
FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE
); );
CREATE TABLE syncQueue (
libraryID INT NOT NULL,
key TEXT NOT NULL,
syncObjectTypeID INT NOT NULL,
lastCheck TIMESTAMP,
tries INT,
PRIMARY KEY (libraryID, key, syncObjectTypeID),
FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE,
FOREIGN KEY (syncObjectTypeID) REFERENCES syncObjectTypes(syncObjectTypeID) ON DELETE CASCADE
);
CREATE TABLE storageDeleteLog ( CREATE TABLE storageDeleteLog (
libraryID INT NOT NULL, libraryID INT NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,

View file

@ -1,4 +1,30 @@
describe("Zotero.Schema", function() { describe("Zotero.Schema", function() {
describe("#initializeSchema()", function () {
it("should set last client version", function* () {
yield resetDB({
thisArg: this,
skipBundledFiles: true
});
var sql = "SELECT value FROM settings WHERE setting='client' AND key='lastVersion'";
var lastVersion = yield Zotero.DB.valueQueryAsync(sql);
yield assert.eventually.equal(Zotero.DB.valueQueryAsync(sql), Zotero.version);
});
});
describe("#updateSchema()", function () {
it("should set last client version", function* () {
var sql = "REPLACE INTO settings (setting, key, value) VALUES ('client', 'lastVersion', ?)";
return Zotero.DB.queryAsync(sql, "5.0old");
yield Zotero.Schema.updateSchema();
var sql = "SELECT value FROM settings WHERE setting='client' AND key='lastVersion'";
var lastVersion = yield Zotero.DB.valueQueryAsync(sql);
yield assert.eventually.equal(Zotero.DB.valueQueryAsync(sql), Zotero.version);
});
});
describe("#integrityCheck()", function () { describe("#integrityCheck()", function () {
before(function* () { before(function* () {
yield resetDB({ yield resetDB({

View file

@ -144,12 +144,8 @@ describe("Zotero.Sync.Storage.Local", function () {
md5, md5,
mtime mtime
}; };
yield Zotero.Sync.Data.Local.saveCacheObjects( yield Zotero.Sync.Data.Local.processObjectsFromJSON('item', libraryID, [json]);
'item', libraryID, [json]
);
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
libraryID, 'item', { stopOnError: true }
);
var item = yield Zotero.Items.getByLibraryAndKeyAsync(libraryID, key); var item = yield Zotero.Items.getByLibraryAndKeyAsync(libraryID, key);
yield Zotero.Sync.Storage.Local.processDownload({ yield Zotero.Sync.Storage.Local.processDownload({
item, item,

View file

@ -12,6 +12,10 @@ describe("Zotero.Sync.APIClient", function () {
} }
before(function () { before(function () {
Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
});
beforeEach(function () {
Components.utils.import("resource://zotero/concurrentCaller.js"); Components.utils.import("resource://zotero/concurrentCaller.js");
var caller = new ConcurrentCaller(1); var caller = new ConcurrentCaller(1);
caller.setLogger(msg => Zotero.debug(msg)); caller.setLogger(msg => Zotero.debug(msg));
@ -24,17 +28,13 @@ describe("Zotero.Sync.APIClient", function () {
} }
}; };
Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
client = new Zotero.Sync.APIClient({ client = new Zotero.Sync.APIClient({
baseURL, baseURL,
apiVersion: ZOTERO_CONFIG.API_VERSION, apiVersion: ZOTERO_CONFIG.API_VERSION,
apiKey, apiKey,
caller caller
}) })
})
beforeEach(function () {
server = sinon.fakeServer.create(); server = sinon.fakeServer.create();
server.autoRespond = true; server.autoRespond = true;
}) })
@ -44,16 +44,29 @@ describe("Zotero.Sync.APIClient", function () {
}) })
describe("#_checkConnection()", function () { describe("#_checkConnection()", function () {
it("should catch an error with an empty response", function* () { var spy;
beforeEach(function () {
client.failureDelayIntervals = [10];
client.failureDelayMax = 15;
});
afterEach(function () {
if (spy) {
spy.restore();
}
});
it("should retry on 500 error", function* () {
setResponse({ setResponse({
method: "GET", method: "GET",
url: "error", url: "error",
status: 500, status: 500,
text: "" text: ""
}) })
var spy = sinon.spy(Zotero.HTTP, "request");
var e = yield getPromiseError(client.makeRequest("GET", baseURL + "error")); var e = yield getPromiseError(client.makeRequest("GET", baseURL + "error"));
assert.ok(e); assert.instanceOf(e, Zotero.HTTP.UnexpectedStatusException);
assert.isTrue(e.message.startsWith(Zotero.getString('sync.error.emptyResponseServer'))); assert.isTrue(spy.calledTwice);
}) })
it("should catch an interrupted connection", function* () { it("should catch an interrupted connection", function* () {

View file

@ -46,7 +46,8 @@ describe("Zotero.Sync.Data.Engine", function () {
data: { data: {
key: options.key, key: options.key,
version: options.version, version: options.version,
name: options.name name: options.name,
parentCollection: options.parentCollection
} }
}; };
} }
@ -93,6 +94,14 @@ describe("Zotero.Sync.Data.Engine", function () {
item: makeItemJSON item: makeItemJSON
}; };
var assertInCache = Zotero.Promise.coroutine(function* (obj) {
var cacheObject = yield Zotero.Sync.Data.Local.getCacheObject(
obj.objectType, obj.libraryID, obj.key, obj.version
);
assert.isObject(cacheObject);
assert.propertyVal(cacheObject, 'key', obj.key);
});
// //
// Tests // Tests
// //
@ -250,23 +259,27 @@ describe("Zotero.Sync.Data.Engine", function () {
assert.equal(obj.name, 'A'); assert.equal(obj.name, 'A');
assert.equal(obj.version, 1); assert.equal(obj.version, 1);
assert.isTrue(obj.synced); assert.isTrue(obj.synced);
yield assertInCache(obj);
obj = yield Zotero.Searches.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA"); obj = yield Zotero.Searches.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA");
assert.equal(obj.name, 'A'); assert.equal(obj.name, 'A');
assert.equal(obj.version, 2); assert.equal(obj.version, 2);
assert.isTrue(obj.synced); assert.isTrue(obj.synced);
yield assertInCache(obj);
obj = yield Zotero.Items.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA"); obj = yield Zotero.Items.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA");
assert.equal(obj.getField('title'), 'A'); assert.equal(obj.getField('title'), 'A');
assert.equal(obj.version, 3); assert.equal(obj.version, 3);
assert.isTrue(obj.synced); assert.isTrue(obj.synced);
var parentItemID = obj.id; var parentItemID = obj.id;
yield assertInCache(obj);
obj = yield Zotero.Items.getByLibraryAndKeyAsync(userLibraryID, "BBBBBBBB"); obj = yield Zotero.Items.getByLibraryAndKeyAsync(userLibraryID, "BBBBBBBB");
assert.equal(obj.getNote(), 'This is a note.'); assert.equal(obj.getNote(), 'This is a note.');
assert.equal(obj.parentItemID, parentItemID); assert.equal(obj.parentItemID, parentItemID);
assert.equal(obj.version, 3); assert.equal(obj.version, 3);
assert.isTrue(obj.synced); assert.isTrue(obj.synced);
yield assertInCache(obj);
}) })
it("should upload new full items and subsequent patches", function* () { it("should upload new full items and subsequent patches", function* () {
@ -651,10 +664,211 @@ describe("Zotero.Sync.Data.Engine", function () {
}); });
yield engine.start(); yield engine.start();
}) })
it("should ignore errors when saving downloaded objects", function* () {
({ engine, client, caller } = yield setup());
engine.stopOnError = false;
var headers = {
"Last-Modified-Version": 3
};
setResponse({
method: "GET",
url: "users/1/settings",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/collections?format=versions",
status: 200,
headers: headers,
json: {
"AAAAAAAA": 1,
"BBBBBBBB": 1,
"CCCCCCCC": 1
}
});
setResponse({
method: "GET",
url: "users/1/searches?format=versions",
status: 200,
headers: headers,
json: {
"DDDDDDDD": 2,
"EEEEEEEE": 2,
"FFFFFFFF": 2
}
});
setResponse({
method: "GET",
url: "users/1/items/top?format=versions&includeTrashed=1",
status: 200,
headers: headers,
json: {
"GGGGGGGG": 3,
"HHHHHHHH": 3
}
});
setResponse({
method: "GET",
url: "users/1/items?format=versions&includeTrashed=1",
status: 200,
headers: headers,
json: {
"GGGGGGGG": 3,
"HHHHHHHH": 3,
"JJJJJJJJ": 3
}
});
setResponse({
method: "GET",
url: "users/1/collections?format=json&collectionKey=AAAAAAAA%2CBBBBBBBB%2CCCCCCCCC",
status: 200,
headers: headers,
json: [
makeCollectionJSON({
key: "AAAAAAAA",
version: 1,
name: "A"
}),
makeCollectionJSON({
key: "BBBBBBBB",
version: 1,
name: "B",
// Missing parent -- collection should be queued
parentCollection: "ZZZZZZZZ"
}),
makeCollectionJSON({
key: "CCCCCCCC",
version: 1,
name: "C",
// Unknown field -- should be ignored
unknownField: 5
})
]
});
setResponse({
method: "GET",
url: "users/1/searches?format=json&searchKey=DDDDDDDD%2CEEEEEEEE%2CFFFFFFFF",
status: 200,
headers: headers,
json: [
makeSearchJSON({
key: "DDDDDDDD",
version: 2,
name: "D",
conditions: [
{
condition: "title",
operator: "is",
value: "a"
}
]
}),
makeSearchJSON({
key: "EEEEEEEE",
version: 2,
name: "E",
conditions: [
{
// Unknown search condition -- search should be queued
condition: "unknownCondition",
operator: "is",
value: "a"
}
]
}),
makeSearchJSON({
key: "FFFFFFFF",
version: 2,
name: "F",
conditions: [
{
condition: "title",
// Unknown search operator -- search should be queued
operator: "unknownOperator",
value: "a"
}
]
})
]
});
setResponse({
method: "GET",
url: "users/1/items?format=json&itemKey=GGGGGGGG%2CHHHHHHHH&includeTrashed=1",
status: 200,
headers: headers,
json: [
makeItemJSON({
key: "GGGGGGGG",
version: 3,
itemType: "book",
title: "G",
// Unknown item field -- should be ignored
unknownField: "B"
}),
makeItemJSON({
key: "HHHHHHHH",
version: 3,
// Unknown item type -- item should be queued
itemType: "unknownItemType",
title: "H"
})
]
});
setResponse({
method: "GET",
url: "users/1/items?format=json&itemKey=JJJJJJJJ&includeTrashed=1",
status: 200,
headers: headers,
json: [
makeItemJSON({
key: "JJJJJJJJ",
version: 3,
itemType: "note",
// Parent that couldn't be saved -- item should be queued
parentItem: "HHHHHHHH",
note: "This is a note."
})
]
});
setResponse({
method: "GET",
url: "users/1/deleted?since=0",
status: 200,
headers: headers,
json: {}
});
var spy = sinon.spy(engine, "onError");
yield engine.start();
var userLibraryID = Zotero.Libraries.userLibraryID;
// Library version should have been updated
assert.equal(Zotero.Libraries.getVersion(userLibraryID), 3);
// Check for saved objects
yield assert.eventually.ok(Zotero.Collections.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA"));
yield assert.eventually.ok(Zotero.Searches.getByLibraryAndKeyAsync(userLibraryID, "DDDDDDDD"));
yield assert.eventually.ok(Zotero.Items.getByLibraryAndKeyAsync(userLibraryID, "GGGGGGGG"));
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('collection', userLibraryID);
assert.sameMembers(keys, ['BBBBBBBB']);
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('search', userLibraryID);
assert.sameMembers(keys, ['EEEEEEEE', 'FFFFFFFF']);
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', userLibraryID);
assert.sameMembers(keys, ['HHHHHHHH', 'JJJJJJJJ']);
assert.equal(spy.callCount, 3);
});
}) })
describe("#_startDownload()", function () { describe("#_startDownload()", function () {
it("shouldn't redownload objects already in the cache", function* () { it("shouldn't redownload objects that are already up to date", function* () {
var userLibraryID = Zotero.Libraries.userLibraryID; var userLibraryID = Zotero.Libraries.userLibraryID;
//yield Zotero.Libraries.setVersion(userLibraryID, 5); //yield Zotero.Libraries.setVersion(userLibraryID, 5);
({ engine, client, caller } = yield setup()); ({ engine, client, caller } = yield setup());
@ -1230,6 +1444,7 @@ describe("Zotero.Sync.Data.Engine", function () {
let obj = objectsClass.getByLibraryAndKey(userLibraryID, objectJSON[type][0].key); let obj = objectsClass.getByLibraryAndKey(userLibraryID, objectJSON[type][0].key);
assert.equal(obj.version, 20); assert.equal(obj.version, 20);
assert.isTrue(obj.synced); assert.isTrue(obj.synced);
yield assertInCache(obj);
// JSON objects 2 should be marked as unsynced, with their version reset to 0 // JSON objects 2 should be marked as unsynced, with their version reset to 0
assert.equal(objects[type][1].version, 0); assert.equal(objects[type][1].version, 0);

View file

@ -89,10 +89,10 @@ describe("Zotero.Sync.Data.Local", function() {
}) })
describe("#processSyncCacheForObjectType()", function () { describe("#processObjectsFromJSON()", function () {
var types = Zotero.DataObjectUtilities.getTypes(); var types = Zotero.DataObjectUtilities.getTypes();
before(function* () { beforeEach(function* () {
yield resetDB({ yield resetDB({
thisArg: this, thisArg: this,
skipBundledFiles: true skipBundledFiles: true
@ -113,11 +113,8 @@ describe("Zotero.Sync.Data.Local", function() {
version: 10, version: 10,
data: data data: data
}; };
yield Zotero.Sync.Data.Local.saveCacheObjects( yield Zotero.Sync.Data.Local.processObjectsFromJSON(
type, libraryID, [json] type, libraryID, [json], { stopOnError: true }
);
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
libraryID, type, { stopOnError: true }
); );
let localObj = objectsClass.getByLibraryAndKey(libraryID, obj.key); let localObj = objectsClass.getByLibraryAndKey(libraryID, obj.key);
assert.equal(localObj.version, 10); assert.equal(localObj.version, 10);
@ -148,12 +145,8 @@ describe("Zotero.Sync.Data.Local", function() {
version: 10, version: 10,
data: data data: data
}; };
yield Zotero.Sync.Data.Local.saveCacheObjects( yield Zotero.Sync.Data.Local.processObjectsFromJSON(
type, libraryID, [json] type, libraryID, [json], { stopOnError: true }
);
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
libraryID, type, { stopOnError: true }
); );
assert.equal(obj.version, 10); assert.equal(obj.version, 10);
assert.equal(obj.getField('title'), changedTitle); assert.equal(obj.getField('title'), changedTitle);
@ -164,23 +157,19 @@ describe("Zotero.Sync.Data.Local", function() {
var libraryID = Zotero.Libraries.userLibraryID; var libraryID = Zotero.Libraries.userLibraryID;
for (let type of types) { for (let type of types) {
let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
// Save original version
let obj = yield createDataObject(type, { version: 5 }); let obj = yield createDataObject(type, { version: 5 });
let data = obj.toJSON(); let data = obj.toJSON();
yield Zotero.Sync.Data.Local.saveCacheObjects( yield Zotero.Sync.Data.Local.saveCacheObjects(
type, libraryID, [data] type, libraryID, [data]
); );
}
for (let type of types) {
let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
let obj = yield createDataObject(type, { version: 10 }); // Save newer version
let data = obj.toJSON(); data.version = 10;
yield Zotero.Sync.Data.Local.saveCacheObjects( yield Zotero.Sync.Data.Local.processObjectsFromJSON(
type, libraryID, [data] type, libraryID, [data], { stopOnError: true }
);
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
libraryID, type, { stopOnError: true }
); );
let localObj = objectsClass.getByLibraryAndKey(libraryID, obj.key); let localObj = objectsClass.getByLibraryAndKey(libraryID, obj.key);
@ -195,7 +184,36 @@ describe("Zotero.Sync.Data.Local", function() {
"should have only latest version of " + type + " in cache" "should have only latest version of " + type + " in cache"
); );
} }
}) });
it("should delete object from sync queue after processing", function* () {
var objectType = 'item';
var libraryID = Zotero.Libraries.userLibraryID;
var key = Zotero.DataObjectUtilities.generateKey();
yield Zotero.Sync.Data.Local.addObjectsToSyncQueue(objectType, libraryID, [key]);
var versions = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue(objectType, libraryID);
assert.include(versions, key);
var json = {
key,
version: 10,
data: {
key,
version: 10,
itemType: "book",
title: "Test"
}
};
yield Zotero.Sync.Data.Local.processObjectsFromJSON(
objectType, libraryID, [json], { stopOnError: true }
);
var versions = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue(objectType, libraryID);
assert.notInclude(versions, key);
});
it("should mark new attachment items for download", function* () { it("should mark new attachment items for download", function* () {
var libraryID = Zotero.Libraries.userLibraryID; var libraryID = Zotero.Libraries.userLibraryID;
@ -216,11 +234,8 @@ describe("Zotero.Sync.Data.Local", function() {
} }
}; };
yield Zotero.Sync.Data.Local.saveCacheObjects( yield Zotero.Sync.Data.Local.processObjectsFromJSON(
'item', Zotero.Libraries.userLibraryID, [json] 'item', libraryID, [json], { stopOnError: true }
);
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
libraryID, 'item', { stopOnError: true }
); );
var item = Zotero.Items.getByLibraryAndKey(libraryID, key); var item = Zotero.Items.getByLibraryAndKey(libraryID, key);
assert.equal(item.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_TO_DOWNLOAD); assert.equal(item.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_TO_DOWNLOAD);
@ -247,12 +262,8 @@ describe("Zotero.Sync.Data.Local", function() {
json.data.version = 10; json.data.version = 10;
json.data.md5 = '57f8a4fda823187b91e1191487b87fe6'; json.data.md5 = '57f8a4fda823187b91e1191487b87fe6';
json.data.mtime = new Date().getTime() + 10000; json.data.mtime = new Date().getTime() + 10000;
yield Zotero.Sync.Data.Local.saveCacheObjects( yield Zotero.Sync.Data.Local.processObjectsFromJSON(
'item', Zotero.Libraries.userLibraryID, [json] 'item', libraryID, [json], { stopOnError: true }
);
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
libraryID, 'item', { stopOnError: true }
); );
assert.equal(item.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_TO_DOWNLOAD); assert.equal(item.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_TO_DOWNLOAD);
@ -284,17 +295,188 @@ describe("Zotero.Sync.Data.Local", function() {
json.data.version = 10; json.data.version = 10;
json.data.md5 = '57f8a4fda823187b91e1191487b87fe6'; json.data.md5 = '57f8a4fda823187b91e1191487b87fe6';
json.data.mtime = new Date().getTime() + 10000; json.data.mtime = new Date().getTime() + 10000;
yield Zotero.Sync.Data.Local.saveCacheObjects('item', libraryID, [json]); yield Zotero.Sync.Data.Local.processObjectsFromJSON(
'item', libraryID, [json], { stopOnError: true }
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
libraryID, 'item', { stopOnError: true }
); );
assert.equal(item.getField('title'), newTitle); assert.equal(item.getField('title'), newTitle);
assert.equal(item.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_TO_DOWNLOAD); assert.equal(item.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_TO_DOWNLOAD);
}) })
it("should roll back partial object changes on error", function* () {
var libraryID = Zotero.Libraries.publicationsLibraryID;
var key1 = "AAAAAAAA";
var key2 = "BBBBBBBB";
var json = [
{
key: key1,
version: 1,
data: {
key: key1,
version: 1,
itemType: "book",
title: "Test A"
}
},
{
key: key2,
version: 1,
data: {
key: key2,
version: 1,
itemType: "journalArticle",
title: "Test B",
deleted: true // Not allowed in My Publications
}
}
];
yield Zotero.Sync.Data.Local.processObjectsFromJSON('item', libraryID, json);
// Shouldn't roll back the successful item
yield assert.eventually.equal(Zotero.DB.valueQueryAsync(
"SELECT COUNT(*) FROM items WHERE libraryID=? AND key=?", [libraryID, key1]
), 1);
// Should rollback the unsuccessful item
yield assert.eventually.equal(Zotero.DB.valueQueryAsync(
"SELECT COUNT(*) FROM items WHERE libraryID=? AND key=?", [libraryID, key2]
), 0);
});
}) })
describe("Sync Queue", function () {
var lib1, lib2;
before(function* () {
lib1 = Zotero.Libraries.userLibraryID;
lib2 = Zotero.Libraries.publicationsLibraryID;
});
beforeEach(function* () {
yield Zotero.DB.queryAsync("DELETE FROM syncQueue");
});
after(function* () {
yield Zotero.DB.queryAsync("DELETE FROM syncQueue");
});
describe("#addObjectsToSyncQueue()", function () {
it("should add new objects and update lastCheck and tries for existing objects", function* () {
var objectType = 'item';
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
var now = Zotero.Date.getUnixTimestamp();
var key1 = Zotero.DataObjectUtilities.generateKey();
var key2 = Zotero.DataObjectUtilities.generateKey();
var key3 = Zotero.DataObjectUtilities.generateKey();
var key4 = Zotero.DataObjectUtilities.generateKey();
yield Zotero.DB.queryAsync(
"INSERT INTO syncQueue (libraryID, key, syncObjectTypeID, lastCheck, tries) "
+ "VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)",
[
lib1, key1, syncObjectTypeID, now - 3700, 0,
lib1, key2, syncObjectTypeID, now - 7000, 1,
lib2, key3, syncObjectTypeID, now - 86400, 2
]
);
yield Zotero.Sync.Data.Local.addObjectsToSyncQueue(objectType, lib1, [key1, key2]);
yield Zotero.Sync.Data.Local.addObjectsToSyncQueue(objectType, lib2, [key4]);
var sql = "SELECT lastCheck, tries FROM syncQueue WHERE libraryID=? "
+ `AND syncObjectTypeID=${syncObjectTypeID} AND key=?`;
var row;
// key1
row = yield Zotero.DB.rowQueryAsync(sql, [lib1, key1]);
assert.approximately(row.lastCheck, now, 1);
assert.equal(row.tries, 1);
// key2
row = yield Zotero.DB.rowQueryAsync(sql, [lib1, key2]);
assert.approximately(row.lastCheck, now, 1);
assert.equal(row.tries, 2);
// key3
row = yield Zotero.DB.rowQueryAsync(sql, [lib2, key3]);
assert.equal(row.lastCheck, now - 86400);
assert.equal(row.tries, 2);
// key4
row = yield Zotero.DB.rowQueryAsync(sql, [lib2, key4]);
assert.approximately(row.lastCheck, now, 1);
assert.equal(row.tries, 0);
});
});
describe("#getObjectsToTryFromSyncQueue()", function () {
it("should get objects that should be retried", function* () {
var objectType = 'item';
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
var now = Zotero.Date.getUnixTimestamp();
var key1 = Zotero.DataObjectUtilities.generateKey();
var key2 = Zotero.DataObjectUtilities.generateKey();
var key3 = Zotero.DataObjectUtilities.generateKey();
var key4 = Zotero.DataObjectUtilities.generateKey();
yield Zotero.DB.queryAsync(
"INSERT INTO syncQueue (libraryID, key, syncObjectTypeID, lastCheck, tries) "
+ "VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)",
[
lib1, key1, syncObjectTypeID, now - (30 * 60) - 10, 0, // more than half an hour, so should be retried
lib1, key2, syncObjectTypeID, now - (16 * 60 * 60) + 10, 4, // less than 16 hours, shouldn't be retried
lib2, key3, syncObjectTypeID, now - 86400 * 7, 20 // more than 64 hours, so should be retried
]
);
var keys = yield Zotero.Sync.Data.Local.getObjectsToTryFromSyncQueue('item', lib1);
assert.sameMembers(keys, [key1]);
var keys = yield Zotero.Sync.Data.Local.getObjectsToTryFromSyncQueue('item', lib2);
assert.sameMembers(keys, [key3]);
});
});
describe("#removeObjectsFromSyncQueue()", function () {
it("should remove objects from the sync queue", function* () {
var objectType = 'item';
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
var now = Zotero.Date.getUnixTimestamp();
var key1 = Zotero.DataObjectUtilities.generateKey();
var key2 = Zotero.DataObjectUtilities.generateKey();
var key3 = Zotero.DataObjectUtilities.generateKey();
yield Zotero.DB.queryAsync(
"INSERT INTO syncQueue (libraryID, key, syncObjectTypeID, lastCheck, tries) "
+ "VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)",
[
lib1, key1, syncObjectTypeID, now, 0,
lib1, key2, syncObjectTypeID, now, 4,
lib2, key3, syncObjectTypeID, now, 20
]
);
yield Zotero.Sync.Data.Local.removeObjectsFromSyncQueue('item', lib1, [key1]);
var sql = "SELECT COUNT(*) FROM syncQueue WHERE libraryID=? "
+ `AND syncObjectTypeID=${syncObjectTypeID} AND key=?`;
assert.notOk(yield Zotero.DB.valueQueryAsync(sql, [lib1, key1]));
assert.ok(yield Zotero.DB.valueQueryAsync(sql, [lib1, key2]));
assert.ok(yield Zotero.DB.valueQueryAsync(sql, [lib2, key3]));
})
});
describe("#resetSyncQueueTries", function () {
var spy;
after(function () {
if (spy) {
spy.restore();
}
})
it("should be run on version upgrade", function* () {
var sql = "REPLACE INTO settings (setting, key, value) VALUES ('client', 'lastVersion', ?)";
yield Zotero.DB.queryAsync(sql, "5.0foo");
spy = sinon.spy(Zotero.Sync.Data.Local, "resetSyncQueueTries");
yield Zotero.Schema.updateSchema();
assert.ok(spy.called);
});
});
});
describe("Conflict Resolution", function () { describe("Conflict Resolution", function () {
beforeEach(function* () { beforeEach(function* () {
yield Zotero.DB.queryAsync("DELETE FROM syncCache"); yield Zotero.DB.queryAsync("DELETE FROM syncCache");
@ -311,6 +493,7 @@ describe("Zotero.Sync.Data.Local", function() {
var objects = []; var objects = [];
var values = []; var values = [];
var dateAdded = Date.now() - 86400000; var dateAdded = Date.now() - 86400000;
var downloadedJSON = [];
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
values.push({ values.push({
left: {}, left: {},
@ -339,14 +522,15 @@ describe("Zotero.Sync.Data.Local", function() {
}; };
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]); yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Create new version in cache, simulating a download // Create updated JSON, simulating a download
json.version = jsonData.version = 15;
values[i].right.title = jsonData.title = Zotero.Utilities.randomString(); values[i].right.title = jsonData.title = Zotero.Utilities.randomString();
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]); values[i].right.version = json.version = jsonData.version = 15;
downloadedJSON.push(json);
// Modify object locally // Modify object locally
yield modifyDataObject(obj, undefined, { skipDateModifiedUpdate: true }); yield modifyDataObject(obj, undefined, { skipDateModifiedUpdate: true });
values[i].left.title = obj.getField('title'); values[i].left.title = obj.getField('title');
values[i].left.version = obj.getField('version');
} }
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) { waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
@ -373,12 +557,14 @@ describe("Zotero.Sync.Data.Local", function() {
} }
wizard.getButton('finish').click(); wizard.getButton('finish').click();
}) })
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType( yield Zotero.Sync.Data.Local.processObjectsFromJSON(
libraryID, type, { stopOnError: true } type, libraryID, downloadedJSON, { stopOnError: true }
); );
assert.equal(objects[0].getField('title'), values[0].right.title); assert.equal(objects[0].getField('title'), values[0].right.title);
assert.equal(objects[1].getField('title'), values[1].left.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* () { it("should resolve all remaining conflicts with one side", function* () {
@ -388,6 +574,7 @@ describe("Zotero.Sync.Data.Local", function() {
var objects = []; var objects = [];
var values = []; var values = [];
var downloadedJSON = [];
var dateAdded = Date.now() - 86400000; var dateAdded = Date.now() - 86400000;
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
values.push({ values.push({
@ -418,13 +605,15 @@ describe("Zotero.Sync.Data.Local", function() {
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]); yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Create new version in cache, simulating a download // Create new version in cache, simulating a download
json.version = jsonData.version = 15;
values[i].right.title = jsonData.title = Zotero.Utilities.randomString(); 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]); yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
downloadedJSON.push(json);
// Modify object locally // Modify object locally
yield modifyDataObject(obj, undefined, { skipDateModifiedUpdate: true }); yield modifyDataObject(obj, undefined, { skipDateModifiedUpdate: true });
values[i].left.title = obj.getField('title'); values[i].left.title = obj.getField('title');
values[i].left.version = obj.getField('version');
} }
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) { waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
@ -460,13 +649,16 @@ describe("Zotero.Sync.Data.Local", function() {
} }
wizard.getButton('finish').click(); wizard.getButton('finish').click();
}) })
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType( yield Zotero.Sync.Data.Local.processObjectsFromJSON(
libraryID, type, { stopOnError: true } type, libraryID, downloadedJSON, { stopOnError: true }
); );
assert.equal(objects[0].getField('title'), values[0].right.title); 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('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('title'), values[2].left.title);
assert.equal(objects[2].getField('version'), values[2].left.version);
}) })
it("should handle local item deletion, keeping deletion", function* () { it("should handle local item deletion, keeping deletion", function* () {
@ -475,6 +667,8 @@ describe("Zotero.Sync.Data.Local", function() {
var type = 'item'; var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type); var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var downloadedJSON = [];
// Create object, generate JSON, and delete // Create object, generate JSON, and delete
var obj = yield createDataObject(type, { version: 10 }); var obj = yield createDataObject(type, { version: 10 });
var jsonData = obj.toJSON(); var jsonData = obj.toJSON();
@ -491,7 +685,7 @@ describe("Zotero.Sync.Data.Local", function() {
// Create new version in cache, simulating a download // Create new version in cache, simulating a download
json.version = jsonData.version = 15; json.version = jsonData.version = 15;
jsonData.title = Zotero.Utilities.randomString(); jsonData.title = Zotero.Utilities.randomString();
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]); downloadedJSON.push(json);
var windowOpened = false; var windowOpened = false;
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) { waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
@ -508,8 +702,8 @@ describe("Zotero.Sync.Data.Local", function() {
mergeGroup.leftpane.pane.click(); mergeGroup.leftpane.pane.click();
wizard.getButton('finish').click(); wizard.getButton('finish').click();
}) })
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType( yield Zotero.Sync.Data.Local.processObjectsFromJSON(
libraryID, type, { stopOnError: true } type, libraryID, downloadedJSON, { stopOnError: true }
); );
assert.isTrue(windowOpened); assert.isTrue(windowOpened);
@ -523,6 +717,8 @@ describe("Zotero.Sync.Data.Local", function() {
var type = 'item'; var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type); var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var downloadedJSON = [];
// Create object, generate JSON, and delete // Create object, generate JSON, and delete
var obj = yield createDataObject(type, { version: 10 }); var obj = yield createDataObject(type, { version: 10 });
var jsonData = obj.toJSON(); var jsonData = obj.toJSON();
@ -538,7 +734,7 @@ describe("Zotero.Sync.Data.Local", function() {
// Create new version in cache, simulating a download // Create new version in cache, simulating a download
json.version = jsonData.version = 15; json.version = jsonData.version = 15;
jsonData.title = Zotero.Utilities.randomString(); jsonData.title = Zotero.Utilities.randomString();
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]); downloadedJSON.push(json);
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) { waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document; var doc = dialog.document;
@ -551,8 +747,8 @@ describe("Zotero.Sync.Data.Local", function() {
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true'); assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
wizard.getButton('finish').click(); wizard.getButton('finish').click();
}) })
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType( yield Zotero.Sync.Data.Local.processObjectsFromJSON(
libraryID, type, { stopOnError: true } type, libraryID, downloadedJSON, { stopOnError: true }
); );
obj = objectsClass.getByLibraryAndKey(libraryID, key); obj = objectsClass.getByLibraryAndKey(libraryID, key);
@ -562,9 +758,9 @@ describe("Zotero.Sync.Data.Local", function() {
it("should handle note conflict", function* () { it("should handle note conflict", function* () {
var libraryID = Zotero.Libraries.userLibraryID; var libraryID = Zotero.Libraries.userLibraryID;
var type = 'item'; var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type); var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var downloadedJSON = [];
var noteText1 = "<p>A</p>"; var noteText1 = "<p>A</p>";
var noteText2 = "<p>B</p>"; var noteText2 = "<p>B</p>";
@ -586,7 +782,7 @@ describe("Zotero.Sync.Data.Local", function() {
// Create new version in cache, simulating a download // Create new version in cache, simulating a download
json.version = jsonData.version = 15; json.version = jsonData.version = 15;
json.data.note = noteText2; json.data.note = noteText2;
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]); downloadedJSON.push(json);
// Delete object locally // Delete object locally
obj.setNote(noteText1); obj.setNote(noteText1);
@ -600,8 +796,8 @@ describe("Zotero.Sync.Data.Local", function() {
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true'); assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
wizard.getButton('finish').click(); wizard.getButton('finish').click();
}) })
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType( yield Zotero.Sync.Data.Local.processObjectsFromJSON(
libraryID, type, { stopOnError: true } type, libraryID, downloadedJSON, { stopOnError: true }
); );
obj = objectsClass.getByLibraryAndKey(libraryID, key); obj = objectsClass.getByLibraryAndKey(libraryID, key);

View file

@ -105,14 +105,31 @@ describe("Zotero.Sync.Runner", function () {
// //
// Helper functions // Helper functions
// //
var setup = Zotero.Promise.coroutine(function* (options = {}) { function setResponse(response) {
yield Zotero.DB.queryAsync("DELETE FROM settings WHERE setting='account'"); setHTTPResponse(server, baseURL, response, responses);
yield Zotero.Users.init(); }
//
// Tests
//
beforeEach(function* () {
yield resetDB({
thisArg: this,
skipBundledFiles: true
});
var runner = new Zotero.Sync.Runner_Module({ baseURL, apiKey }); userLibraryID = Zotero.Libraries.userLibraryID;
publicationsLibraryID = Zotero.Libraries.publicationsLibraryID;
Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
server = sinon.fakeServer.create();
server.autoRespond = true;
runner = new Zotero.Sync.Runner_Module({ baseURL, apiKey });
Components.utils.import("resource://zotero/concurrentCaller.js"); Components.utils.import("resource://zotero/concurrentCaller.js");
var caller = new ConcurrentCaller(1); caller = new ConcurrentCaller(1);
caller.setLogger(msg => Zotero.debug(msg)); caller.setLogger(msg => Zotero.debug(msg));
caller.stopOnError = true; caller.stopOnError = true;
caller.onError = function (e) { caller.onError = function (e) {
@ -126,29 +143,6 @@ describe("Zotero.Sync.Runner", function () {
} }
}; };
return { runner, caller };
})
function setResponse(response) {
setHTTPResponse(server, baseURL, response, responses);
}
//
// Tests
//
before(function* () {
userLibraryID = Zotero.Libraries.userLibraryID;
publicationsLibraryID = Zotero.Libraries.publicationsLibraryID;
})
beforeEach(function* () {
Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
server = sinon.fakeServer.create();
server.autoRespond = true;
({ runner, caller } = yield setup());
yield Zotero.Users.setCurrentUserID(1); yield Zotero.Users.setCurrentUserID(1);
yield Zotero.Users.setCurrentUsername("A"); yield Zotero.Users.setCurrentUsername("A");
}) })
@ -404,27 +398,7 @@ describe("Zotero.Sync.Runner", function () {
}) })
describe("#sync()", function () { describe("#sync()", function () {
var spy;
before(function* () {
yield resetDB({
thisArg: this,
skipBundledFiles: true
});
yield Zotero.Libraries.init();
})
afterEach(function () {
if (spy) {
spy.restore();
}
});
it("should perform a sync across all libraries and update library versions", function* () { it("should perform a sync across all libraries and update library versions", function* () {
yield Zotero.Users.setCurrentUserID(1);
yield Zotero.Users.setCurrentUsername("A");
setResponse('keyInfo.fullAccess'); setResponse('keyInfo.fullAccess');
setResponse('userGroups.groupVersions'); setResponse('userGroups.groupVersions');
setResponse('groups.ownerGroup'); setResponse('groups.ownerGroup');
@ -675,11 +649,11 @@ describe("Zotero.Sync.Runner", function () {
// Check local library versions // Check local library versions
assert.equal( assert.equal(
Zotero.Libraries.getVersion(Zotero.Libraries.userLibraryID), Zotero.Libraries.getVersion(userLibraryID),
5 5
); );
assert.equal( assert.equal(
Zotero.Libraries.getVersion(Zotero.Libraries.publicationsLibraryID), Zotero.Libraries.getVersion(publicationsLibraryID),
10 10
); );
assert.equal( assert.equal(
@ -699,8 +673,9 @@ describe("Zotero.Sync.Runner", function () {
it("should show the sync error icon on error", function* () { it("should show the sync error icon on error", function* () {
yield Zotero.Users.setCurrentUserID(1); let pubLib = Zotero.Libraries.get(publicationsLibraryID);
yield Zotero.Users.setCurrentUsername("A"); pubLib.libraryVersion = 5;
yield pubLib.save();
setResponse('keyInfo.fullAccess'); setResponse('keyInfo.fullAccess');
setResponse('userGroups.groupVersionsEmpty'); setResponse('userGroups.groupVersionsEmpty');
@ -716,6 +691,25 @@ describe("Zotero.Sync.Runner", function () {
INVALID: true // TODO: Find a cleaner error INVALID: true // TODO: Find a cleaner error
} }
}); });
// No publications changes
setResponse({
method: "GET",
url: "users/1/publications/settings?since=5",
status: 304,
headers: {
"Last-Modified-Version": 5
},
json: {}
});
setResponse({
method: "GET",
url: "users/1/publications/fulltext",
status: 200,
headers: {
"Last-Modified-Version": 5
},
json: {}
});
spy = sinon.spy(runner, "updateIcons"); spy = sinon.spy(runner, "updateIcons");
yield runner.sync(); yield runner.sync();