API syncing megacommit

There's a lot more to do, and this isn't ready for actual usage, but the
basic functionality is mostly in place and has decent test coverage. It
can successfully upgrade a library last used with classic syncing and
pull down changes via the API. Uploading mostly works but is currently
disabled for safety until it has better test coverage.

Downloaded JSON is first saved to a cache table, which is then used to
populate other tables and later for generating PATCH requests and
automatically resolving conflicts (since it shows what was changed
locally and what was changed remotely). Objects with unmet dependencies
or unknown fields are skipped for now but don't block the rest of the
sync.

Some of the bigger remaining to-dos:

- Tests for uploading
- Re-do the preferences to get an API key
- File sync integration
- Full-text syncing integration
- Manual conflict resolution (though this already includes much smarter
  conflict handling that automatically resolves many conflicts)
This commit is contained in:
Dan Stillman 2015-07-20 17:27:55 -04:00
parent b4a8083f2f
commit 984789d304
27 changed files with 6058 additions and 1149 deletions

View file

@ -373,7 +373,7 @@ Zotero_Preferences.Sync = {
available to the custom callbacks
onSuccess: function () {
Zotero.Sync.Runner.setSyncIcon();
Zotero.Sync.Runner.updateIcons();
ps.alert(
null,
"Restore Completed",

View file

@ -26,14 +26,30 @@
Zotero.DataObjectUtilities = {
/**
* Get an array of all DataObject types
* Get all DataObject types
*
* @return {String[]}
* @return {String[]} - An array of DataObject types
*/
getTypes: function () {
return ['collection', 'search', 'item'];
},
/**
* Get DataObject types that are valid for a given library
*
* @param {Integer} libraryID
* @return {String[]} - An array of DataObject types
*/
getTypesForLibrary: function (libraryID) {
switch (Zotero.Libraries.getType(libraryID)) {
case 'publications':
return ['item'];
default:
return this.getTypes();
}
},
"checkLibraryID": function (libraryID) {
if (!libraryID) {
throw new Error("libraryID not provided");

View file

@ -327,36 +327,6 @@ Zotero.DataObjects.prototype.getNewer = Zotero.Promise.method(function (libraryI
});
/**
* @param {Integer} libraryID
* @return {Promise} A promise for an array of object ids
*/
Zotero.DataObjects.prototype.getUnsynced = function (libraryID) {
var sql = "SELECT " + this._ZDO_id + " FROM " + this._ZDO_table
+ " WHERE libraryID=? AND synced=0";
return Zotero.DB.columnQueryAsync(sql, [libraryID]);
}
/**
* Get JSON from the sync cache that hasn't yet been written to the
* main object tables
*
* @param {Integer} libraryID
* @return {Promise} A promise for an array of JSON objects
*/
Zotero.DataObjects.prototype.getUnwrittenData = function (libraryID) {
var sql = "SELECT data FROM syncCache SC "
+ "LEFT JOIN " + this._ZDO_table + " "
+ "USING (libraryID) "
+ "WHERE SC.libraryID=? AND "
+ "syncObjectTypeID IN (SELECT syncObjectTypeID FROM "
+ "syncObjectTypes WHERE name='" + this._ZDO_object + "') "
+ "AND IFNULL(O.version, 0) < SC.version";
return Zotero.DB.columnQueryAsync(sql, [libraryID]);
}
/**
* Reload loaded data of loaded objects
*

View file

@ -188,13 +188,9 @@ Zotero.Group.prototype.hasItem = function (item) {
Zotero.Group.prototype.save = Zotero.Promise.coroutine(function* () {
if (!this.id) {
throw new Error("Group id not set");
}
if (!this.name) {
throw new Error("Group name not set");
}
if (!this.id) throw new Error("Group id not set");
if (!this.name) throw new Error("Group name not set");
if (!this.version) throw new Error("Group version not set");
if (!this._changed) {
Zotero.debug("Group " + this.id + " has not changed");

View file

@ -155,7 +155,7 @@ Zotero.Libraries = new function () {
/**
* @param {Integer} libraryID
* @param {Integer} version
* @param {Integer} version - Library version, or -1 to indicate that a full sync is required
* @return {Promise}
*/
this.setVersion = Zotero.Promise.coroutine(function* (libraryID, version) {
@ -173,14 +173,14 @@ Zotero.Libraries = new function () {
/**
* @param {Integer} libraryID
* @param {Date} lastSyncTime
* @return {Promise}
*/
this.setLastSyncTime = function (libraryID, lastSyncTime) {
var lastSyncTime = Math.round(lastSyncTime.getTime() / 1000);
_libraryData[libraryID].lastSyncTime = lastSyncTime;
this.updateLastSyncTime = function (libraryID) {
var d = new Date();
_libraryData[libraryID].lastSyncTime = d;
return Zotero.DB.queryAsync(
"UPDATE libraries SET lastsync=? WHERE libraryID=?", [lastSyncTime, libraryID]
"UPDATE libraries SET lastsync=? WHERE libraryID=?",
[Math.round(d.getTime() / 1000), libraryID]
);
};

View file

@ -25,7 +25,6 @@
Zotero.Error = function (message, error, data) {
this.name = "Zotero Error";
this.message = message;
this.data = data;
if (parseInt(error) == error) {
@ -35,8 +34,8 @@ Zotero.Error = function (message, error, data) {
this.error = Zotero.Error["ERROR_" + error] ? Zotero.Error["ERROR_" + error] : 0;
}
}
Zotero.Error.prototype = new Error;
Zotero.Error.prototype = Object.create(Error.prototype);
Zotero.Error.prototype.name = "Zotero Error";
Zotero.Error.ERROR_UNKNOWN = 0;
Zotero.Error.ERROR_MISSING_OBJECT = 1;
@ -51,10 +50,6 @@ Zotero.Error.ERROR_USER_NOT_AVAILABLE = 9;
//Zotero.Error.ERROR_SYNC_EMPTY_RESPONSE_FROM_SERVER = 6;
//Zotero.Error.ERROR_SYNC_INVALID_RESPONSE_FROM_SERVER = 7;
Zotero.Error.prototype.toString = function () {
return this.message;
}
/**
* Namespace for runtime exceptions
* @namespace

View file

@ -863,6 +863,8 @@ Zotero.Fulltext = new function(){
* @return {String} PHP-formatted POST data for items not yet downloaded
*/
this.getUndownloadedPostData = Zotero.Promise.coroutine(function* () {
// TODO: Redo for API syncing
// On upgrade, get all content
var sql = "SELECT value FROM settings WHERE setting='fulltext' AND key='downloadAll'";
if (yield Zotero.DB.valueQueryAsync(sql)) {

View file

@ -33,6 +33,9 @@ Zotero.HTTP = new function() {
}
};
this.UnexpectedStatusException.prototype = Object.create(Error.prototype);
this.UnexpectedStatusException.prototype.is4xx = function () {
return this.status >= 400 && this.status < 500;
}
this.UnexpectedStatusException.prototype.toString = function() {
return this.message;
};

View file

@ -2213,6 +2213,9 @@ Zotero.Schema = new function(){
yield Zotero.DB.queryAsync("INSERT OR IGNORE INTO syncDeleteLog SELECT * FROM syncDeleteLogOld");
yield Zotero.DB.queryAsync("DROP INDEX IF EXISTS syncDeleteLog_timestamp");
yield Zotero.DB.queryAsync("CREATE INDEX syncDeleteLog_synced ON syncDeleteLog(synced)");
// TODO: Something special for tag deletions?
//yield Zotero.DB.queryAsync("DELETE FROM syncDeleteLog WHERE syncObjectTypeID IN (2, 5, 6)");
//yield Zotero.DB.queryAsync("DELETE FROM syncObjectTypes WHERE syncObjectTypeID IN (2, 5, 6)");
yield Zotero.DB.queryAsync("ALTER TABLE storageDeleteLog RENAME TO storageDeleteLogOld");
yield Zotero.DB.queryAsync("CREATE TABLE storageDeleteLog (\n libraryID INT NOT NULL,\n key TEXT NOT NULL,\n synced INT NOT NULL DEFAULT 0,\n PRIMARY KEY (libraryID, key),\n FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE\n)");

View file

@ -325,7 +325,7 @@ Zotero.Sync.Storage.ZFS = (function () {
dialogButtonCallback: buttonCallback
}
);
e.errorMode = 'warning';
e.errorType = 'warning';
Zotero.debug(e, 2);
Components.utils.reportError(e);
throw e;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,445 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2014 Center for History and New Media
George Mason University, Fairfax, Virginia, USA
http://zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
if (!Zotero.Sync) {
Zotero.Sync = {};
}
Zotero.Sync.APIClient = function (options) {
this.baseURL = options.baseURL;
this.apiKey = options.apiKey;
this.concurrentCaller = options.concurrentCaller;
if (options.apiVersion == undefined) {
throw new Error("options.apiVersion not set");
}
this.apiVersion = options.apiVersion;
}
Zotero.Sync.APIClient.prototype = {
MAX_OBJECTS_PER_REQUEST: 100,
getKeyInfo: Zotero.Promise.coroutine(function* () {
var uri = this.baseURL + "keys/" + this.apiKey;
var xmlhttp = yield this._makeRequest("GET", uri, { successCodes: [200, 404] });
if (xmlhttp.status == 404) {
return false;
}
var json = this._parseJSON(xmlhttp.responseText);
delete json.key;
return json;
}),
/**
* Get group metadata versions
*
* Note: This is the version for group metadata, not library data.
*/
getGroupVersions: Zotero.Promise.coroutine(function* (userID) {
if (!userID) throw new Error("User ID not provided");
var uri = this.baseURL + "users/" + userID + "/groups?format=versions";
var xmlhttp = yield this._makeRequest("GET", uri);
return this._parseJSON(xmlhttp.responseText);
}),
/**
* @param {Integer} groupID
* @return {Object|false} - Group metadata response, or false if group not found
*/
getGroupInfo: Zotero.Promise.coroutine(function* (groupID) {
if (!groupID) throw new Error("Group ID not provided");
var uri = this.baseURL + "groups/" + groupID;
var xmlhttp = yield this._makeRequest("GET", uri, { successCodes: [200, 404] });
if (xmlhttp.status == 404) {
return false;
}
return this._parseJSON(xmlhttp.responseText);
}),
getSettings: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, since) {
var params = {
target: "settings",
libraryType: libraryType,
libraryTypeID: libraryTypeID
};
if (since) {
params.since = since;
}
var uri = this._buildRequestURI(params);
var options = {
successCodes: [200, 304]
};
if (since) {
options.headers = {
"If-Modified-Since-Version": since
};
}
var xmlhttp = yield this._makeRequest("GET", uri, options);
if (xmlhttp.status == 304) {
return false;
}
var libraryVersion = xmlhttp.getResponseHeader('Last-Modified-Version');
if (!libraryVersion) {
throw new Error("Last-Modified-Version not provided");
}
return {
libraryVersion: libraryVersion,
settings: this._parseJSON(xmlhttp.responseText)
};
}),
getDeleted: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, since) {
var params = {
target: "deleted",
libraryType: libraryType,
libraryTypeID: libraryTypeID,
since: since || 0
};
var uri = this._buildRequestURI(params);
var xmlhttp = yield this._makeRequest("GET", uri);
var libraryVersion = xmlhttp.getResponseHeader('Last-Modified-Version');
if (!libraryVersion) {
throw new Error("Last-Modified-Version not provided");
}
return {
libraryVersion: libraryVersion,
deleted: this._parseJSON(xmlhttp.responseText)
};
}),
/**
* Return a promise for a JS object with object keys as keys and version
* numbers as values. By default, returns all objects in the library.
* Additional parameters (such as 'since', 'sincetime', 'libraryVersion')
* can be passed in 'params'.
*
* @param {String} libraryType 'user' or 'group'
* @param {Integer} libraryTypeID userID or groupID
* @param {String} objectType 'item', 'collection', 'search'
* @param {Object} queryParams Query parameters (see _buildRequestURI())
* @return {Promise<Object>|FALSE} Object with 'libraryVersion' and 'results'
*/
getVersions: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, objectType, queryParams, libraryVersion) {
var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
var params = {
target: objectTypePlural,
libraryType: libraryType,
libraryTypeID: libraryTypeID,
format: 'versions'
};
if (queryParams) {
for (let i in queryParams) {
params[i] = queryParams[i];
}
}
if (objectType == 'item') {
params.includeTrashed = 1;
}
// TODO: Use pagination
var uri = this._buildRequestURI(params);
var options = {
successCodes: [200, 304]
};
if (libraryVersion) {
options.headers = {
"If-Modified-Since-Version": libraryVersion
};
}
var xmlhttp = yield this._makeRequest("GET", uri, options);
if (xmlhttp.status == 304) {
return false;
}
var libraryVersion = xmlhttp.getResponseHeader('Last-Modified-Version');
if (!libraryVersion) {
throw new Error("Last-Modified-Version not provided");
}
return {
libraryVersion: libraryVersion,
versions: this._parseJSON(xmlhttp.responseText)
};
}),
/**
* Retrieve JSON from API for requested objects
*
* If necessary, multiple API requests will be made.
*
* @param {String} libraryType - 'user', 'group'
* @param {Integer} libraryTypeID - userID or groupID
* @param {String} objectType - 'collection', 'item', 'search'
* @param {String[]} objectKeys - Keys of objects to request
* @return {Array<Promise<Object[]|Error[]>>} - An array of promises for batches of JSON objects
* or Errors for failures
*/
downloadObjects: function (libraryType, libraryTypeID, objectType, objectKeys) {
if (!objectKeys.length) {
return [];
}
// If more than max per request, call in batches
if (objectKeys.length > this.MAX_OBJECTS_PER_REQUEST) {
let allKeys = objectKeys.concat();
let promises = [];
while (true) {
let requestKeys = allKeys.splice(0, this.MAX_OBJECTS_PER_REQUEST)
if (!requestKeys.length) {
break;
}
let promise = this.downloadObjects(
libraryType,
libraryTypeID,
objectType,
requestKeys
)[0];
if (promise) {
promises.push(promise);
}
}
return promises;
}
// Otherwise make request
var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
Zotero.debug("Retrieving " + objectKeys.length + " "
+ (objectKeys.length == 1 ? objectType : objectTypePlural));
var params = {
target: objectTypePlural,
libraryType: libraryType,
libraryTypeID: libraryTypeID,
format: 'json'
};
params[objectType + "Key"] = objectKeys.join(",");
if (objectType == 'item') {
params.includeTrashed = 1;
}
var uri = this._buildRequestURI(params);
return [
this._makeRequest("GET", uri)
.then(function (xmlhttp) {
return this._parseJSON(xmlhttp.responseText)
}.bind(this))
// Return the error without failing the whole chain
.catch(function (e) {
if (e instanceof Zotero.HTTP.UnexpectedStatusException && e.is4xx()) {
Zotero.logError(e);
throw e;
}
Zotero.logError(e);
return e;
})
];
},
uploadObjects: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, objectType, method, version, objects) {
throw new Error("Uploading disabled");
if (method != 'POST' && method != 'PATCH') {
throw new Error("Invalid method '" + method + "'");
}
var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
Zotero.debug("Uploading " + objects.length + " "
+ (objects.length == 1 ? objectType : objectTypePlural));
Zotero.debug("Sending If-Unmodified-Since-Version: " + version);
var json = JSON.stringify(objects);
var params = {
target: objectTypePlural,
libraryType: libraryType,
libraryTypeID: libraryTypeID
};
var uri = this._buildRequestURI(params);
var xmlhttp = yield this._makeRequest(method, uri, {
headers: {
"If-Unmodified-Since-Version": version
},
body: json,
successCodes: [200, 412]
});
// Avoid logging error from Zotero.HTTP.request() in ConcurrentCaller
if (xmlhttp.status == 412) {
Zotero.debug("Server returned 412: " + xmlhttp.responseText, 2);
throw new Zotero.HTTP.UnexpectedStatusException(xmlhttp);
}
var libraryVersion = xmlhttp.getResponseHeader('Last-Modified-Version');
if (!libraryVersion) {
throw new Error("Last-Modified-Version not provided");
}
return {
libraryVersion: libraryVersion,
results: this._parseJSON(xmlhttp.responseText)
};
}),
_buildRequestURI: function (params) {
var uri = this.baseURL;
switch (params.libraryType) {
case 'publications':
uri += 'users/' + params.libraryTypeID + '/' + params.libraryType;
break;
default:
uri += params.libraryType + 's/' + params.libraryTypeID;
break;
}
uri += "/" + params.target;
if (params.objectKey) {
uri += "/" + params.objectKey;
}
var queryString = '?';
var queryParamsArray = [];
var queryParamOptions = [
'session',
'format',
'include',
'includeTrashed',
'itemType',
'itemKey',
'collectionKey',
'searchKey',
'linkMode',
'start',
'limit',
'sort',
'direction',
'since',
'sincetime'
];
queryParams = {};
for (let option in params) {
let value = params[option];
if (value !== undefined && value !== '' && queryParamOptions.indexOf(option) != -1) {
queryParams[option] = value;
}
}
for (let index in queryParams) {
let value = queryParams[index];
if (Array.isArray(value)) {
value.forEach(function(v, i) {
queryParamsArray.push(encodeURIComponent(index) + '=' + encodeURIComponent(v));
});
}
else {
queryParamsArray.push(encodeURIComponent(index) + '=' + encodeURIComponent(value));
}
}
return uri + (queryParamsArray.length ? "?" + queryParamsArray.join('&') : "");
},
_makeRequest: function (method, uri, options) {
if (!options) {
options = {};
}
if (!options.headers) {
options.headers = {};
}
options.headers["Zotero-API-Version"] = this.apiVersion;
options.dontCache = true;
options.foreground = !options.background;
options.responseType = options.responseType || 'text';
if (this.apiKey) {
options.headers.Authorization = "Bearer " + this.apiKey;
}
var self = this;
return this.concurrentCaller.fcall(function () {
return Zotero.HTTP.request(method, uri, options)
.catch(function (e) {
if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
self._checkResponse(e.xmlhttp);
}
throw e;
});
});
},
_parseJSON: function (json) {
try {
json = JSON.parse(json);
}
catch (e) {
Zotero.debug(e, 1);
Zotero.debug(json, 1);
throw e;
}
return json;
},
_checkResponse: function (xmlhttp) {
this._checkBackoff(xmlhttp);
this._checkAuth(xmlhttp);
},
_checkAuth: function (xmlhttp) {
if (xmlhttp.status == 403) {
var e = new Zotero.Error(Zotero.getString('sync.error.invalidLogin'), "INVALID_SYNC_LOGIN");
e.fatal = true;
throw e;
}
},
_checkBackoff: function (xmlhttp) {
var backoff = xmlhttp.getResponseHeader("Backoff");
if (backoff) {
// Sanity check -- don't wait longer than an hour
if (backoff > 3600) {
// TODO: Update status?
this.concurrentCaller.pause(backoff * 1000);
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,216 @@
Zotero.Sync.EventListeners = {
/**
* Start all listeners
*/
init: function () {
for (let i in this) {
if (i.indexOf('Listener') != -1) {
if (this[i].init) {
this[i].init();
}
}
}
}
};
/**
* Notifier observer to add deleted objects to syncDeleteLog/storageDeleteLog
* plus related methods
*/
Zotero.Sync.EventListeners.ChangeListener = new function () {
this.init = function () {
// Initialize delete log listener
// TODO: Support clearing of full-text for an item?
Zotero.Notifier.registerObserver(
this, ['collection', 'item', 'search', 'setting'], 'deleteLog'
);
}
this.notify = Zotero.Promise.method(function (event, type, ids, extraData) {
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(type);
if (!syncObjectTypeID) {
return;
}
if (event != 'delete') {
return;
}
var syncSQL = "REPLACE INTO syncDeleteLog VALUES (?, ?, ?, 0)";
if (type == 'item' && Zotero.Sync.Storage.WebDAV.includeUserFiles) {
var storageSQL = "REPLACE INTO storageDeleteLog VALUES (?, ?, 0)";
}
return Zotero.DB.executeTransaction(function* () {
for (let i = 0; i < ids.length; i++) {
let id = ids[i];
if (extraData[id] && extraData[id].skipDeleteLog) {
continue;
}
var libraryID, key;
if (type == 'setting') {
[libraryID, key] = ids[i].split("/");
}
else {
let d = extraData[ids[i]];
libraryID = d.libraryID;
key = d.key;
}
if (!key) {
throw new Error("Key not provided in notifier object");
}
yield Zotero.DB.queryAsync(
syncSQL,
[
syncObjectTypeID,
libraryID,
key
]
);
if (storageSQL && oldItem.itemType == 'attachment' &&
[
Zotero.Attachments.LINK_MODE_IMPORTED_FILE,
Zotero.Attachments.LINK_MODE_IMPORTED_URL
].indexOf(oldItem.linkMode) != -1) {
yield Zotero.DB.queryAsync(
storageSQL,
[
libraryID,
key
]
);
}
}
});
});
}
Zotero.Sync.EventListeners.AutoSyncListener = {
init: function () {
// Initialize save observer
Zotero.Notifier.registerObserver(this);
},
notify: function (event, type, ids, extraData) {
// TODO: skip others
if (event == 'refresh' || event == 'redraw') {
return;
}
if (Zotero.Prefs.get('sync.autoSync') && Zotero.Sync.Server.enabled) {
Zotero.Sync.Runner.setSyncTimeout(false, false, true);
}
}
}
Zotero.Sync.EventListeners.IdleListener = {
_idleTimeout: 3600,
_backTimeout: 900,
init: function () {
// DEBUG: Allow override for testing
var idleTimeout = Zotero.Prefs.get("sync.autoSync.idleTimeout");
if (idleTimeout) {
this._idleTimeout = idleTimeout;
}
var backTimeout = Zotero.Prefs.get("sync.autoSync.backTimeout");
if (backTimeout) {
this._backTimeout = backTimeout;
}
if (Zotero.Prefs.get("sync.autoSync")) {
this.register();
}
},
register: function () {
Zotero.debug("Initializing sync idle observer");
var idleService = Components.classes["@mozilla.org/widget/idleservice;1"]
.getService(Components.interfaces.nsIIdleService);
idleService.addIdleObserver(this, this._idleTimeout);
idleService.addIdleObserver(this._backObserver, this._backTimeout);
},
observe: function (subject, topic, data) {
if (topic != 'idle') {
return;
}
if (!Zotero.Sync.Server.enabled
|| Zotero.Sync.Server.syncInProgress
|| Zotero.Sync.Storage.syncInProgress) {
return;
}
// TODO: move to Runner.sync()?
if (Zotero.locked) {
Zotero.debug('Zotero is locked -- skipping idle sync', 4);
return;
}
if (Zotero.Sync.Server.manualSyncRequired) {
Zotero.debug('Manual sync required -- skipping idle sync', 4);
return;
}
Zotero.debug("Beginning idle sync");
Zotero.Sync.Runner.sync({
background: true
});
Zotero.Sync.Runner.setSyncTimeout(this._idleTimeout, true, true);
},
_backObserver: {
observe: function (subject, topic, data) {
if (topic != 'back') {
return;
}
Zotero.Sync.Runner.clearSyncTimeout();
if (!Zotero.Sync.Server.enabled
|| Zotero.Sync.Server.syncInProgress
|| Zotero.Sync.Storage.syncInProgress) {
return;
}
Zotero.debug("Beginning return-from-idle sync");
Zotero.Sync.Runner.sync({
background: true
});
}
},
unregister: function () {
Zotero.debug("Stopping sync idle observer");
var idleService = Components.classes["@mozilla.org/widget/idleservice;1"]
.getService(Components.interfaces.nsIIdleService);
idleService.removeIdleObserver(this, this._idleTimeout);
idleService.removeIdleObserver(this._backObserver, this._backTimeout);
}
}
Zotero.Sync.EventListeners.progressListener = {
onStart: function () {
},
onProgress: function (current, max) {
},
onStop: function () {
}
};

View file

@ -0,0 +1,706 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2014 Center for History and New Media
George Mason University, Fairfax, Virginia, USA
http://zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
if (!Zotero.Sync.Data) {
Zotero.Sync.Data = {};
}
Zotero.Sync.Data.Local = {
_lastSyncTime: null,
_lastClassicSyncTime: null,
init: Zotero.Promise.coroutine(function* () {
yield this._loadLastSyncTime();
if (!_lastSyncTime) {
yield this._loadLastClassicSyncTime();
}
}),
getLastSyncTime: function () {
if (_lastSyncTime === null) {
throw new Error("Last sync time not yet loaded");
}
return _lastSyncTime;
},
/**
* @return {Promise}
*/
updateLastSyncTime: function () {
_lastSyncTime = new Date();
return Zotero.DB.queryAsync(
"REPLACE INTO version (schema, version) VALUES ('lastsync', ?)",
Math.round(_lastSyncTime.getTime() / 1000)
);
},
_loadLastSyncTime: Zotero.Promise.coroutine(function* () {
var sql = "SELECT version FROM version WHERE schema='lastsync'";
var lastsync = yield Zotero.DB.valueQueryAsync(sql);
_lastSyncTime = (lastsync ? new Date(lastsync * 1000) : false);
}),
/**
* @param {Integer} libraryID
* @return {Promise<String[]>} - A promise for an array of object keys
*/
getSynced: function (libraryID, objectType) {
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
var sql = "SELECT key FROM " + objectsClass.table + " WHERE libraryID=? AND synced=1";
return Zotero.DB.columnQueryAsync(sql, [libraryID]);
},
/**
* @param {Integer} libraryID
* @return {Promise<Integer[]>} - A promise for an array of object ids
*/
getUnsynced: Zotero.Promise.coroutine(function* (libraryID, objectType) {
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
var sql = "SELECT " + objectsClass.idColumn + " FROM " + objectsClass.table
+ " WHERE libraryID=? AND synced=0";
// RETRIEVE PARENT DOWN? EVEN POSSIBLE?
// items via parent
// collections via getDescendents?
return Zotero.DB.columnQueryAsync(sql, [libraryID]);
}),
//
// Cache management
//
/**
* 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
*/
getLatestCacheObjectVersions: Zotero.Promise.coroutine(function* (libraryID, objectType) {
var sql = "SELECT key, version FROM syncCache WHERE libraryID=? AND "
+ "syncObjectTypeID IN (SELECT syncObjectTypeID FROM "
+ "syncObjectTypes WHERE name=?) ORDER BY version";
var rows = yield Zotero.DB.queryAsync(sql, [libraryID, objectType]);
var versions = {};
for (let i = 0; i < rows.length; i++) {
let row = rows[i];
versions[row.key] = row.version;
}
return versions;
}),
/**
* @return {Promise<Integer[]>} - A promise for an array of object versions
*/
getCacheObjectVersions: function (objectType, libraryID, key) {
var sql = "SELECT version FROM syncCache WHERE libraryID=? AND key=? "
+ "AND syncObjectTypeID IN (SELECT syncObjectTypeID FROM "
+ "syncObjectTypes WHERE name=?) ORDER BY version";
return Zotero.DB.columnQueryAsync(sql, [libraryID, key, objectType]);
},
/**
* @return {Promise<Number>} - A promise for an object version
*/
getLatestCacheObjectVersion: function (objectType, libraryID, key) {
var sql = "SELECT version FROM syncCache WHERE libraryID=? AND key=? "
+ "AND syncObjectTypeID IN (SELECT syncObjectTypeID FROM "
+ "syncObjectTypes WHERE name=?) ORDER BY VERSION DESC LIMIT 1";
return Zotero.DB.valueQueryAsync(sql, [libraryID, key, objectType]);
},
/**
* @return {Promise}
*/
getCacheObject: Zotero.Promise.coroutine(function* (objectType, libraryID, key, version) {
var sql = "SELECT data FROM syncCache WHERE libraryID=? AND key=? AND version=? "
+ "AND syncObjectTypeID IN (SELECT syncObjectTypeID FROM "
+ "syncObjectTypes WHERE name=?)";
var data = yield Zotero.DB.valueQueryAsync(sql, [libraryID, key, version, objectType]);
if (data) {
return JSON.parse(data);
}
return false;
}),
saveCacheObjects: Zotero.Promise.coroutine(function* (objectType, libraryID, jsonArray) {
if (!Array.isArray(jsonArray)) {
throw new Error("'json' must be an array");
}
Zotero.debug("Saving to sync cache:");
Zotero.debug(jsonArray);
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
var sql = "INSERT OR REPLACE INTO syncCache "
+ "(libraryID, key, syncObjectTypeID, version, data) VALUES ";
var chunkSize = Math.floor(Zotero.DB.MAX_BOUND_PARAMETERS / 5);
return Zotero.DB.executeTransaction(function* () {
return Zotero.Utilities.Internal.forEachChunkAsync(
jsonArray,
chunkSize,
Zotero.Promise.coroutine(function* (chunk) {
var params = [];
for (let i = 0; i < chunk.length; i++) {
let o = chunk[i];
if (o.key === undefined) {
throw new Error("Missing 'key' property in JSON");
}
if (o.version === undefined) {
throw new Error("Missing 'version' property in JSON");
}
params.push(libraryID, o.key, syncObjectTypeID, o.version, JSON.stringify(o));
}
return Zotero.DB.queryAsync(
sql + chunk.map(() => "(?, ?, ?, ?, ?)").join(", "), params
);
})
);
}.bind(this));
}),
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) {
options = options || {};
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
var ObjectType = Zotero.Utilities.capitalize(objectType);
var libraryName = Zotero.Libraries.getName(libraryID);
Zotero.debug("Processing " + objectTypePlural + " in sync cache for " + libraryName);
var numSaved = 0;
var numSkipped = 0;
var data = yield this._getUnwrittenData(libraryID, objectType);
if (!data.length) {
Zotero.debug("No unwritten " + objectTypePlural + " in sync cache");
return;
}
Zotero.debug("Processing " + data.length + " "
+ (data.length == 1 ? objectType : objectTypePlural)
+ " in sync cache");
if (options.setStatus) {
options.setStatus("Processing " + objectTypePlural); // TODO: localize
}
// Sort parent objects first, to avoid retries due to unmet dependencies
if (objectType == 'item' || objectType == 'collection') {
let parentProp = 'parent' + objectType[0].toUpperCase() + objectType.substr(1);
data.sort(function (a, b) {
if (a[parentProp] && !b[parentProp]) return 1;
if (b[parentProp] && !a[parentProp]) return -1;
return 0;
});
}
var concurrentObjects = 5;
yield Zotero.Utilities.Internal.forEachChunkAsync(
data,
concurrentObjects,
function (chunk) {
return Zotero.DB.executeTransaction(function* () {
for (let i = 0; i < chunk.length; i++) {
let json = chunk[i];
let jsonData = json.data;
let isNewObject;
let objectKey = json.key;
Zotero.debug(json);
if (!jsonData) {
Zotero.logError(new Error("Missing 'data' object in JSON in sync cache for "
+ objectType + " " + libraryID + "/" + objectKey));
continue;
}
// 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) {
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 obj = yield objectsClass.getByLibraryAndKeyAsync(
libraryID, objectKey, { noCache: true }
);
if (obj) {
Zotero.debug("Matching local " + objectType + " exists", 4);
isNewObject = false;
// Local object has not been modified since last sync
if (obj.synced) {
// Overwrite local below
}
else {
Zotero.debug("Local " + objectType + " " + obj.libraryKey
+ " has been modified since last sync", 4);
let cachedJSON = yield this.getCacheObject(
objectType, obj.libraryID, obj.key, obj.version
);
Zotero.debug("GOT CACHED");
Zotero.debug(cachedJSON);
let jsonDataLocal = yield obj.toJSON();
let result = this._reconcileChanges(
objectType,
cachedJSON.data,
jsonDataLocal,
jsonData,
['dateAdded', 'dateModified']
);
// If no changes, update local version and keep as unsynced
if (!result.changes.length && !result.conflicts.length) {
Zotero.debug("No remote changes to apply to local " + objectType
+ " " + obj.libraryKey);
yield obj.updateVersion(json.version);
continue;
}
// Ignore conflicts from Quick Start Guide, and just use remote version
/*if (objectType == 'item'
&& jsonDataLocal.key == "ABCD2345"
&& jsonDataLocal.url.indexOf('quick_start_guide') != -1
&& jsonData.url.indexOf('quick_start_guide') != -1) {
Zotero.debug("Ignoring conflict for item '" + jsonData.title + "' "
+ "-- using remote version");
let saved = yield this._saveObjectFromJSON(obj, jsonData, options);
if (saved) numSaved++;
continue;
}*/
// If no conflicts, apply remote changes automatically
if (!result.conflicts.length) {
Zotero.DataObjectUtilities.applyChanges(
jsonData, result.changes
);
let saved = yield this._saveObjectFromJSON(obj, jsonData, options);
if (saved) numSaved++;
continue;
}
Zotero.debug('======DIFF========');
Zotero.debug(cachedJSON);
Zotero.debug(jsonDataLocal);
Zotero.debug(jsonData);
Zotero.debug(result);
throw new Error("Conflict");
// TODO
// reconcile changes automatically if we can
// if we can't:
// if it's a search or collection, use most recent version
// if it's an item,
}
let saved = yield this._saveObjectFromJSON(obj, jsonData, options);
if (saved) numSaved++;
}
// Object doesn't exist locally
else {
isNewObject = true;
// Check if object has been deleted locally
if (yield this._objectInDeleteLog(objectType, libraryID, objectKey)) {
switch (objectType) {
case 'item':
throw new Error("Unimplemented");
break;
// Auto-restore some locally deleted objects that have changed remotely
case 'collection':
case 'search':
yield this._removeObjectFromDeleteLog(
objectType,
libraryID,
objectKey
);
throw new Error("Unimplemented");
break;
default:
throw new Error("Unknown object type '" + objectType + "'");
}
}
// Create new object
obj = new Zotero[ObjectType];
obj.libraryID = libraryID;
obj.key = objectKey;
yield obj.loadPrimaryData();
let saved = yield this._saveObjectFromJSON(obj, jsonData, options, {
// Don't cache new items immediately, which skips reloading after save
skipCache: true
});
if (saved) numSaved++;
}
}
}.bind(this));
}.bind(this)
);
// Keep retrying if we skipped any, as long as we're still making progress
if (numSkipped && numSaved != 0) {
Zotero.debug("More " + objectTypePlural + " in cache -- continuing");
yield this.processSyncCacheForObjectType(libraryID, objectType, options);
}
data = yield this._getUnwrittenData(libraryID, objectType);
Zotero.debug("Skipping " + data.length + " "
+ (data.length == 1 ? objectType : objectTypePlural)
+ " in sync cache");
return data;
}),
//
// Classic sync
//
getLastClassicSyncTime: function () {
if (_lastClassicSyncTime === null) {
throw new Error("Last classic sync time not yet loaded");
}
return _lastClassicSyncTime;
},
_loadLastClassicSyncTime: Zotero.Promise.coroutine(function* () {
var sql = "SELECT version FROM version WHERE schema='lastlocalsync'";
var lastsync = yield Zotero.DB.valueQueryAsync(sql);
_lastClassicSyncTime = (lastsync ? new Date(lastsync * 1000) : false);
}),
_saveObjectFromJSON: Zotero.Promise.coroutine(function* (obj, json, options) {
try {
yield obj.fromJSON(json);
obj.version = json.version;
obj.synced = true;
Zotero.debug("SAVING " + json.key + " WITH SYNCED");
Zotero.debug(obj.version);
yield obj.save({
skipDateModifiedUpdate: true,
skipSelect: true,
errorHandler: function (e) {
// Don't log expected errors
if (e.name == 'ZoteroUnknownTypeError'
&& e.name == 'ZoteroUnknownFieldError'
&& e.name == 'ZoteroMissingObjectError') {
return;
}
Zotero.debug(e, 1);
}
});
}
catch (e) {
if (e.name == 'ZoteroUnknownTypeError'
|| e.name == 'ZoteroUnknownFieldError'
|| e.name == 'ZoteroMissingObjectError') {
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();
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;
}),
/**
* Calculate a changeset to apply locally to resolve an object conflict, plus a list of
* conflicts where not possible
*/
_reconcileChanges: function (objectType, originalJSON, currentJSON, newJSON, ignoreFields) {
if (!originalJSON) {
return this._reconcileChangesWithoutCache(objectType, currentJSON, newJSON, ignoreFields);
}
var changeset1 = Zotero.DataObjectUtilities.diff(originalJSON, currentJSON, ignoreFields);
var changeset2 = Zotero.DataObjectUtilities.diff(originalJSON, newJSON, ignoreFields);
var conflicts = [];
for (let i = 0; i < changeset1.length; i++) {
for (let j = 0; j < changeset2.length; j++) {
let c1 = changeset1[i];
let c2 = changeset2[j];
if (c1.field != c2.field) {
continue;
}
// Disregard member additions/deletions for different values
if (c1.op.startsWith('member-') && c2.op.startsWith('member-')) {
switch (c1.field) {
case 'collections':
if (c1.value !== c2.value) {
continue;
}
break;
case 'creators':
if (!Zotero.Creators.equals(c1.value, c2.value)) {
continue;
}
break;
case 'tags':
if (!Zotero.Tags.equals(c1.value, c2.value)) {
// If just a type difference, treat as modify with type 0 if
// not type 0 in changeset1
if (c1.op == 'member-add' && c2.op == 'member-add'
&& c1.value.tag === c2.value.tag) {
changeset1.splice(i--, 1);
changeset2.splice(j--, 1);
if (c1.value.type > 0) {
changeset2.push({
field: "tags",
op: "member-remove",
value: c1.value
});
changeset2.push({
field: "tags",
op: "member-add",
value: c2.value
});
}
}
continue;
}
break;
}
}
// Disregard member additions/deletions for different properties and values
if (c1.op.startsWith('property-member-') && c2.op.startsWith('property-member-')) {
if (c1.value.key !== c2.value.key || c1.value.value !== c2.value.value) {
continue;
}
}
// Changes are equal or in conflict
// Removed on both sides
if (c1.op == 'delete' && c2.op == 'delete') {
changeset2.splice(j--, 1);
continue;
}
// Added or removed members on both sides
if ((c1.op == 'member-add' && c2.op == 'member-add')
|| (c1.op == 'member-remove' && c2.op == 'member-remove')
|| (c1.op == 'property-member-add' && c2.op == 'property-member-add')
|| (c1.op == 'property-member-remove' && c2.op == 'property-member-remove')) {
changeset2.splice(j--, 1);
continue;
}
// If both sides have values, see if they're the same, and if so remove the
// second one
if (c1.op != 'delete' && c2.op != 'delete' && c1.value === c2.value) {
changeset2.splice(j--, 1);
continue;
}
// Automatically apply remote changes for non-items, even if in conflict
if (objectType != 'item') {
continue;
}
// Conflict
changeset2.splice(j--, 1);
conflicts.push([c1, c2]);
}
}
return {
changes: changeset2,
conflicts: conflicts
};
},
/**
* Calculate a changeset to apply locally to resolve an object conflict in absence of a
* cached version. Members and property members (e.g., collections, tags, relations)
* are combined, so any removals will be automatically undone. Field changes result in
* conflicts.
*/
_reconcileChangesWithoutCache: function (objectType, currentJSON, newJSON, ignoreFields) {
var changeset = Zotero.DataObjectUtilities.diff(currentJSON, newJSON, ignoreFields);
var changes = [];
var conflicts = [];
for (let i = 0; i < changeset.length; i++) {
let c = changeset[i];
// Member changes are additive only, so ignore removals
if (c.op.endsWith('-remove')) {
continue;
}
// Record member changes
if (c.op.startsWith('member-') || c.op.startsWith('property-member-')) {
changes.push(c);
continue;
}
// Automatically apply remote changes for non-items, even if in conflict
if (objectType != 'item') {
changes.push(c);
continue;
}
// Field changes are conflicts
conflicts.push(c);
}
return { changes, conflicts };
},
/**
* @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) {
obj.synced = true;
return obj.saveTx({
skipSyncedUpdate: true,
skipDateModifiedUpdate: true,
skipClientDateModifiedUpdate: true,
skipNotifier: true
});
}),
markObjectAsUnsynced: Zotero.Promise.method(function (obj) {
obj.synced = false;
return obj.saveTx({
skipSyncedUpdate: true,
skipDateModifiedUpdate: true,
skipClientDateModifiedUpdate: true,
skipNotifier: true
});
}),
/**
* @return {Promise<Boolean>}
*/
_objectInDeleteLog: Zotero.Promise.coroutine(function* (objectType, libraryID, key) {
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
var sql = "SELECT COUNT(*) FROM syncDeleteLog WHERE libraryID=? AND key=? "
+ "AND syncObjectTypeID=?";
var count = yield Zotero.DB.valueQueryAsync(sql, [libraryID, key, syncObjectTypeID]);
return !!count;
}),
/**
* @return {Promise}
*/
_removeObjectFromDeleteLog: function (objectType, libraryID, key) {
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
var sql = "DELETE FROM syncDeleteLog WHERE libraryID=? AND key=? AND syncObjectTypeID=?";
return Zotero.DB.queryAsync(sql, [libraryID, key, syncObjectTypeID]);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,49 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2014 Center for History and New Media
George Mason University, Fairfax, Virginia, USA
http://zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
if (!Zotero.Sync.Data) {
Zotero.Sync.Data = {};
}
Zotero.Sync.Data.Utilities = {
_syncObjectTypeIDs: {},
init: Zotero.Promise.coroutine(function* () {
// If not found, cache all
var sql = "SELECT name, syncObjectTypeID AS id FROM syncObjectTypes";
var rows = yield Zotero.DB.queryAsync(sql);
for (let i = 0; i < rows.length; i++) {
row = rows[i];
this._syncObjectTypeIDs[row.name] = row.id;
}
}),
getSyncObjectTypeID: function (objectType) {
if (!this._syncObjectTypeIDs[objectType]) {
return false;
}
return this._syncObjectTypeIDs[objectType];
},
};

View file

@ -43,7 +43,24 @@ Zotero.SyncedSettings = (function () {
return JSON.parse(json);
}),
set: Zotero.Promise.coroutine(function* (libraryID, setting, value, version, synced) {
/**
* Used by sync and tests
*
* @return {Object} - Object with 'synced' and 'version' properties
*/
getMetadata: Zotero.Promise.coroutine(function* (libraryID, setting) {
var sql = "SELECT * FROM syncedSettings WHERE setting=? AND libraryID=?";
var row = yield Zotero.DB.rowQueryAsync(sql, [setting, libraryID]);
if (!row) {
return false;
}
return {
synced: !!row.synced,
version: row.version
};
}),
set: Zotero.Promise.coroutine(function* (libraryID, setting, value, version = 0, synced) {
if (typeof value == undefined) {
throw new Error("Value not provided");
}
@ -87,13 +104,18 @@ Zotero.SyncedSettings = (function () {
synced = synced ? 1 : 0;
if (hasCurrentValue) {
var sql = "UPDATE syncedSettings SET value=?, synced=? WHERE setting=? AND libraryID=?";
yield Zotero.DB.queryAsync(sql, [JSON.stringify(value), synced, setting, libraryID]);
var sql = "UPDATE syncedSettings SET value=?, version=?, synced=? "
+ "WHERE setting=? AND libraryID=?";
yield Zotero.DB.queryAsync(
sql, [JSON.stringify(value), version, synced, setting, libraryID]
);
}
else {
var sql = "INSERT INTO syncedSettings "
+ "(setting, libraryID, value, synced) VALUES (?, ?, ?, ?)";
yield Zotero.DB.queryAsync(sql, [setting, libraryID, JSON.stringify(value), synced]);
+ "(setting, libraryID, value, version, synced) VALUES (?, ?, ?, ?, ?)";
yield Zotero.DB.queryAsync(
sql, [setting, libraryID, JSON.stringify(value), version, synced]
);
}
yield Zotero.Notifier.trigger(event, 'setting', [id], extraData);
return true;

View file

@ -604,8 +604,9 @@ Components.utils.import("resource://gre/modules/osfile.jsm");
Zotero.Notifier.registerObserver(Zotero.Tags, 'setting');
Zotero.Sync.init();
Zotero.Sync.Runner.init();
yield Zotero.Sync.Data.Local.init();
yield Zotero.Sync.Data.Utilities.init();
Zotero.Sync.EventListeners.init();
Zotero.MIMETypeHandler.init();
yield Zotero.Proxies.init();

View file

@ -183,7 +183,7 @@ var ZoteroPane = new function()
if (index == 0) {
Zotero.Sync.Server.sync({
onSuccess: function () {
Zotero.Sync.Runner.setSyncIcon();
Zotero.Sync.Runner.updateIcons();
ps.alert(
null,
@ -436,7 +436,7 @@ var ZoteroPane = new function()
//
// We don't bother setting an error state at open
if (Zotero.Sync.Server.syncInProgress || Zotero.Sync.Storage.syncInProgress) {
Zotero.Sync.Runner.setSyncIcon('animate');
Zotero.Sync.Runner.updateIcons('animate');
}
return true;
@ -3969,7 +3969,7 @@ var ZoteroPane = new function()
+ ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING;
// Warning
if (e.errorMode == 'warning') {
if (e.errorType == 'warning') {
var title = Zotero.getString('general.warning');
// If secondary button not specified, just use an alert
@ -3996,7 +3996,7 @@ var ZoteroPane = new function()
}
}
// Error
else if (e.errorMode == 'error') {
else if (e.errorType == 'error') {
var title = Zotero.getString('general.error');
// If secondary button is explicitly null, just use an alert
@ -4031,7 +4031,7 @@ var ZoteroPane = new function()
}
}
// Upgrade
else if (e.errorMode == 'upgrade') {
else if (e.errorType == 'upgrade') {
ps.alert(null, "", e.message);
}
};

View file

@ -822,7 +822,7 @@ sync.error.invalidCharsFilename = The filename '%S' contains invalid characters
sync.lastSyncWithDifferentAccount = This Zotero database was last synced with a different zotero.org account ('%1$S') from the current one ('%2$S').
sync.localDataWillBeCombined = If you continue, local Zotero data will be combined with data from the '%S' account stored on the server.
sync.localGroupsWillBeRemoved1 = Local groups, including any with changed items, will also be removed.
sync.localGroupsWillBeRemoved1 = Local groups, including any with changed items, will also be removed from this computer.
sync.avoidCombiningData = To avoid combining or losing data, revert to the '%S' account or use the Reset options in the Sync pane of the Zotero preferences.
sync.localGroupsWillBeRemoved2 = If you continue, local groups, including any with changed items, will be removed and replaced with groups linked to the '%1$S' account.\n\nTo avoid losing local changes to groups, be sure you have synced with the '%2$S' account before syncing with the '%1$S' account.

View file

@ -95,6 +95,12 @@ const xpcomFilesLocal = [
'server',
'style',
'sync',
'sync/syncAPIClient',
'sync/syncEngine',
'sync/syncEventListeners',
'sync/syncLocal',
'sync/syncRunner',
'sync/syncUtilities',
'storage',
'storage/streamListener',
'storage/queueManager',

View file

@ -9,7 +9,7 @@ var ZOTERO_CONFIG = {
PROXY_AUTH_URL: 'https://s3.amazonaws.com/zotero.org/proxy-auth',
SYNC_URL: 'https://sync.zotero.org/',
API_URL: 'https://api.zotero.org/',
API_VERSION: 2,
API_VERSION: 3,
PREF_BRANCH: 'extensions.zotero.',
BOOKMARKLET_ORIGIN: 'https://www.zotero.org',
HTTP_BOOKMARKLET_ORIGIN: 'http://www.zotero.org',

View file

@ -627,3 +627,40 @@ function importFileAttachment(filename) {
filename.split('/').forEach((part) => testfile.append(part));
return Zotero.Attachments.importFromFile({file: testfile});
}
/**
* Sets the fake XHR server to response to a given response
*
* @param {Object} server - Sinon FakeXMLHttpRequest server
* @param {Object|String} response - Dot-separated path to predefined response in responses
* object (e.g., keyInfo.fullAccess) or a JSON object
* that defines the response
* @param {Object} responses - Predefined responses
*/
function setHTTPResponse(server, baseURL, response, responses) {
if (typeof response == 'string') {
let [topic, key] = response.split('.');
if (!responses[topic]) {
throw new Error("Invalid topic");
}
if (!responses[topic][key]) {
throw new Error("Invalid response key");
}
response = responses[topic][key];
}
var responseArray = [response.status || 200, {}, ""];
if (response.json) {
responseArray[1]["Content-Type"] = "application/json";
responseArray[2] = JSON.stringify(response.json);
}
else {
responseArray[1]["Content-Type"] = "text/plain";
responseArray[2] = response.text || "";
}
for (let i in response.headers) {
responseArray[1][i] = response.headers[i];
}
server.respondWith(response.method, baseURL + response.url, responseArray);
}

View file

@ -0,0 +1,754 @@
"use strict";
describe("Zotero.Sync.Data.Engine", function () {
var apiKey = Zotero.Utilities.randomString(24);
var baseURL = "http://local.zotero/";
var engine, server, client, caller, stub, spy;
var responses = {};
var setup = Zotero.Promise.coroutine(function* (options) {
options = options || {};
server = sinon.fakeServer.create();
server.autoRespond = true;
Components.utils.import("resource://zotero/concurrent-caller.js");
var caller = new ConcurrentCaller(1);
caller.setLogger(msg => Zotero.debug(msg));
caller.stopOnError = true;
caller.onError = function (e) {
Zotero.logError(e);
if (options.onError) {
options.onError(e);
}
if (e.fatal) {
caller.stop();
throw e;
}
};
var client = new Zotero.Sync.APIClient({
baseURL: baseURL,
apiVersion: options.apiVersion || ZOTERO_CONFIG.API_VERSION,
apiKey: apiKey,
concurrentCaller: caller,
background: options.background || true
});
var engine = new Zotero.Sync.Data.Engine({
apiClient: client,
libraryID: options.libraryID || Zotero.Libraries.userLibraryID
});
return { engine, client, caller };
});
function setResponse(response) {
setHTTPResponse(server, baseURL, response, responses);
}
function makeCollectionJSON(options) {
return {
key: options.key,
version: options.version,
data: {
key: options.key,
version: options.version,
name: options.name
}
};
}
function makeSearchJSON(options) {
return {
key: options.key,
version: options.version,
data: {
key: options.key,
version: options.version,
name: options.name,
conditions: options.conditions ? options.conditions : [
{
condition: 'title',
operator: 'contains',
value: 'test'
}
]
}
};
}
function makeItemJSON(options) {
var json = {
key: options.key,
version: options.version,
data: {
key: options.key,
version: options.version,
itemType: options.itemType || 'book',
title: options.title || options.name
}
};
Object.assign(json.data, options);
delete json.data.name;
return json;
}
// Allow functions to be called programmatically
var makeJSONFunctions = {
collection: makeCollectionJSON,
search: makeSearchJSON,
item: makeItemJSON
};
//
// Tests
//
beforeEach(function* () {
this.timeout(60000);
yield resetDB({
skipBundledFiles: true
});
Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
yield Zotero.Users.setCurrentUserID(1);
yield Zotero.Users.setCurrentUsername("testuser");
})
after(function* () {
this.timeout(60000);
yield resetDB();
})
describe("Syncing", function () {
it("should perform a sync for a new library", function* () {
({ engine, client, caller } = yield setup());
server.respond(function (req) {
if (req.method == "POST" && req.url == baseURL + "users/1/items") {
let ifUnmodifiedSince = req.requestHeaders["If-Unmodified-Since-Version"];
if (ifUnmodifiedSince == 0) {
req.respond(412, {}, "Library has been modified since specified version");
return;
}
if (ifUnmodifiedSince == 3) {
let json = JSON.parse(req.requestBody);
req.respond(
200,
{
"Content-Type": "application/json",
"Last-Modified-Version": 3
},
JSON.stringify({
success: {
"0": json[0].key,
"1": json[1].key
},
unchanged: {},
failed: {}
})
);
return;
}
}
})
var headers = {
"Last-Modified-Version": 3
};
setResponse({
method: "GET",
url: "users/1/settings",
status: 200,
headers: headers,
json: {
tagColors: {
value: [
{
name: "A",
color: "#CC66CC"
}
],
version: 2
}
}
});
setResponse({
method: "GET",
url: "users/1/collections?format=versions",
status: 200,
headers: headers,
json: {
"AAAAAAAA": 1
}
});
setResponse({
method: "GET",
url: "users/1/searches?format=versions",
status: 200,
headers: headers,
json: {
"AAAAAAAA": 2
}
});
setResponse({
method: "GET",
url: "users/1/items?format=versions&includeTrashed=1",
status: 200,
headers: headers,
json: {
"AAAAAAAA": 3
}
});
setResponse({
method: "GET",
url: "users/1/collections?format=json&collectionKey=AAAAAAAA",
status: 200,
headers: headers,
json: [
makeCollectionJSON({
key: "AAAAAAAA",
version: 1,
name: "A"
})
]
});
setResponse({
method: "GET",
url: "users/1/searches?format=json&searchKey=AAAAAAAA",
status: 200,
headers: headers,
json: [
makeSearchJSON({
key: "AAAAAAAA",
version: 2,
name: "A"
})
]
});
setResponse({
method: "GET",
url: "users/1/items?format=json&itemKey=AAAAAAAA&includeTrashed=1",
status: 200,
headers: headers,
json: [
makeItemJSON({
key: "AAAAAAAA",
version: 3,
itemType: "book",
title: "A"
})
]
});
setResponse({
method: "GET",
url: "users/1/deleted?since=0",
status: 200,
headers: headers,
json: {}
});
yield engine.start();
var userLibraryID = Zotero.Libraries.userLibraryID;
// Check local library version
assert.equal(Zotero.Libraries.getVersion(userLibraryID), 3);
// Make sure local objects exist
var setting = yield Zotero.SyncedSettings.get(userLibraryID, "tagColors");
assert.lengthOf(setting, 1);
assert.equal(setting[0].name, 'A');
var settingMetadata = yield Zotero.SyncedSettings.getMetadata(userLibraryID, "tagColors");
assert.equal(settingMetadata.version, 2);
assert.isTrue(settingMetadata.synced);
var obj = yield Zotero.Collections.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA");
assert.equal(obj.name, 'A');
assert.equal(obj.version, 1);
assert.isTrue(obj.synced);
obj = yield Zotero.Searches.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA");
assert.equal(obj.name, 'A');
assert.equal(obj.version, 2);
assert.isTrue(obj.synced);
obj = yield Zotero.Items.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA");
assert.equal(obj.getField('title'), 'A');
assert.equal(obj.version, 3);
assert.isTrue(obj.synced);
})
it("should make only one request if in sync", function* () {
yield Zotero.Libraries.setVersion(Zotero.Libraries.userLibraryID, 5);
({ engine, client, caller } = yield setup());
server.respond(function (req) {
if (req.method == "GET" && req.url == baseURL + "users/1/settings?since=5") {
let since = req.requestHeaders["If-Modified-Since-Version"];
if (since == 5) {
req.respond(304);
return;
}
}
});
yield engine.start();
})
})
describe("#_startDownload()", function () {
it("shouldn't redownload objects already in the cache", function* () {
var userLibraryID = Zotero.Libraries.userLibraryID;
//yield Zotero.Libraries.setVersion(userLibraryID, 5);
({ engine, client, caller } = yield setup());
var objects = {};
for (let type of Zotero.DataObjectUtilities.getTypes()) {
let obj = objects[type] = createUnsavedDataObject(type);
obj.version = 5;
obj.synced = true;
yield obj.saveTx({ skipSyncedUpdate: true });
yield Zotero.Sync.Data.Local.saveCacheObjects(
type,
userLibraryID,
[
{
key: obj.key,
version: obj.version,
data: (yield obj.toJSON())
}
]
);
}
var json;
var headers = {
"Last-Modified-Version": 5
};
setResponse({
method: "GET",
url: "users/1/settings",
status: 200,
headers: headers,
json: {}
});
json = {};
json[objects.collection.key] = 5;
setResponse({
method: "GET",
url: "users/1/collections?format=versions",
status: 200,
headers: headers,
json: json
});
json = {};
json[objects.search.key] = 5;
setResponse({
method: "GET",
url: "users/1/searches?format=versions",
status: 200,
headers: headers,
json: json
});
json = {};
json[objects.item.key] = 5;
setResponse({
method: "GET",
url: "users/1/items?format=versions&includeTrashed=1",
status: 200,
headers: headers,
json: json
});
setResponse({
method: "GET",
url: "users/1/deleted?since=0",
status: 200,
headers: headers,
json: {}
});
yield engine._startDownload();
})
it("should apply remote deletions", function* () {
var userLibraryID = Zotero.Libraries.userLibraryID;
yield Zotero.Libraries.setVersion(userLibraryID, 5);
({ engine, client, caller } = yield setup());
// Create objects and mark them as synced
yield Zotero.SyncedSettings.set(
userLibraryID, 'tagColors', [{name: 'A', color: '#CC66CC'}], 1, true
);
var collection = createUnsavedDataObject('collection');
collection.synced = true;
var collectionID = yield collection.saveTx({ skipSyncedUpdate: true });
var collectionKey = collection.key;
var search = createUnsavedDataObject('search');
search.synced = true;
var searchID = yield search.saveTx({ skipSyncedUpdate: true });
var searchKey = search.key;
var item = createUnsavedDataObject('item');
item.synced = true;
var itemID = yield item.saveTx({ skipSyncedUpdate: true });
var itemKey = item.key;
var headers = {
"Last-Modified-Version": 6
};
setResponse({
method: "GET",
url: "users/1/settings?since=5",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/collections?format=versions&since=5",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/searches?format=versions&since=5",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/items?format=versions&since=5&includeTrashed=1",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/deleted?since=5",
status: 200,
headers: headers,
json: {
settings: ['tagColors'],
collections: [collection.key],
searches: [search.key],
items: [item.key]
}
});
yield engine._startDownload();
// Make sure objects were deleted
assert.isFalse(yield Zotero.SyncedSettings.get(userLibraryID, 'tagColors'));
assert.isFalse(Zotero.Collections.exists(collectionID));
assert.isFalse(Zotero.Searches.exists(searchID));
assert.isFalse(Zotero.Items.exists(itemID));
// Make sure objects weren't added to sync delete log
assert.isFalse(yield Zotero.Sync.Data.Local._objectInDeleteLog(
'setting', userLibraryID, 'tagColors'
));
assert.isFalse(yield Zotero.Sync.Data.Local._objectInDeleteLog(
'collection', userLibraryID, collectionKey
));
assert.isFalse(yield Zotero.Sync.Data.Local._objectInDeleteLog(
'search', userLibraryID, searchKey
));
assert.isFalse(yield Zotero.Sync.Data.Local._objectInDeleteLog(
'item', userLibraryID, itemKey
));
})
it("should ignore remote deletions for non-item objects if local objects changed", function* () {
var userLibraryID = Zotero.Libraries.userLibraryID;
yield Zotero.Libraries.setVersion(userLibraryID, 5);
({ engine, client, caller } = yield setup());
// Create objects marked as unsynced
yield Zotero.SyncedSettings.set(
userLibraryID, 'tagColors', [{name: 'A', color: '#CC66CC'}]
);
var collection = createUnsavedDataObject('collection');
var collectionID = yield collection.saveTx();
var collectionKey = collection.key;
var search = createUnsavedDataObject('search');
var searchID = yield search.saveTx();
var searchKey = search.key;
var headers = {
"Last-Modified-Version": 6
};
setResponse({
method: "GET",
url: "users/1/settings?since=5",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/collections?format=versions&since=5",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/searches?format=versions&since=5",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/items?format=versions&since=5&includeTrashed=1",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/deleted?since=5",
status: 200,
headers: headers,
json: {
settings: ['tagColors'],
collections: [collection.key],
searches: [search.key],
items: []
}
});
yield engine._startDownload();
// Make sure objects weren't deleted
assert.ok(yield Zotero.SyncedSettings.get(userLibraryID, 'tagColors'));
assert.ok(Zotero.Collections.exists(collectionID));
assert.ok(Zotero.Searches.exists(searchID));
})
})
describe("#_upgradeCheck()", function () {
it("should upgrade a library last synced with the classic sync architecture", function* () {
var userLibraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
yield Zotero.Items.erase([1, 2], { skipDeleteLog: true });
var types = Zotero.DataObjectUtilities.getTypes();
var objects = {};
// Create objects added before the last classic sync time,
// which should end up marked as synced
for (let type of types) {
objects[type] = [yield createDataObject(type)];
}
var time1 = "2015-05-01 01:23:45";
yield Zotero.DB.queryAsync("UPDATE collections SET clientDateModified=?", time1);
yield Zotero.DB.queryAsync("UPDATE savedSearches SET clientDateModified=?", time1);
yield Zotero.DB.queryAsync("UPDATE items SET clientDateModified=?", time1);
// Create objects added after the last sync time, which should be ignored and
// therefore end up marked as unsynced
for (let type of types) {
objects[type].push(yield createDataObject(type));
}
var objectJSON = {};
for (let type of types) {
objectJSON[type] = [];
}
// Create JSON for objects created remotely after the last sync time,
// which should be ignored
objectJSON.collection.push(makeCollectionJSON({
key: Zotero.DataObjectUtilities.generateKey(),
version: 20,
name: Zotero.Utilities.randomString()
}));
objectJSON.search.push(makeSearchJSON({
key: Zotero.DataObjectUtilities.generateKey(),
version: 20,
name: Zotero.Utilities.randomString()
}));
objectJSON.item.push(makeItemJSON({
key: Zotero.DataObjectUtilities.generateKey(),
version: 20,
itemType: "book",
title: Zotero.Utilities.randomString()
}));
var lastSyncTime = Zotero.Date.toUnixTimestamp(
Zotero.Date.sqlToDate("2015-05-02 00:00:00", true)
);
yield Zotero.DB.queryAsync(
"INSERT INTO version VALUES ('lastlocalsync', ?1), ('lastremotesync', ?1)",
lastSyncTime
);
var headers = {
"Last-Modified-Version": 20
}
for (let type of types) {
var suffix = type == 'item' ? '&includeTrashed=1' : '';
var json = {};
json[objects[type][0].key] = 10;
json[objectJSON[type][0].key] = objectJSON[type][0].version;
setResponse({
method: "GET",
url: "users/1/" + Zotero.DataObjectUtilities.getObjectTypePlural(type)
+ "?format=versions" + suffix,
status: 200,
headers: headers,
json: json
});
json = {};
json[objectJSON[type][0].key] = objectJSON[type][0].version;
setResponse({
method: "GET",
url: "users/1/" + Zotero.DataObjectUtilities.getObjectTypePlural(type)
+ "?format=versions&sincetime=" + lastSyncTime + suffix,
status: 200,
headers: headers,
json: json
});
}
var versionResults = yield engine._upgradeCheck();
// Objects 1 should be marked as synced, with versions from the server
// Objects 2 should be marked as unsynced
for (let type of types) {
var synced = yield Zotero.Sync.Data.Local.getSynced(userLibraryID, type);
assert.deepEqual(synced, [objects[type][0].key]);
assert.equal(objects[type][0].version, 10);
var unsynced = yield Zotero.Sync.Data.Local.getUnsynced(userLibraryID, type);
assert.deepEqual(unsynced, [objects[type][1].id]);
assert.equal(versionResults[type].libraryVersion, headers["Last-Modified-Version"]);
assert.property(versionResults[type].versions, objectJSON[type][0].key);
}
assert.equal(Zotero.Libraries.getVersion(userLibraryID), -1);
})
})
describe("#_fullSync()", function () {
it("should download missing/updated local objects and flag remotely missing local objects for upload", function* () {
var userLibraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
yield Zotero.Items.erase([1, 2], { skipDeleteLog: true });
var types = Zotero.DataObjectUtilities.getTypes();
var objects = {};
var objectJSON = {};
for (let type of types) {
objectJSON[type] = [];
}
for (let type of types) {
// Create objects with outdated versions, which should be updated
let obj = createUnsavedDataObject(type);
obj.synced = true;
obj.version = 5;
yield obj.saveTx();
objects[type] = [obj];
objectJSON[type].push(makeJSONFunctions[type]({
key: obj.key,
version: 20,
name: Zotero.Utilities.randomString()
}));
// Create JSON for objects that exist remotely and not locally,
// which should be downloaded
objectJSON[type].push(makeJSONFunctions[type]({
key: Zotero.DataObjectUtilities.generateKey(),
version: 20,
name: Zotero.Utilities.randomString()
}));
// Create objects marked as synced that don't exist remotely,
// which should be flagged for upload
obj = createUnsavedDataObject(type);
obj.synced = true;
obj.version = 10;
yield obj.saveTx();
objects[type].push(obj);
}
var headers = {
"Last-Modified-Version": 20
}
setResponse({
method: "GET",
url: "users/1/settings",
status: 200,
headers: headers,
json: {
tagColors: {
value: [
{
name: "A",
color: "#CC66CC"
}
],
version: 2
}
}
});
for (let type of types) {
var suffix = type == 'item' ? '&includeTrashed=1' : '';
var json = {};
json[objectJSON[type][0].key] = objectJSON[type][0].version;
json[objectJSON[type][1].key] = objectJSON[type][1].version;
setResponse({
method: "GET",
url: "users/1/" + Zotero.DataObjectUtilities.getObjectTypePlural(type)
+ "?format=versions" + suffix,
status: 200,
headers: headers,
json: json
});
setResponse({
method: "GET",
url: "users/1/" + Zotero.DataObjectUtilities.getObjectTypePlural(type)
+ "?format=json"
+ "&" + type + "Key=" + objectJSON[type][0].key + "%2C" + objectJSON[type][1].key
+ suffix,
status: 200,
headers: headers,
json: objectJSON[type]
});
}
yield engine._fullSync();
// Check settings
var setting = yield Zotero.SyncedSettings.get(userLibraryID, "tagColors");
assert.lengthOf(setting, 1);
assert.equal(setting[0].name, 'A');
var settingMetadata = yield Zotero.SyncedSettings.getMetadata(userLibraryID, "tagColors");
assert.equal(settingMetadata.version, 2);
assert.isTrue(settingMetadata.synced);
// Check objects
for (let type of types) {
// Objects 1 should be updated with version from server
assert.equal(objects[type][0].version, 20);
assert.isTrue(objects[type][0].synced);
// JSON objects 1 should be created locally with version from server
let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
let obj = objectsClass.getByLibraryAndKey(userLibraryID, objectJSON[type][0].key);
assert.equal(obj.version, 20);
assert.isTrue(obj.synced);
// JSON objects 2 should be marked as unsynced, with their version reset to 0
assert.equal(objects[type][1].version, 0);
assert.isFalse(objects[type][1].synced);
}
})
})
})

984
test/tests/syncLocalTest.js Normal file
View file

@ -0,0 +1,984 @@
"use strict";
describe("Zotero.Sync.Data.Local", function() {
describe("#processSyncCacheForObjectType()", function () {
var types = Zotero.DataObjectUtilities.getTypes();
it("should update local version number if remote version is identical", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
for (let type of types) {
let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
let obj = yield createDataObject(type);
let data = yield obj.toJSON();
data.key = obj.key;
data.version = 10;
let json = {
key: obj.key,
version: 10,
data: data
};
yield Zotero.Sync.Data.Local.saveCacheObjects(
type, libraryID, [json]
);
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
libraryID, type, { stopOnError: true }
);
assert.equal(
objectsClass.getByLibraryAndKey(libraryID, obj.key).version, 10
);
}
})
})
describe("#_reconcileChanges()", function () {
describe("items", function () {
it("should ignore non-conflicting local changes and return remote changes", function () {
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
itemType: "book",
title: "Title 1",
url: "http://zotero.org/",
publicationTitle: "Publisher", // Remove locally
extra: "Extra", // Removed on both
dateModified: "2015-05-14 12:34:56",
collections: [
'AAAAAAAA', // Removed locally
'DDDDDDDD', // Removed remotely,
'EEEEEEEE' // Removed from both
],
relations: {
a: 'A', // Unchanged string
c: ['C1', 'C2'], // Unchanged array
d: 'D', // String removed locally
e: ['E'], // Array removed locally
f: 'F1', // String changed locally
g: [
'G1', // Unchanged
'G2', // Removed remotely
'G3' // Removed from both
],
h: 'H', // String removed remotely
i: ['I'], // Array removed remotely
},
tags: [
{ tag: 'A' }, // Removed locally
{ tag: 'D' }, // Removed remotely
{ tag: 'E' } // Removed from both
]
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
itemType: "book",
title: "Title 2", // Changed locally
url: "https://www.zotero.org/", // Same change on local and remote
place: "Place", // Added locally
dateModified: "2015-05-14 14:12:34", // Changed locally and remotely, but ignored
collections: [
'BBBBBBBB', // Added locally
'DDDDDDDD',
'FFFFFFFF' // Added on both
],
relations: {
'a': 'A',
'b': 'B', // String added locally
'f': 'F2',
'g': [
'G1',
'G2',
'G6' // Added locally and remotely
],
h: 'H', // String removed remotely
i: ['I'], // Array removed remotely
},
tags: [
{ tag: 'B' },
{ tag: 'D' },
{ tag: 'F', type: 1 }, // Added on both
{ tag: 'G' }, // Added on both, but with different types
{ tag: 'H', type: 1 } // Added on both, but with different types
]
};
var json2 = {
key: "AAAAAAAA",
version: 1235,
itemType: "book",
title: "Title 1",
url: "https://www.zotero.org/",
publicationTitle: "Publisher",
date: "2015-05-15", // Added remotely
dateModified: "2015-05-14 13:45:12",
collections: [
'AAAAAAAA',
'CCCCCCCC', // Added remotely
'FFFFFFFF'
],
relations: {
'a': 'A',
'd': 'D',
'e': ['E'],
'f': 'F1',
'g': [
'G1',
'G4', // Added remotely
'G6'
],
},
tags: [
{ tag: 'A' },
{ tag: 'C' },
{ tag: 'F', type: 1 },
{ tag: 'G', type: 1 },
{ tag: 'H' }
]
};
var ignoreFields = ['dateAdded', 'dateModified'];
var result = Zotero.Sync.Data.Local._reconcileChanges(
'item', cacheJSON, json1, json2, ignoreFields
);
assert.sameDeepMembers(
result.changes,
[
{
field: "date",
op: "add",
value: "2015-05-15"
},
{
field: "collections",
op: "member-add",
value: "CCCCCCCC"
},
{
field: "collections",
op: "member-remove",
value: "DDDDDDDD"
},
// Relations
{
field: "relations",
op: "property-member-remove",
value: {
key: 'g',
value: 'G2'
}
},
{
field: "relations",
op: "property-member-add",
value: {
key: 'g',
value: 'G4'
}
},
{
field: "relations",
op: "property-member-remove",
value: {
key: 'h',
value: 'H'
}
},
{
field: "relations",
op: "property-member-remove",
value: {
key: 'i',
value: 'I'
}
},
// Tags
{
field: "tags",
op: "member-add",
value: {
tag: 'C'
}
},
{
field: "tags",
op: "member-remove",
value: {
tag: 'D'
}
},
{
field: "tags",
op: "member-remove",
value: {
tag: 'H',
type: 1
}
},
{
field: "tags",
op: "member-add",
value: {
tag: 'H'
}
}
]
);
assert.lengthOf(result.conflicts, 0);
})
it("should return empty arrays when no remote changes to apply", function () {
// Similar to above but without differing remote changes
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
itemType: "book",
title: "Title 1",
url: "http://zotero.org/",
publicationTitle: "Publisher", // Remove locally
extra: "Extra", // Removed on both
dateModified: "2015-05-14 12:34:56",
collections: [
'AAAAAAAA', // Removed locally
'DDDDDDDD',
'EEEEEEEE' // Removed from both
],
tags: [
{
tag: 'A' // Removed locally
},
{
tag: 'D' // Removed remotely
},
{
tag: 'E' // Removed from both
}
]
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
itemType: "book",
title: "Title 2", // Changed locally
url: "https://www.zotero.org/", // Same change on local and remote
place: "Place", // Added locally
dateModified: "2015-05-14 14:12:34", // Changed locally and remotely, but ignored
collections: [
'BBBBBBBB', // Added locally
'DDDDDDDD',
'FFFFFFFF' // Added on both
],
tags: [
{
tag: 'B'
},
{
tag: 'D'
},
{
tag: 'F', // Added on both
type: 1
},
{
tag: 'G' // Added on both, but with different types
}
]
};
var json2 = {
key: "AAAAAAAA",
version: 1235,
itemType: "book",
title: "Title 1",
url: "https://www.zotero.org/",
publicationTitle: "Publisher",
dateModified: "2015-05-14 13:45:12",
collections: [
'AAAAAAAA',
'DDDDDDDD',
'FFFFFFFF'
],
tags: [
{
tag: 'A'
},
{
tag: 'D'
},
{
tag: 'F',
type: 1
},
{
tag: 'G',
type: 1
}
]
};
var ignoreFields = ['dateAdded', 'dateModified'];
var result = Zotero.Sync.Data.Local._reconcileChanges(
'item', cacheJSON, json1, json2, ignoreFields
);
assert.lengthOf(result.changes, 0);
assert.lengthOf(result.conflicts, 0);
})
it("should return conflict when changes can't be automatically resolved", function () {
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
title: "Title 1",
dateModified: "2015-05-14 12:34:56"
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
title: "Title 2",
dateModified: "2015-05-14 14:12:34"
};
var json2 = {
key: "AAAAAAAA",
version: 1235,
title: "Title 3",
dateModified: "2015-05-14 13:45:12"
};
var ignoreFields = ['dateAdded', 'dateModified'];
var result = Zotero.Sync.Data.Local._reconcileChanges(
'item', cacheJSON, json1, json2, ignoreFields
);
Zotero.debug('=-=-=-=');
Zotero.debug(result);
assert.lengthOf(result.changes, 0);
assert.sameDeepMembers(
result.conflicts,
[
[
{
field: "title",
op: "modify",
value: "Title 2"
},
{
field: "title",
op: "modify",
value: "Title 3"
}
]
]
);
})
it("should automatically merge array/object members and generate conflicts for field changes in absence of cached version", function () {
var json1 = {
key: "AAAAAAAA",
version: 1234,
itemType: "book",
title: "Title",
creators: [
{
name: "Center for History and New Media",
creatorType: "author"
}
],
place: "Place", // Local
dateModified: "2015-05-14 14:12:34", // Changed on both, but ignored
collections: [
'AAAAAAAA' // Local
],
relations: {
'a': 'A',
'b': 'B', // Local
'e': 'E1',
'f': [
'F1',
'F2' // Local
],
h: 'H', // String removed remotely
i: ['I'], // Array removed remotely
},
tags: [
{ tag: 'A' }, // Local
{ tag: 'C' },
{ tag: 'F', type: 1 },
{ tag: 'G' }, // Different types
{ tag: 'H', type: 1 } // Different types
]
};
var json2 = {
key: "AAAAAAAA",
version: 1235,
itemType: "book",
title: "Title",
creators: [
{
creatorType: "author", // Different property order shouldn't matter
name: "Center for History and New Media"
}
],
date: "2015-05-15", // Remote
dateModified: "2015-05-14 13:45:12",
collections: [
'BBBBBBBB' // Remote
],
relations: {
'a': 'A',
'c': 'C', // Remote
'd': ['D'], // Remote
'e': 'E2',
'f': [
'F1',
'F3' // Remote
],
},
tags: [
{ tag: 'B' }, // Remote
{ tag: 'C' },
{ tag: 'F', type: 1 },
{ tag: 'G', type: 1 }, // Different types
{ tag: 'H' } // Different types
]
};
var ignoreFields = ['dateAdded', 'dateModified'];
var result = Zotero.Sync.Data.Local._reconcileChanges(
'item', false, json1, json2, ignoreFields
);
Zotero.debug(result);
assert.sameDeepMembers(
result.changes,
[
// Collections
{
field: "collections",
op: "member-add",
value: "BBBBBBBB"
},
// Relations
{
field: "relations",
op: "property-member-add",
value: {
key: 'c',
value: 'C'
}
},
{
field: "relations",
op: "property-member-add",
value: {
key: 'd',
value: 'D'
}
},
{
field: "relations",
op: "property-member-add",
value: {
key: 'e',
value: 'E2'
}
},
{
field: "relations",
op: "property-member-add",
value: {
key: 'f',
value: 'F3'
}
},
// Tags
{
field: "tags",
op: "member-add",
value: {
tag: 'B'
}
},
{
field: "tags",
op: "member-add",
value: {
tag: 'G',
type: 1
}
},
{
field: "tags",
op: "member-add",
value: {
tag: 'H'
}
}
]
);
assert.sameDeepMembers(
result.conflicts,
[
{
field: "place",
op: "delete"
},
{
field: "date",
op: "add",
value: "2015-05-15"
}
]
);
})
})
describe("collections", function () {
it("should ignore non-conflicting local changes and return remote changes", function () {
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
parentCollection: null,
relations: {
A: "A", // Removed locally
C: "C" // Removed on both
}
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 2", // Changed locally
parentCollection: null,
relations: {}
};
var json2 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
parentCollection: "BBBBBBBB", // Added remotely
relations: {
A: "A",
B: "B" // Added remotely
}
};
var result = Zotero.Sync.Data.Local._reconcileChanges(
'collection', cacheJSON, json1, json2
);
assert.sameDeepMembers(
result.changes,
[
{
field: "parentCollection",
op: "add",
value: "BBBBBBBB"
},
{
field: "relations",
op: "property-member-add",
value: {
key: "B",
value: "B"
}
}
]
);
assert.lengthOf(result.conflicts, 0);
})
it("should return empty arrays when no remote changes to apply", function () {
// Similar to above but without differing remote changes
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 2", // Changed locally
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
// Added locally
{
condition: "place",
operator: "is",
value: "New York"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var json2 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var result = Zotero.Sync.Data.Local._reconcileChanges(
'search', cacheJSON, json1, json2
);
assert.lengthOf(result.changes, 0);
assert.lengthOf(result.conflicts, 0);
})
it("should automatically resolve conflicts with remote version", function () {
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1"
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 2"
};
var json2 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 3"
};
var result = Zotero.Sync.Data.Local._reconcileChanges(
'search', cacheJSON, json1, json2
);
assert.sameDeepMembers(
result.changes,
[
{
field: "name",
op: "modify",
value: "Name 3"
}
]
);
assert.lengthOf(result.conflicts, 0);
})
it("should automatically resolve conflicts in absence of cached version", function () {
var json1 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "New York"
}
]
};
var json2 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 2",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var result = Zotero.Sync.Data.Local._reconcileChanges(
'search', false, json1, json2
);
assert.sameDeepMembers(
result.changes,
[
{
field: "name",
op: "modify",
value: "Name 2"
},
{
field: "conditions",
op: "member-add",
value: {
condition: "place",
operator: "is",
value: "Chicago"
}
}
]
);
assert.lengthOf(result.conflicts, 0);
})
})
describe("searches", function () {
it("should ignore non-conflicting local changes and return remote changes", function () {
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 2", // Changed locally
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
// Removed remotely
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var json2 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
// Added remotely
{
condition: "place",
operator: "is",
value: "New York"
}
]
};
var result = Zotero.Sync.Data.Local._reconcileChanges(
'search', cacheJSON, json1, json2
);
assert.sameDeepMembers(
result.changes,
[
{
field: "conditions",
op: "member-add",
value: {
condition: "place",
operator: "is",
value: "New York"
}
},
{
field: "conditions",
op: "member-remove",
value: {
condition: "place",
operator: "is",
value: "Chicago"
}
}
]
);
assert.lengthOf(result.conflicts, 0);
})
it("should return empty arrays when no remote changes to apply", function () {
// Similar to above but without differing remote changes
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 2", // Changed locally
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
// Added locally
{
condition: "place",
operator: "is",
value: "New York"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var json2 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var result = Zotero.Sync.Data.Local._reconcileChanges(
'search', cacheJSON, json1, json2
);
assert.lengthOf(result.changes, 0);
assert.lengthOf(result.conflicts, 0);
})
it("should automatically resolve conflicts with remote version", function () {
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1"
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 2"
};
var json2 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 3"
};
var result = Zotero.Sync.Data.Local._reconcileChanges(
'search', cacheJSON, json1, json2
);
assert.sameDeepMembers(
result.changes,
[
{
field: "name",
op: "modify",
value: "Name 3"
}
]
);
assert.lengthOf(result.conflicts, 0);
})
it("should automatically resolve conflicts in absence of cached version", function () {
var json1 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "New York"
}
]
};
var json2 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 2",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var result = Zotero.Sync.Data.Local._reconcileChanges(
'search', false, json1, json2
);
assert.sameDeepMembers(
result.changes,
[
{
field: "name",
op: "modify",
value: "Name 2"
},
{
field: "conditions",
op: "member-add",
value: {
condition: "place",
operator: "is",
value: "Chicago"
}
}
]
);
assert.lengthOf(result.conflicts, 0);
})
})
})
})

View file

@ -0,0 +1,653 @@
"use strict";
describe("Zotero.Sync.Runner", function () {
var apiKey = Zotero.Utilities.randomString(24);
var baseURL = "http://local.zotero/";
var userLibraryID, publicationsLibraryID, runner, caller, server, client, stub, spy;
var responses = {
keyInfo: {
fullAccess: {
method: "GET",
url: "keys/" + apiKey,
status: 200,
json: {
key: apiKey,
userID: 1,
username: "Username",
access: {
user: {
library: true,
files: true,
notes: true,
write: true
},
groups: {
all: {
library: true,
write: true
}
}
}
}
}
},
userGroups: {
groupVersions: {
method: "GET",
url: "users/1/groups?format=versions",
json: {
"1623562": 10,
"2694172": 11
}
},
groupVersionsEmpty: {
method: "GET",
url: "users/1/groups?format=versions",
json: {}
},
groupVersionsOnlyMemberGroup: {
method: "GET",
url: "users/1/groups?format=versions",
json: {
"2694172": 11
}
}
},
groups: {
ownerGroup: {
method: "GET",
url: "groups/1623562",
json: {
id: 1623562,
version: 10,
data: {
id: 1623562,
version: 10,
name: "Group Name",
description: "<p>Test group</p>",
owner: 1,
type: "Private",
libraryEditing: "members",
libraryReading: "all",
fileEditing: "members",
admins: [],
members: []
}
}
},
memberGroup: {
method: "GET",
url: "groups/2694172",
json: {
id: 2694172,
version: 11,
data: {
id: 2694172,
version: 11,
name: "Group Name 2",
description: "<p>Test group</p>",
owner: 123456,
type: "Private",
libraryEditing: "admins",
libraryReading: "all",
fileEditing: "admins",
admins: [],
members: [1]
}
}
}
}
};
//
// Helper functions
//
var setup = Zotero.Promise.coroutine(function* (options = {}) {
yield Zotero.DB.queryAsync("DELETE FROM settings WHERE setting='account'");
yield Zotero.Users.init();
var runner = new Zotero.Sync.Runner_Module({
baseURL: baseURL,
apiKey: apiKey
});
Components.utils.import("resource://zotero/concurrent-caller.js");
var caller = new ConcurrentCaller(1);
caller.setLogger(msg => Zotero.debug(msg));
caller.stopOnError = true;
caller.onError = function (e) {
Zotero.logError(e);
if (options.onError) {
options.onError(e);
}
if (e.fatal) {
caller.stop();
throw e;
}
};
var client = new Zotero.Sync.APIClient({
baseURL: baseURL,
apiVersion: options.apiVersion || ZOTERO_CONFIG.API_VERSION,
apiKey: apiKey,
concurrentCaller: caller,
background: options.background || true
});
return { runner, caller, client };
})
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, client } = yield setup());
yield Zotero.Users.setCurrentUserID(1);
yield Zotero.Users.setCurrentUsername("A");
})
afterEach(function () {
if (stub) stub.restore();
if (spy) spy.restore();
})
after(function () {
Zotero.HTTP.mock = null;
})
describe("#checkAccess()", function () {
it("should check key access", function* () {
spy = sinon.spy(runner, "checkUser");
setResponse('keyInfo.fullAccess');
var json = yield runner.checkAccess(client);
sinon.assert.calledWith(spy, 1, "Username");
var compare = {};
Object.assign(compare, responses.keyInfo.fullAccess.json);
delete compare.key;
assert.deepEqual(json, compare);
})
})
describe("#checkLibraries()", function () {
afterEach(function* () {
var group = Zotero.Groups.get(responses.groups.ownerGroup.json.id);
if (group) {
yield group.eraseTx();
}
group = Zotero.Groups.get(responses.groups.memberGroup.json.id);
if (group) {
yield group.eraseTx();
}
})
it("should check library access and versions without library list", function* () {
// Create group with same id and version as groups response
var groupData = responses.groups.ownerGroup;
var group1 = yield createGroup({
id: groupData.json.id,
version: groupData.json.version
});
groupData = responses.groups.memberGroup;
var group2 = yield createGroup({
id: groupData.json.id,
version: groupData.json.version
});
setResponse('userGroups.groupVersions');
var libraries = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json
);
assert.lengthOf(libraries, 4);
assert.sameMembers(
libraries,
[userLibraryID, publicationsLibraryID, group1.libraryID, group2.libraryID]
);
})
it("should check library access and versions with library list", function* () {
// Create groups with same id and version as groups response
var groupData = responses.groups.ownerGroup;
var group1 = yield createGroup({
id: groupData.json.id,
version: groupData.json.version
});
groupData = responses.groups.memberGroup;
var group2 = yield createGroup({
id: groupData.json.id,
version: groupData.json.version
});
setResponse('userGroups.groupVersions');
var libraries = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json, [userLibraryID]
);
assert.lengthOf(libraries, 1);
assert.sameMembers(libraries, [userLibraryID]);
var libraries = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json, [userLibraryID, publicationsLibraryID]
);
assert.lengthOf(libraries, 2);
assert.sameMembers(libraries, [userLibraryID, publicationsLibraryID]);
var libraries = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json, [group1.libraryID]
);
assert.lengthOf(libraries, 1);
assert.sameMembers(libraries, [group1.libraryID]);
})
it("should update outdated group metadata", function* () {
// Create groups with same id as groups response but earlier versions
var groupData1 = responses.groups.ownerGroup;
var group1 = yield createGroup({
id: groupData1.json.id,
version: groupData1.json.version - 1,
editable: false
});
var groupData2 = responses.groups.memberGroup;
var group2 = yield createGroup({
id: groupData2.json.id,
version: groupData2.json.version - 1,
editable: true
});
setResponse('userGroups.groupVersions');
setResponse('groups.ownerGroup');
setResponse('groups.memberGroup');
var libraries = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json
);
assert.lengthOf(libraries, 4);
assert.sameMembers(
libraries,
[userLibraryID, publicationsLibraryID, group1.libraryID, group2.libraryID]
);
assert.equal(group1.name, groupData1.json.data.name);
assert.equal(group1.version, groupData1.json.version);
assert.isTrue(group1.editable);
assert.equal(group2.name, groupData2.json.data.name);
assert.equal(group2.version, groupData2.json.version);
assert.isFalse(group2.editable);
})
it("should update outdated group metadata for group created with classic sync", function* () {
var groupData1 = responses.groups.ownerGroup;
var group1 = yield createGroup({
id: groupData1.json.id,
version: 0,
editable: false
});
var groupData2 = responses.groups.memberGroup;
var group2 = yield createGroup({
id: groupData2.json.id,
version: 0,
editable: true
});
yield Zotero.DB.queryAsync(
"UPDATE groups SET version=0 WHERE groupID IN (?, ?)", [group1.id, group2.id]
);
yield Zotero.Groups.init();
group1 = Zotero.Groups.get(group1.id);
group2 = Zotero.Groups.get(group2.id);
setResponse('userGroups.groupVersions');
setResponse('groups.ownerGroup');
setResponse('groups.memberGroup');
var libraries = yield runner.checkLibraries(
client,
false,
responses.keyInfo.fullAccess.json,
[group1.libraryID, group2.libraryID]
);
assert.lengthOf(libraries, 2);
assert.sameMembers(libraries, [group1.libraryID, group2.libraryID]);
assert.equal(group1.name, groupData1.json.data.name);
assert.equal(group1.version, groupData1.json.version);
assert.isTrue(group1.editable);
assert.equal(group2.name, groupData2.json.data.name);
assert.equal(group2.version, groupData2.json.version);
assert.isFalse(group2.editable);
})
it("should create locally missing groups", function* () {
setResponse('userGroups.groupVersions');
setResponse('groups.ownerGroup');
setResponse('groups.memberGroup');
var libraries = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json
);
assert.lengthOf(libraries, 4);
var groupData1 = responses.groups.ownerGroup;
var group1 = Zotero.Groups.get(groupData1.json.id);
var groupData2 = responses.groups.memberGroup;
var group2 = Zotero.Groups.get(groupData2.json.id);
assert.ok(group1);
assert.ok(group2);
assert.sameMembers(
libraries,
[userLibraryID, publicationsLibraryID, group1.libraryID, group2.libraryID]
);
assert.equal(group1.name, groupData1.json.data.name);
assert.isTrue(group1.editable);
assert.equal(group2.name, groupData2.json.data.name);
assert.isFalse(group2.editable);
})
it("should delete remotely missing groups", function* () {
var groupData1 = responses.groups.ownerGroup;
var group1 = yield createGroup({ id: groupData1.json.id, version: groupData1.json.version });
var groupData2 = responses.groups.memberGroup;
var group2 = yield createGroup({ id: groupData2.json.id, version: groupData2.json.version });
setResponse('userGroups.groupVersionsOnlyMemberGroup');
waitForDialog(function (dialog) {
var text = dialog.document.documentElement.textContent;
assert.include(text, group1.name);
});
var libraries = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json
);
assert.lengthOf(libraries, 3);
assert.sameMembers(libraries, [userLibraryID, publicationsLibraryID, group2.libraryID]);
assert.isFalse(Zotero.Groups.exists(groupData1.json.id));
assert.isTrue(Zotero.Groups.exists(groupData2.json.id));
})
it.skip("should keep remotely missing groups", function* () {
var groupData = responses.groups.ownerGroup;
var group = yield createGroup({ id: groupData.json.id, version: groupData.json.version });
setResponse('userGroups.groupVersionsEmpty');
waitForDialog(function (dialog) {
var text = dialog.document.documentElement.textContent;
assert.include(text, group.name);
}, "extra1");
var libraries = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json
);
assert.lengthOf(libraries, 3);
assert.sameMembers(libraries, [userLibraryID, publicationsLibraryID, group.libraryID]);
assert.isTrue(Zotero.Groups.exists(groupData.json.id));
})
it("should cancel sync with remotely missing groups", function* () {
var groupData = responses.groups.ownerGroup;
var group = yield createGroup({ id: groupData.json.id, version: groupData.json.version });
setResponse('userGroups.groupVersionsEmpty');
waitForDialog(function (dialog) {
var text = dialog.document.documentElement.textContent;
assert.include(text, group.name);
}, "cancel");
var libraries = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json
);
assert.lengthOf(libraries, 0);
assert.isTrue(Zotero.Groups.exists(groupData.json.id));
})
})
describe("#checkUser()", function () {
it("should prompt for user update and perform on accept", function* () {
waitForDialog(function (dialog) {
var text = dialog.document.documentElement.textContent;
var matches = text.match(/'[^']*'/g);
assert.equal(matches.length, 4);
assert.equal(matches[0], "'A'");
assert.equal(matches[1], "'B'");
assert.equal(matches[2], "'B'");
assert.equal(matches[3], "'A'");
});
var cont = yield runner.checkUser(2, "B");
assert.isTrue(cont);
assert.equal(Zotero.Users.getCurrentUserID(), 2);
assert.equal(Zotero.Users.getCurrentUsername(), "B");
})
it("should prompt for user update and cancel", function* () {
yield Zotero.Users.setCurrentUserID(1);
yield Zotero.Users.setCurrentUsername("A");
waitForDialog(false, 'cancel');
var cont = yield runner.checkUser(2, "B");
assert.isFalse(cont);
assert.equal(Zotero.Users.getCurrentUserID(), 1);
assert.equal(Zotero.Users.getCurrentUsername(), "A");
})
})
describe("#sync()", function () {
before(function* () {
this.timeout(60000);
yield resetDB({
skipBundledFiles: true
});
yield Zotero.Groups.init();
})
after(function* () {
this.timeout(60000);
yield resetDB();
})
it("should perform a sync across all libraries", function* () {
yield Zotero.Users.setCurrentUserID(1);
yield Zotero.Users.setCurrentUsername("A");
setResponse('keyInfo.fullAccess');
setResponse('userGroups.groupVersions');
setResponse('groups.ownerGroup');
setResponse('groups.memberGroup');
// My Library
setResponse({
method: "GET",
url: "users/1/settings",
status: 200,
headers: {
"Last-Modified-Version": 5
},
json: []
});
setResponse({
method: "GET",
url: "users/1/collections?format=versions",
status: 200,
headers: {
"Last-Modified-Version": 5
},
json: []
});
setResponse({
method: "GET",
url: "users/1/searches?format=versions",
status: 200,
headers: {
"Last-Modified-Version": 5
},
json: []
});
setResponse({
method: "GET",
url: "users/1/items?format=versions&includeTrashed=1",
status: 200,
headers: {
"Last-Modified-Version": 5
},
json: []
});
setResponse({
method: "GET",
url: "users/1/deleted?since=0",
status: 200,
headers: {
"Last-Modified-Version": 5
},
json: []
});
// My Publications
setResponse({
method: "GET",
url: "users/1/publications/settings",
status: 200,
headers: {
"Last-Modified-Version": 10
},
json: []
});
setResponse({
method: "GET",
url: "users/1/publications/items?format=versions&includeTrashed=1",
status: 200,
headers: {
"Last-Modified-Version": 10
},
json: []
});
setResponse({
method: "GET",
url: "users/1/publications/deleted?since=0",
status: 200,
headers: {
"Last-Modified-Version": 10
},
json: []
});
// Group library 1
setResponse({
method: "GET",
url: "groups/1623562/settings",
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: []
});
setResponse({
method: "GET",
url: "groups/1623562/collections?format=versions",
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: []
});
setResponse({
method: "GET",
url: "groups/1623562/searches?format=versions",
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: []
});
setResponse({
method: "GET",
url: "groups/1623562/items?format=versions&includeTrashed=1",
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: []
});
setResponse({
method: "GET",
url: "groups/1623562/deleted?since=0",
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: []
});
// Group library 2
setResponse({
method: "GET",
url: "groups/2694172/settings",
status: 200,
headers: {
"Last-Modified-Version": 20
},
json: []
});
setResponse({
method: "GET",
url: "groups/2694172/collections?format=versions",
status: 200,
headers: {
"Last-Modified-Version": 20
},
json: []
});
setResponse({
method: "GET",
url: "groups/2694172/searches?format=versions",
status: 200,
headers: {
"Last-Modified-Version": 20
},
json: []
});
setResponse({
method: "GET",
url: "groups/2694172/items?format=versions&includeTrashed=1",
status: 200,
headers: {
"Last-Modified-Version": 20
},
json: []
});
setResponse({
method: "GET",
url: "groups/2694172/deleted?since=0",
status: 200,
headers: {
"Last-Modified-Version": 20
},
json: []
});
yield runner.sync({
baseURL: baseURL,
apiKey: apiKey,
onError: e => { throw e },
});
// Check local library versions
assert.equal(
Zotero.Libraries.getVersion(Zotero.Libraries.userLibraryID),
5
);
assert.equal(
Zotero.Libraries.getVersion(Zotero.Libraries.publicationsLibraryID),
10
);
assert.equal(
Zotero.Libraries.getVersion(Zotero.Groups.getLibraryIDFromGroupID(1623562)),
15
);
assert.equal(
Zotero.Libraries.getVersion(Zotero.Groups.getLibraryIDFromGroupID(2694172)),
20
);
})
})
})