2033 lines
62 KiB
JavaScript
2033 lines
62 KiB
JavaScript
/*
|
|
***** 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 = {
|
|
_syncQueueIntervals: [0.5, 1, 4, 16, 16, 16, 16, 16, 16, 16, 64], // hours
|
|
_loginManagerHost: 'chrome://zotero',
|
|
_loginManagerRealm: 'Zotero Web API',
|
|
_lastSyncTime: null,
|
|
_lastClassicSyncTime: null,
|
|
|
|
init: Zotero.Promise.coroutine(function* () {
|
|
yield this._loadLastSyncTime();
|
|
if (!_lastSyncTime) {
|
|
yield this._loadLastClassicSyncTime();
|
|
}
|
|
}),
|
|
|
|
|
|
/**
|
|
* @return {Promise}
|
|
*/
|
|
getAPIKey: Zotero.Promise.method(function () {
|
|
var login = this._getAPIKeyLoginInfo();
|
|
return login
|
|
? login.password
|
|
// Fallback to old username/password
|
|
: this._getAPIKeyFromLogin();
|
|
}),
|
|
|
|
|
|
/**
|
|
* Check for an API key or a legacy username/password (which may or may not be valid)
|
|
*/
|
|
hasCredentials: function () {
|
|
var login = this._getAPIKeyLoginInfo();
|
|
if (login) {
|
|
return true;
|
|
}
|
|
// If no API key, check for legacy login
|
|
var username = Zotero.Prefs.get('sync.server.username');
|
|
return username && !!this.getLegacyPassword(username)
|
|
},
|
|
|
|
|
|
setAPIKey: function (apiKey) {
|
|
var loginManager = Components.classes["@mozilla.org/login-manager;1"]
|
|
.getService(Components.interfaces.nsILoginManager);
|
|
|
|
var oldLoginInfo = this._getAPIKeyLoginInfo();
|
|
|
|
// Clear old login
|
|
if ((!apiKey || apiKey === "")) {
|
|
if (oldLoginInfo) {
|
|
Zotero.debug("Clearing old API key");
|
|
loginManager.removeLogin(oldLoginInfo);
|
|
}
|
|
Zotero.Notifier.trigger('delete', 'api-key', []);
|
|
return;
|
|
}
|
|
|
|
var nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
|
|
Components.interfaces.nsILoginInfo, "init");
|
|
var loginInfo = new nsLoginInfo(
|
|
this._loginManagerHost,
|
|
null,
|
|
this._loginManagerRealm,
|
|
'API Key',
|
|
apiKey,
|
|
'',
|
|
''
|
|
);
|
|
if (!oldLoginInfo) {
|
|
Zotero.debug("Setting API key");
|
|
loginManager.addLogin(loginInfo);
|
|
}
|
|
else {
|
|
Zotero.debug("Replacing API key");
|
|
loginManager.modifyLogin(oldLoginInfo, loginInfo);
|
|
}
|
|
Zotero.Notifier.trigger('modify', 'api-key', []);
|
|
},
|
|
|
|
|
|
/**
|
|
* Make sure we're syncing with the same account we used last time, and prompt if not.
|
|
* If user accepts, change the current user and initiate deletion of all user data after a
|
|
* restart.
|
|
*
|
|
* @param {Window|null}
|
|
* @param {Integer} userID - New userID
|
|
* @param {String} username - New username
|
|
* @param {String} [name] - New display name
|
|
* @return {Boolean} - True to continue, false to cancel
|
|
*/
|
|
checkUser: Zotero.Promise.coroutine(function* (win, userID, username, name) {
|
|
var lastUserID = Zotero.Users.getCurrentUserID();
|
|
var lastUsername = Zotero.Users.getCurrentUsername();
|
|
var lastName = Zotero.Users.getCurrentName();
|
|
|
|
if (lastUserID && lastUserID != userID) {
|
|
Zotero.debug(`Last user id ${lastUserID}, current user id ${userID}, `
|
|
+ `last username '${lastUsername}', current username '${username}'`, 2);
|
|
var io = {
|
|
title: Zotero.getString('general.warning'),
|
|
text: Zotero.getString(
|
|
'account.lastSyncWithDifferentAccount',
|
|
[Zotero.appName, lastUsername, username]
|
|
) + '\n\n'
|
|
+ Zotero.getString(
|
|
'account.lastSyncWithDifferentAccount.beforeContinuing',
|
|
[lastUsername, Zotero.appName]
|
|
),
|
|
checkboxLabel: Zotero.getString('account.confirmDelete', lastUsername),
|
|
acceptLabel: Zotero.getString('account.confirmDelete.button'),
|
|
extra2Label: Zotero.getString('general.moreInformation')
|
|
};
|
|
win.openDialog("chrome://zotero/content/hardConfirmationDialog.xul", "",
|
|
"chrome,dialog,modal,centerscreen", io);
|
|
|
|
if (io.accept) {
|
|
var resetDataDirFile = OS.Path.join(Zotero.DataDirectory.dir, 'reset-data-directory');
|
|
yield Zotero.File.putContentsAsync(resetDataDirFile, '');
|
|
|
|
Zotero.Prefs.clear('sync.storage.downloadMode.groups');
|
|
Zotero.Prefs.clear('sync.storage.groups.enabled');
|
|
Zotero.Prefs.clear('sync.storage.downloadMode.personal');
|
|
Zotero.Prefs.clear('sync.storage.username');
|
|
Zotero.Prefs.clear('sync.storage.url');
|
|
Zotero.Prefs.clear('sync.storage.scheme');
|
|
Zotero.Prefs.clear('sync.storage.protocol');
|
|
Zotero.Prefs.clear('sync.storage.enabled');
|
|
|
|
Zotero.Utilities.Internal.quitZotero(true);
|
|
|
|
return true;
|
|
}
|
|
else if (io.extra2) {
|
|
Zotero.launchURL("https://www.zotero.org/support/kb/switching_accounts");
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
yield Zotero.DB.executeTransaction(async function () {
|
|
if (lastUsername != username) {
|
|
await Zotero.Users.setCurrentUsername(username);
|
|
}
|
|
if (!lastUserID) {
|
|
await Zotero.Users.setCurrentUserID(userID);
|
|
|
|
// Replace local user key with libraryID, in case duplicates were merged before the
|
|
// first sync
|
|
await Zotero.Relations.updateUser(null, userID);
|
|
await Zotero.Notes.updateUser(null, userID);
|
|
}
|
|
var newName = name || username;
|
|
if (lastName != newName) {
|
|
await Zotero.Users.setCurrentName(newName);
|
|
}
|
|
});
|
|
|
|
return true;
|
|
}),
|
|
|
|
|
|
/**
|
|
* @return {Promise<Boolean>} - True if library updated, false to cancel
|
|
*/
|
|
checkLibraryForAccess: Zotero.Promise.coroutine(function* (win, libraryID, editable, filesEditable) {
|
|
var library = Zotero.Libraries.get(libraryID);
|
|
|
|
// If library is going from editable to non-editable and there's unsynced local data, prompt
|
|
if (library.editable && !editable && (yield this._libraryHasUnsyncedData(libraryID))) {
|
|
let index = Zotero.Sync.Data.Utilities.showWriteAccessLostPrompt(win, library);
|
|
// Reset library
|
|
if (index == 0) {
|
|
// This check happens before item data is loaded for syncing, so do it now,
|
|
// since the reset requires it
|
|
if (!library.getDataLoaded('item')) {
|
|
yield library.waitForDataLoad('item');
|
|
}
|
|
yield this.resetUnsyncedLibraryData(libraryID);
|
|
return true;
|
|
}
|
|
|
|
// Skip library
|
|
return false;
|
|
}
|
|
|
|
if (library.filesEditable && !filesEditable && (yield this._libraryHasUnsyncedFiles(libraryID))) {
|
|
let index = Zotero.Sync.Storage.Utilities.showFileWriteAccessLostPrompt(win, library);
|
|
// Reset library files
|
|
if (index == 0) {
|
|
// This check happens before item data is loaded for syncing, so do it now,
|
|
// since the reset requires it
|
|
if (!library.getDataLoaded('item')) {
|
|
yield library.waitForDataLoad('item');
|
|
}
|
|
yield this.resetUnsyncedLibraryFiles(libraryID);
|
|
return true;
|
|
}
|
|
|
|
// Skip library
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}),
|
|
|
|
|
|
_libraryHasUnsyncedData: Zotero.Promise.coroutine(function* (libraryID) {
|
|
let settings = yield Zotero.SyncedSettings.getUnsynced(libraryID);
|
|
if (Object.keys(settings).length) {
|
|
return true;
|
|
}
|
|
|
|
for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(libraryID)) {
|
|
let ids = yield Zotero.Sync.Data.Local.getUnsynced(objectType, libraryID);
|
|
if (ids.length) {
|
|
return true;
|
|
}
|
|
|
|
let keys = yield Zotero.Sync.Data.Local.getDeleted(objectType, libraryID);
|
|
if (keys.length) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}),
|
|
|
|
|
|
_libraryHasUnsyncedFiles: Zotero.Promise.coroutine(function* (libraryID) {
|
|
// TODO: Check for modified file attachment items, which also can't be uploaded
|
|
// (and which are corrected by resetUnsyncedLibraryFiles())
|
|
yield Zotero.Sync.Storage.Local.checkForUpdatedFiles(libraryID);
|
|
return !!(yield Zotero.Sync.Storage.Local.getFilesToUpload(libraryID)).length;
|
|
}),
|
|
|
|
|
|
resetUnsyncedLibraryData: Zotero.Promise.coroutine(function* (libraryID) {
|
|
let settings = yield Zotero.SyncedSettings.getUnsynced(libraryID);
|
|
if (Object.keys(settings).length) {
|
|
yield Zotero.Promise.each(Object.keys(settings), function (key) {
|
|
return Zotero.SyncedSettings.clear(libraryID, key, { skipDeleteLog: true });
|
|
});
|
|
}
|
|
|
|
for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(libraryID)) {
|
|
// New/modified objects
|
|
let ids = yield this.getUnsynced(objectType, libraryID);
|
|
yield this._resetObjects(libraryID, objectType, ids);
|
|
}
|
|
|
|
// Mark library for full sync
|
|
var library = Zotero.Libraries.get(libraryID);
|
|
library.libraryVersion = -1;
|
|
yield library.saveTx();
|
|
|
|
yield this.resetUnsyncedLibraryFiles(libraryID);
|
|
}),
|
|
|
|
|
|
/**
|
|
* Delete unsynced files from library
|
|
*
|
|
* _libraryHasUnsyncedFiles(), which checks for updated files, must be called first.
|
|
*/
|
|
resetUnsyncedLibraryFiles: async function (libraryID) {
|
|
// Reset unsynced file attachments
|
|
var itemIDs = await Zotero.Sync.Data.Local.getUnsynced('item', libraryID);
|
|
var toReset = [];
|
|
for (let itemID of itemIDs) {
|
|
let item = Zotero.Items.get(itemID);
|
|
if (item.isFileAttachment()) {
|
|
toReset.push(item.id);
|
|
}
|
|
}
|
|
await this._resetObjects(libraryID, 'item', toReset);
|
|
|
|
// Delete unsynced files
|
|
var itemIDs = await Zotero.Sync.Storage.Local.getFilesToUpload(libraryID);
|
|
for (let itemID of itemIDs) {
|
|
let item = Zotero.Items.get(itemID);
|
|
await item.deleteAttachmentFile();
|
|
}
|
|
},
|
|
|
|
|
|
_resetObjects: async function (libraryID, objectType, ids) {
|
|
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
|
|
|
|
var keys = ids.map(id => objectsClass.getLibraryAndKeyFromID(id).key);
|
|
var cacheVersions = await this.getLatestCacheObjectVersions(objectType, libraryID, keys);
|
|
var toDelete = [];
|
|
for (let key of keys) {
|
|
let obj = objectsClass.getByLibraryAndKey(libraryID, key);
|
|
|
|
// If object is in cache, overwrite with pristine data
|
|
if (cacheVersions[key]) {
|
|
let json = await this.getCacheObject(objectType, libraryID, key, cacheVersions[key]);
|
|
await Zotero.DB.executeTransaction(async function () {
|
|
await this._saveObjectFromJSON(obj, json, {});
|
|
}.bind(this));
|
|
}
|
|
// Otherwise, erase
|
|
else {
|
|
toDelete.push(objectsClass.getIDFromLibraryAndKey(libraryID, key));
|
|
}
|
|
}
|
|
if (toDelete.length) {
|
|
await objectsClass.erase(
|
|
toDelete,
|
|
{
|
|
skipEditCheck: true,
|
|
skipDeleteLog: true
|
|
}
|
|
);
|
|
}
|
|
|
|
// Deleted objects
|
|
keys = await Zotero.Sync.Data.Local.getDeleted(objectType, libraryID);
|
|
await this.removeObjectsFromDeleteLog(objectType, libraryID, keys);
|
|
},
|
|
|
|
|
|
getSkippedLibraries: function () {
|
|
return this._getSkippedLibrariesByPrefix("L");
|
|
},
|
|
|
|
|
|
getSkippedGroups: function () {
|
|
return this._getSkippedLibrariesByPrefix("G");
|
|
},
|
|
|
|
|
|
_getSkippedLibrariesByPrefix: function (prefix) {
|
|
var pref = 'sync.librariesToSkip';
|
|
try {
|
|
var librariesToSkip = JSON.parse(Zotero.Prefs.get(pref) || '[]');
|
|
return librariesToSkip
|
|
.filter(id => id.startsWith(prefix))
|
|
.map(id => parseInt(id.substr(1)));
|
|
}
|
|
catch (e) {
|
|
Zotero.logError(e);
|
|
Zotero.Prefs.clear(pref);
|
|
return [];
|
|
}
|
|
},
|
|
|
|
|
|
/**
|
|
* @param {Zotero.Library[]} libraries
|
|
* @return {Zotero.Library[]}
|
|
*/
|
|
filterSkippedLibraries: function (libraries) {
|
|
var skippedLibraries = this.getSkippedLibraries();
|
|
var skippedGroups = this.getSkippedGroups();
|
|
|
|
return libraries.filter((library) => {
|
|
var libraryType = library.libraryType;
|
|
if (libraryType == 'group') {
|
|
return !skippedGroups.includes(library.groupID);
|
|
}
|
|
return !skippedLibraries.includes(library.libraryID);
|
|
});
|
|
},
|
|
|
|
|
|
/**
|
|
* @return {nsILoginInfo|false}
|
|
*/
|
|
_getAPIKeyLoginInfo: function () {
|
|
try {
|
|
var logins = Services.logins.findLogins(
|
|
{},
|
|
this._loginManagerHost,
|
|
null,
|
|
this._loginManagerRealm
|
|
);
|
|
}
|
|
catch (e) {
|
|
Zotero.logError(e);
|
|
if (this._lastLoginManagerErrorTime > Date.now() - 60000) {
|
|
let msg = Zotero.getString('sync.error.loginManagerCorrupted1', Zotero.appName) + "\n\n"
|
|
+ Zotero.getString('sync.error.loginManagerCorrupted2', Zotero.appName);
|
|
Zotero.alert(null, Zotero.getString('general.error'), msg);
|
|
this._lastLoginManagerErrorTime = Date.now();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Get API from returned array of nsILoginInfo objects
|
|
return logins.length ? logins[0] : false;
|
|
},
|
|
|
|
|
|
_getAPIKeyFromLogin: Zotero.Promise.coroutine(function* () {
|
|
let username = Zotero.Prefs.get('sync.server.username');
|
|
if (username) {
|
|
// Check for legacy password if no password set in current session
|
|
// and no API keys stored yet
|
|
let password = this.getLegacyPassword(username);
|
|
if (!password) {
|
|
return "";
|
|
}
|
|
|
|
let json = yield Zotero.Sync.Runner.createAPIKeyFromCredentials(username, password);
|
|
this.removeLegacyLogins();
|
|
return json.key;
|
|
}
|
|
return "";
|
|
}),
|
|
|
|
|
|
getLegacyPassword: function (username) {
|
|
var loginManagerHost = 'chrome://zotero';
|
|
var loginManagerRealm = 'Zotero Sync Server';
|
|
|
|
Zotero.debug('Getting Zotero sync password');
|
|
|
|
var loginManager = Components.classes["@mozilla.org/login-manager;1"]
|
|
.getService(Components.interfaces.nsILoginManager);
|
|
try {
|
|
var logins = loginManager.findLogins({}, loginManagerHost, null, loginManagerRealm);
|
|
}
|
|
catch (e) {
|
|
Zotero.logError(e);
|
|
return '';
|
|
}
|
|
|
|
// Find user from returned array of nsILoginInfo objects
|
|
for (let i = 0; i < logins.length; i++) {
|
|
if (logins[i].username == username) {
|
|
return logins[i].password;
|
|
}
|
|
}
|
|
|
|
// Pre-4.0.28.5 format, broken for findLogins and removeLogin in Fx41,
|
|
var logins = loginManager.findLogins({}, loginManagerHost, "", null);
|
|
for (let i = 0; i < logins.length; i++) {
|
|
if (logins[i].username == username
|
|
&& logins[i].formSubmitURL == "Zotero Sync Server") {
|
|
return logins[i].password;
|
|
}
|
|
}
|
|
return '';
|
|
},
|
|
|
|
|
|
removeLegacyLogins: function () {
|
|
var loginManagerHost = 'chrome://zotero';
|
|
var loginManagerRealm = 'Zotero Sync Server';
|
|
|
|
Zotero.debug('Removing legacy Zotero sync credentials (api key acquired)');
|
|
|
|
var loginManager = Components.classes["@mozilla.org/login-manager;1"]
|
|
.getService(Components.interfaces.nsILoginManager);
|
|
try {
|
|
var logins = loginManager.findLogins({}, loginManagerHost, null, loginManagerRealm);
|
|
}
|
|
catch (e) {
|
|
Zotero.logError(e);
|
|
return '';
|
|
}
|
|
|
|
// Remove all legacy users
|
|
for (let login of logins) {
|
|
loginManager.removeLogin(login);
|
|
}
|
|
// Remove the legacy pref
|
|
Zotero.Prefs.clear('sync.server.username');
|
|
},
|
|
|
|
|
|
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 {String} objectType
|
|
* @param {Integer} libraryID
|
|
* @return {Promise<String[]>} - A promise for an array of object keys
|
|
*/
|
|
getSynced: function (objectType, libraryID) {
|
|
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 {String} objectType
|
|
* @param {Integer} libraryID
|
|
* @return {Promise<Integer[]>} - A promise for an array of object ids
|
|
*/
|
|
getUnsynced: Zotero.Promise.coroutine(function* (objectType, libraryID) {
|
|
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
|
|
var sql = "SELECT O." + objectsClass.idColumn + " FROM " + objectsClass.table + " O";
|
|
if (objectType == 'item') {
|
|
sql += " LEFT JOIN itemAttachments IA USING (itemID) "
|
|
+ "LEFT JOIN itemNotes INo ON (O.itemID=INo.itemID) "
|
|
+ "LEFT JOIN itemAnnotations IAn ON (O.itemID=IAn.itemID)";
|
|
}
|
|
sql += " WHERE libraryID=? AND synced=0";
|
|
// Don't sync external annotations
|
|
if (objectType == 'item') {
|
|
sql += " AND (IAn.isExternal IS NULL OR IAN.isExternal=0)";
|
|
}
|
|
|
|
var ids = yield Zotero.DB.columnQueryAsync(sql, [libraryID]);
|
|
|
|
// Sort descendent collections last
|
|
if (objectType == 'collection') {
|
|
try {
|
|
ids = Zotero.Collections.sortByLevel(ids.map(id => Zotero.Collections.get(id))).map(o => o.id);
|
|
}
|
|
catch (e) {
|
|
Zotero.logError(e);
|
|
// If collections were incorrectly nested, fix and try again
|
|
if (e instanceof Zotero.Error && e.error == Zotero.Error.ERROR_INVALID_OBJECT_NESTING) {
|
|
let c = Zotero.Collections.get(e.collectionID);
|
|
Zotero.debug(`Removing parent collection ${c.parentKey} from collection ${c.key}`);
|
|
c.parentID = null;
|
|
yield c.saveTx();
|
|
return this.getUnsynced(...arguments);
|
|
}
|
|
else {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
else if (objectType == 'item') {
|
|
ids = Zotero.Items.sortByParent(ids.map(id => Zotero.Items.get(id))).map(o => o.id);
|
|
}
|
|
|
|
return ids;
|
|
}),
|
|
|
|
|
|
isSyncItem: function (item) {
|
|
if (item.itemType == 'annotation' && item.annotationIsExternal) {
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
|
|
//
|
|
// 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* (objectType, libraryID, keys=[]) {
|
|
var versions = {};
|
|
|
|
yield Zotero.Utilities.Internal.forEachChunkAsync(
|
|
keys,
|
|
Zotero.DB.MAX_BOUND_PARAMETERS - 2,
|
|
Zotero.Promise.coroutine(function* (chunk) {
|
|
// 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 key, MAX(version) AS version FROM syncCache "
|
|
+ "WHERE libraryID=? AND "
|
|
+ "syncObjectTypeID IN (SELECT syncObjectTypeID FROM syncObjectTypes WHERE name=?) ";
|
|
var params = [libraryID, objectType]
|
|
if (chunk.length) {
|
|
sql += "AND key IN (" + chunk.map(key => '?').join(', ') + ") ";
|
|
params = params.concat(chunk);
|
|
}
|
|
sql += "GROUP BY libraryID, key";
|
|
var rows = yield Zotero.DB.queryAsync(sql, params);
|
|
|
|
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) {
|
|
try {
|
|
return JSON.parse(data);
|
|
}
|
|
// Shouldn't happen, but don't break syncing if it does
|
|
// https://forums.zotero.org/discussion/95926/zotero-not-syncing-report-id-1924846177
|
|
catch (e) {
|
|
Zotero.logError(e);
|
|
}
|
|
}
|
|
return false;
|
|
}),
|
|
|
|
|
|
getCacheObjects: Zotero.Promise.coroutine(function* (objectType, libraryID, keyVersionPairs) {
|
|
if (!keyVersionPairs.length) return [];
|
|
var rows = [];
|
|
yield Zotero.Utilities.Internal.forEachChunkAsync(
|
|
keyVersionPairs,
|
|
240, // SQLITE_MAX_COMPOUND_SELECT defaults to 500
|
|
async function (chunk) {
|
|
var sql = "SELECT data FROM syncCache SC JOIN (SELECT "
|
|
+ chunk.map((pair) => {
|
|
Zotero.DataObjectUtilities.checkKey(pair[0]);
|
|
return "'" + pair[0] + "' AS key, " + parseInt(pair[1]) + " AS version";
|
|
}).join(" UNION SELECT ")
|
|
+ ") AS pairs ON (pairs.key=SC.key AND pairs.version=SC.version) "
|
|
+ "WHERE libraryID=? AND "
|
|
+ "syncObjectTypeID IN (SELECT syncObjectTypeID FROM syncObjectTypes WHERE name=?)";
|
|
rows.push(...await Zotero.DB.columnQueryAsync(sql, [libraryID, objectType]));
|
|
}
|
|
)
|
|
return rows.map(row => JSON.parse(row));
|
|
}),
|
|
|
|
|
|
saveCacheObject: Zotero.Promise.coroutine(function* (objectType, libraryID, json) {
|
|
json = this._checkCacheJSON(json);
|
|
|
|
Zotero.debug("Saving to sync cache:");
|
|
Zotero.debug(json);
|
|
|
|
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
|
|
var sql = "INSERT OR REPLACE INTO syncCache "
|
|
+ "(libraryID, key, syncObjectTypeID, version, data) VALUES (?, ?, ?, ?, ?)";
|
|
var params = [libraryID, json.key, syncObjectTypeID, json.version, JSON.stringify(json)];
|
|
return Zotero.DB.queryAsync(sql, params);
|
|
}),
|
|
|
|
|
|
saveCacheObjects: Zotero.Promise.coroutine(function* (objectType, libraryID, jsonArray) {
|
|
if (!Array.isArray(jsonArray)) {
|
|
throw new Error("'json' must be an array");
|
|
}
|
|
|
|
if (!jsonArray.length) {
|
|
Zotero.debug("No " + Zotero.DataObjectUtilities.getObjectTypePlural(objectType)
|
|
+ " to save to sync cache");
|
|
return;
|
|
}
|
|
|
|
jsonArray = jsonArray.map(json => this._checkCacheJSON(json));
|
|
|
|
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.Utilities.Internal.forEachChunkAsync(
|
|
jsonArray,
|
|
chunkSize,
|
|
Zotero.Promise.coroutine(function* (chunk) {
|
|
var params = [];
|
|
for (let i = 0; i < chunk.length; i++) {
|
|
let o = chunk[i];
|
|
params.push(libraryID, o.key, syncObjectTypeID, o.version, JSON.stringify(o));
|
|
}
|
|
return Zotero.DB.queryAsync(
|
|
sql + chunk.map(() => "(?, ?, ?, ?, ?)").join(", "), params
|
|
);
|
|
})
|
|
);
|
|
}),
|
|
|
|
|
|
/**
|
|
* Process downloaded JSON and update local objects
|
|
*
|
|
* @return {Promise<Object[]>} - Promise for an array of objects with the following properties:
|
|
* {String} key
|
|
* {Boolean} processed
|
|
* {Object} [error]
|
|
* {Boolean} [retry]
|
|
* {Boolean} [restored=false] - Locally deleted object was added back
|
|
* {Boolean} [conflict=false]
|
|
* {Object} [left] - Local JSON data for conflict (or .deleted and .dateDeleted)
|
|
* {Object} [right] - Remote JSON data for conflict
|
|
* {Object[]} [changes] - An array of operations to apply locally to resolve conflicts,
|
|
* as returned by _reconcileChanges()
|
|
* {Object[]} [conflicts] - An array of conflicting fields that can't be resolved automatically
|
|
*/
|
|
processObjectsFromJSON: Zotero.Promise.coroutine(function* (objectType, libraryID, json, options = {}) {
|
|
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
|
|
var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
|
|
var ObjectType = Zotero.Utilities.capitalize(objectType);
|
|
var libraryName = Zotero.Libraries.get(libraryID).name;
|
|
|
|
var knownErrors = new Set([
|
|
'ZoteroInvalidDataError',
|
|
'ZoteroMissingObjectError'
|
|
]);
|
|
|
|
Zotero.debug("Processing " + json.length + " downloaded "
|
|
+ (json.length == 1 ? objectType : objectTypePlural)
|
|
+ " for " + libraryName);
|
|
|
|
var results = [];
|
|
|
|
if (!json.length) {
|
|
return results;
|
|
}
|
|
|
|
json = json.map(o => this._checkCacheJSON(o));
|
|
|
|
if (options.setStatus) {
|
|
options.setStatus("Downloading " + objectTypePlural + " in " + libraryName); // 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);
|
|
json.sort(function (a, b) {
|
|
if (a[parentProp] && !b[parentProp]) return 1;
|
|
if (b[parentProp] && !a[parentProp]) return -1;
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
var batchSize = options.getNotifierBatchSize ? options.getNotifierBatchSize() : json.length;
|
|
var notifierQueues = [];
|
|
|
|
try {
|
|
for (let i = 0; i < json.length; i++) {
|
|
// Batch notifier updates
|
|
if (notifierQueues.length == batchSize) {
|
|
yield Zotero.Notifier.commit(notifierQueues);
|
|
notifierQueues = [];
|
|
// Get the current batch size, which might have increased
|
|
if (options.getNotifierBatchSize) {
|
|
batchSize = options.getNotifierBatchSize()
|
|
}
|
|
}
|
|
let notifierQueue = new Zotero.Notifier.Queue({
|
|
skipAutoSync: true
|
|
});
|
|
|
|
let jsonObject = json[i];
|
|
let jsonData = jsonObject.data;
|
|
let objectKey = jsonObject.key;
|
|
|
|
let saveOptions = {};
|
|
Object.assign(saveOptions, options);
|
|
saveOptions.isNewObject = false;
|
|
saveOptions.skipCache = false;
|
|
saveOptions.storageDetailsChanged = false;
|
|
saveOptions.notifierQueue = notifierQueue;
|
|
|
|
Zotero.debug(`Processing ${objectType} ${libraryID}/${objectKey}`);
|
|
Zotero.debug(jsonObject);
|
|
|
|
// Skip objects with unmet dependencies
|
|
if (objectType == 'item' || objectType == 'collection') {
|
|
// Missing parent collection or item
|
|
let parentProp = 'parent' + objectType[0].toUpperCase() + objectType.substr(1);
|
|
let parentKey = jsonData[parentProp];
|
|
if (parentKey) {
|
|
let parentObj = yield objectsClass.getByLibraryAndKeyAsync(
|
|
libraryID, parentKey, { noCache: true }
|
|
);
|
|
if (!parentObj) {
|
|
let error = new Error("Parent of " + objectType + " "
|
|
+ libraryID + "/" + jsonData.key + " not found -- skipping");
|
|
error.name = "ZoteroMissingObjectError";
|
|
Zotero.debug(error.message);
|
|
results.push({
|
|
key: objectKey,
|
|
processed: false,
|
|
error,
|
|
retry: true
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Missing collection -- this could happen if the collection was deleted
|
|
// locally and an item in it was modified remotely
|
|
if (objectType == 'item' && jsonData.collections) {
|
|
let error;
|
|
for (let key of jsonData.collections) {
|
|
let collection = Zotero.Collections.getByLibraryAndKey(libraryID, key);
|
|
if (!collection) {
|
|
error = new Error(`Collection ${libraryID}/${key} not found `
|
|
+ `-- skipping item`);
|
|
error.name = "ZoteroMissingObjectError";
|
|
Zotero.debug(error.message);
|
|
results.push({
|
|
key: objectKey,
|
|
processed: false,
|
|
error,
|
|
retry: false
|
|
});
|
|
|
|
// If the collection is in the delete log, the deletion will upload
|
|
// after downloads are done. Otherwise, we somehow missed
|
|
// downloading it and should add it to the queue to try again.
|
|
if (!(yield this.getDateDeleted('collection', libraryID, key))) {
|
|
yield this.addObjectsToSyncQueue('collection', libraryID, [key]);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if (error) {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Errors have to be thrown in order to roll back the transaction, so catch those here
|
|
// and continue
|
|
try {
|
|
yield Zotero.DB.executeTransaction(async function () {
|
|
let obj = await objectsClass.getByLibraryAndKeyAsync(
|
|
libraryID, objectKey, { noCache: true }
|
|
);
|
|
let restored = false;
|
|
if (obj) {
|
|
Zotero.debug("Matching local " + objectType + " exists", 4);
|
|
|
|
let jsonDataLocal = obj.toJSON();
|
|
|
|
// For items, check if mtime or file hash changed in metadata,
|
|
// which would indicate that a remote storage sync took place and
|
|
// a download is needed
|
|
if (objectType == 'item' && obj.isStoredFileAttachment()) {
|
|
if (jsonDataLocal.mtime != jsonData.mtime
|
|
|| jsonDataLocal.md5 != jsonData.md5) {
|
|
saveOptions.storageDetailsChanged = true;
|
|
}
|
|
}
|
|
|
|
// Local object has been modified since last sync
|
|
if (!obj.synced) {
|
|
Zotero.debug("Local " + objectType + " " + obj.libraryKey
|
|
+ " has been modified since last sync", 4);
|
|
|
|
let cachedJSON = await this.getCacheObject(
|
|
objectType, obj.libraryID, obj.key, obj.version
|
|
);
|
|
let result = this._reconcileChanges(
|
|
objectType,
|
|
cachedJSON.data,
|
|
jsonDataLocal,
|
|
jsonData,
|
|
['mtime', 'md5', 'dateAdded', 'dateModified']
|
|
);
|
|
|
|
// If no changes, just update local version number and mark as synced
|
|
if (!result.changes.length && !result.conflicts.length) {
|
|
Zotero.debug("No remote changes to apply to local "
|
|
+ objectType + " " + obj.libraryKey);
|
|
saveOptions.skipData = true;
|
|
// If either there were additional local changes after cancelling
|
|
// out equivalent changes on both sides or the local object was
|
|
// different but we ignored the changes (e.g., ISBN hyphenation),
|
|
// keep as unsynced. In the latter case, since we're skipping
|
|
// data, the local fields won't be overwritten.
|
|
if (result.localChanged) {
|
|
saveOptions.saveAsUnsynced = true;
|
|
}
|
|
let saveResults = await this._saveObjectFromJSON(
|
|
obj,
|
|
jsonObject,
|
|
saveOptions
|
|
);
|
|
results.push(saveResults);
|
|
if (!saveResults.processed) {
|
|
throw saveResults.error;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (result.conflicts.length) {
|
|
if (objectType != 'item') {
|
|
throw new Error(`Unexpected conflict on ${objectType} object`);
|
|
}
|
|
|
|
// Skip conflict resolution if there are invalid fields
|
|
try {
|
|
let testObj = obj.clone();
|
|
testObj.fromJSON(jsonData, { strict: true });
|
|
}
|
|
catch (e) {
|
|
results.push({
|
|
key: objectKey,
|
|
processed: false,
|
|
error: e,
|
|
retry: false
|
|
});
|
|
throw e;
|
|
}
|
|
|
|
Zotero.debug("Conflict!", 2);
|
|
Zotero.debug(jsonDataLocal);
|
|
Zotero.debug(jsonData);
|
|
Zotero.debug(result);
|
|
results.push({
|
|
libraryID,
|
|
key: objectKey,
|
|
processed: false,
|
|
conflict: true,
|
|
left: jsonDataLocal,
|
|
right: jsonData,
|
|
changes: result.changes,
|
|
conflicts: result.conflicts
|
|
});
|
|
return;
|
|
}
|
|
|
|
// If no conflicts, apply remote changes automatically
|
|
Zotero.debug(`Applying remote changes to ${objectType} `
|
|
+ obj.libraryKey);
|
|
Zotero.debug(result.changes);
|
|
// If there were local changes as well, keep object as unsynced and
|
|
// save the remote version to the sync cache rather than the merged
|
|
// version
|
|
if (result.localChanged) {
|
|
saveOptions.saveAsUnsynced = true;
|
|
saveOptions.cacheObject = jsonObject.data;
|
|
}
|
|
Zotero.DataObjectUtilities.applyChanges(
|
|
jsonDataLocal, result.changes
|
|
);
|
|
// Transfer properties that aren't in the changeset
|
|
['version', 'dateAdded', 'dateModified'].forEach(x => {
|
|
if (jsonData[x] === undefined) return;
|
|
if (jsonDataLocal[x] !== jsonData[x]) {
|
|
Zotero.debug(`Applying remote '${x}' value`);
|
|
}
|
|
jsonDataLocal[x] = jsonData[x];
|
|
})
|
|
jsonObject.data = jsonDataLocal;
|
|
}
|
|
}
|
|
// Object doesn't exist locally
|
|
else {
|
|
Zotero.debug(ObjectType + " doesn't exist locally");
|
|
|
|
saveOptions.isNewObject = true;
|
|
|
|
// Check if object has been deleted locally
|
|
let dateDeleted = await this.getDateDeleted(
|
|
objectType, libraryID, objectKey
|
|
);
|
|
if (dateDeleted) {
|
|
Zotero.debug(ObjectType + " was deleted locally");
|
|
|
|
switch (objectType) {
|
|
case 'item':
|
|
if (jsonData.deleted) {
|
|
Zotero.debug("Remote item is in trash -- allowing local deletion to propagate");
|
|
results.push({
|
|
libraryID,
|
|
key: objectKey,
|
|
processed: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
results.push({
|
|
libraryID,
|
|
key: objectKey,
|
|
processed: false,
|
|
conflict: true,
|
|
left: {
|
|
deleted: true,
|
|
dateDeleted: Zotero.Date.dateToSQL(dateDeleted, true)
|
|
},
|
|
right: jsonData
|
|
});
|
|
return;
|
|
|
|
// Auto-restore some locally deleted objects that have changed remotely
|
|
case 'collection':
|
|
case 'search':
|
|
Zotero.debug(`${ObjectType} ${objectKey} was modified remotely `
|
|
+ '-- restoring');
|
|
await this.removeObjectsFromDeleteLog(
|
|
objectType,
|
|
libraryID,
|
|
[objectKey]
|
|
);
|
|
restored = true;
|
|
break;
|
|
|
|
default:
|
|
throw new Error("Unknown object type '" + objectType + "'");
|
|
}
|
|
}
|
|
|
|
// Create new object
|
|
obj = new Zotero[ObjectType];
|
|
obj.libraryID = libraryID;
|
|
obj.key = objectKey;
|
|
await obj.loadPrimaryData();
|
|
|
|
// Don't cache new items immediately, which skips reloading after save
|
|
saveOptions.skipCache = true;
|
|
}
|
|
|
|
let saveResults = await this._saveObjectFromJSON(obj, jsonObject, saveOptions);
|
|
if (restored) {
|
|
saveResults.restored = true;
|
|
}
|
|
results.push(saveResults);
|
|
if (!saveResults.processed) {
|
|
throw saveResults.error;
|
|
}
|
|
}.bind(this));
|
|
|
|
if (notifierQueue.size) {
|
|
notifierQueues.push(notifierQueue);
|
|
}
|
|
}
|
|
catch (e) {
|
|
// This allows errors handled by syncRunner to know the library in question
|
|
e.libraryID = libraryID;
|
|
|
|
// Display nicer debug line for known errors
|
|
if (knownErrors.has(e.name)) {
|
|
let desc = e.name
|
|
.replace(/^Zotero/, "")
|
|
// Convert "MissingObjectError" to "missing object error"
|
|
.split(/([a-z]+)/).join(' ').trim()
|
|
.replace(/([A-Z]) ([a-z]+)/g, "$1$2").toLowerCase();
|
|
let msg = Zotero.Utilities.capitalize(desc) + " for "
|
|
+ `${objectType} ${jsonObject.key} in ${Zotero.Libraries.get(libraryID).name}`;
|
|
Zotero.debug(msg, 2);
|
|
Zotero.debug(e, 2);
|
|
Components.utils.reportError(msg + ": " + e.message);
|
|
}
|
|
else {
|
|
Zotero.logError(e);
|
|
}
|
|
|
|
if (options.onError) {
|
|
options.onError(e);
|
|
}
|
|
|
|
if (Zotero.DB.closed) {
|
|
e.fatal = true;
|
|
}
|
|
if (options.stopOnError || e.fatal) {
|
|
throw e;
|
|
}
|
|
}
|
|
finally {
|
|
if (options.onObjectProcessed) {
|
|
options.onObjectProcessed();
|
|
}
|
|
}
|
|
|
|
yield Zotero.Promise.delay(10);
|
|
}
|
|
}
|
|
finally {
|
|
if (notifierQueues.length) {
|
|
yield Zotero.Notifier.commit(notifierQueues);
|
|
}
|
|
}
|
|
|
|
let processed = 0;
|
|
let skipped = 0;
|
|
results.forEach(x => x.processed ? processed++ : skipped++);
|
|
|
|
Zotero.debug(`Processed ${processed} `
|
|
+ (processed == 1 ? objectType : objectTypePlural)
|
|
+ (skipped ? ` and skipped ${skipped}` : "")
|
|
+ " in " + libraryName);
|
|
|
|
return results;
|
|
}),
|
|
|
|
|
|
_checkCacheJSON: function (json) {
|
|
if (json.key === undefined) {
|
|
Zotero.debug(json, 1);
|
|
throw new Error("Missing 'key' property in JSON");
|
|
}
|
|
if (json.version === undefined) {
|
|
Zotero.debug(json, 1);
|
|
throw new Error("Missing 'version' property in JSON");
|
|
}
|
|
if (json.version === 0) {
|
|
Zotero.debug(json, 1);
|
|
// TODO: Fix tests so this doesn't happen
|
|
Zotero.warn("'version' cannot be 0 in cache JSON");
|
|
//throw new Error("'version' cannot be 0 in cache JSON");
|
|
}
|
|
// If direct data object passed, wrap in fake response object
|
|
return json.data === undefined ? {
|
|
key: json.key,
|
|
version: json.version,
|
|
data: json
|
|
} : json;
|
|
},
|
|
|
|
|
|
/**
|
|
* Check whether an attachment's file mod time matches the given mod time, and mark the file
|
|
* for download if not (or if this is a new attachment)
|
|
*/
|
|
_checkAttachmentForDownload: async function (item, mtime, isNewObject) {
|
|
var markToDownload = true;
|
|
var fileExists = false;
|
|
if (!isNewObject) {
|
|
// Convert previously used Unix timestamps to ms-based timestamps
|
|
if (mtime < 10000000000) {
|
|
Zotero.debug("Converting Unix timestamp '" + mtime + "' to ms");
|
|
mtime = mtime * 1000;
|
|
}
|
|
var fmtime = null;
|
|
try {
|
|
fmtime = await item.attachmentModificationTime;
|
|
}
|
|
catch (e) {
|
|
// This will probably fail later too, but ignore it for now
|
|
Zotero.logError(e);
|
|
}
|
|
if (fmtime) {
|
|
let state = Zotero.Sync.Storage.Local.checkFileModTime(item, fmtime, mtime);
|
|
if (state === false) {
|
|
markToDownload = false;
|
|
}
|
|
fileExists = true;
|
|
}
|
|
}
|
|
if (markToDownload) {
|
|
// If file already exists locally, download it even in "as needed" mode. While we could
|
|
// just check whether a download is necessary at file open, these are files that people
|
|
// have previously downloaded, and avoiding opening an outdated version seems more
|
|
// important than avoiding a little bit of extra data transfer.
|
|
item.attachmentSyncState = fileExists ? "force_download" : "to_download";
|
|
}
|
|
},
|
|
|
|
|
|
/**
|
|
* Delete one or more versions of an object from the sync cache
|
|
*
|
|
* @param {String} objectType
|
|
* @param {Integer} libraryID
|
|
* @param {String} key
|
|
* @param {Integer} [minVersion]
|
|
* @param {Integer} [maxVersion]
|
|
*/
|
|
deleteCacheObjectVersions: function (objectType, libraryID, key, minVersion, maxVersion) {
|
|
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
|
|
var sql = "DELETE FROM syncCache WHERE libraryID=? AND key=? AND syncObjectTypeID=?";
|
|
var params = [libraryID, key, syncObjectTypeID];
|
|
if (minVersion && minVersion == maxVersion) {
|
|
sql += " AND version=?";
|
|
params.push(minVersion);
|
|
}
|
|
else {
|
|
if (minVersion) {
|
|
sql += " AND version>=?";
|
|
params.push(minVersion);
|
|
}
|
|
if (maxVersion || maxVersion === 0) {
|
|
sql += " AND version<=?";
|
|
params.push(maxVersion);
|
|
}
|
|
}
|
|
return Zotero.DB.queryAsync(sql, params);
|
|
},
|
|
|
|
|
|
/**
|
|
* Delete entries from sync cache that don't exist or are less than the current object version
|
|
*/
|
|
purgeCache: Zotero.Promise.coroutine(function* (objectType, libraryID) {
|
|
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
|
|
var table = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType).table;
|
|
var sql = "DELETE FROM syncCache WHERE ROWID IN ("
|
|
+ "SELECT SC.ROWID FROM syncCache SC "
|
|
+ `LEFT JOIN ${table} O USING (libraryID, key, version) `
|
|
+ "WHERE syncObjectTypeID=? AND SC.libraryID=? AND "
|
|
+ "(O.libraryID IS NULL OR SC.version < O.version))";
|
|
yield Zotero.DB.queryAsync(sql, [syncObjectTypeID, libraryID]);
|
|
}),
|
|
|
|
|
|
clearCacheForLibrary: async function (libraryID) {
|
|
await Zotero.DB.queryAsync("DELETE FROM syncCache WHERE libraryID=?", libraryID);
|
|
},
|
|
|
|
|
|
processConflicts: Zotero.Promise.coroutine(function* (objectType, libraryID, conflicts, options = {}) {
|
|
if (!conflicts.length) return [];
|
|
|
|
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
|
|
var ObjectType = Zotero.Utilities.capitalize(objectType);
|
|
|
|
// Sort conflicts by local Date Modified/Deleted
|
|
conflicts.sort(function (a, b) {
|
|
var d1 = a.left.dateDeleted || a.left.dateModified;
|
|
var d2 = b.left.dateDeleted || b.left.dateModified;
|
|
if (d1 > d2) {
|
|
return 1
|
|
}
|
|
if (d1 < d2) {
|
|
return -1;
|
|
}
|
|
return 0;
|
|
})
|
|
|
|
var results = [];
|
|
|
|
var mergeData = this.showConflictResolutionWindow(conflicts);
|
|
if (!mergeData) {
|
|
Zotero.debug("Conflict resolution was cancelled", 2);
|
|
for (let conflict of conflicts) {
|
|
results.push({
|
|
// Use key from either, in case one side is deleted
|
|
key: conflict.left.key || conflict.right.key,
|
|
processed: false,
|
|
retry: false
|
|
});
|
|
}
|
|
return results;
|
|
}
|
|
|
|
Zotero.debug("Processing resolved conflicts");
|
|
|
|
let batchSize = mergeData.length;
|
|
let notifierQueues = [];
|
|
try {
|
|
for (let i = 0; i < mergeData.length; i++) {
|
|
// Batch notifier updates, despite multiple transactions
|
|
if (notifierQueues.length == batchSize) {
|
|
yield Zotero.Notifier.commit(notifierQueues);
|
|
notifierQueues = [];
|
|
}
|
|
let notifierQueue = new Zotero.Notifier.Queue;
|
|
|
|
let json = mergeData[i].data;
|
|
|
|
let saveOptions = {};
|
|
Object.assign(saveOptions, options);
|
|
// If choosing local object, save as unsynced with remote version (or 0 if remote is
|
|
// deleted) and remote object in cache, to simulate a save and edit
|
|
if (mergeData[i].selected == 'left') {
|
|
json.version = conflicts[i].right.version || 0;
|
|
saveOptions.saveAsUnsynced = true;
|
|
if (conflicts[i].right.version) {
|
|
saveOptions.cacheObject = conflicts[i].right;
|
|
}
|
|
}
|
|
saveOptions.notifierQueue = notifierQueue;
|
|
|
|
// Errors have to be thrown in order to roll back the transaction, so catch
|
|
// those here and continue
|
|
try {
|
|
yield Zotero.DB.executeTransaction(async function () {
|
|
let obj = await objectsClass.getByLibraryAndKeyAsync(
|
|
libraryID, json.key, { noCache: true }
|
|
);
|
|
// Update object with merge data
|
|
if (obj) {
|
|
// Delete local object
|
|
if (json.deleted) {
|
|
try {
|
|
await obj.erase({
|
|
notifierQueue
|
|
});
|
|
}
|
|
catch (e) {
|
|
results.push({
|
|
key: json.key,
|
|
processed: false,
|
|
error: e,
|
|
retry: false
|
|
});
|
|
throw e;
|
|
}
|
|
results.push({
|
|
key: json.key,
|
|
processed: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Save merged changes below
|
|
}
|
|
// If no local object and merge wanted a delete, we're good
|
|
else if (json.deleted) {
|
|
results.push({
|
|
key: json.key,
|
|
processed: true
|
|
});
|
|
return;
|
|
}
|
|
// Recreate locally deleted object
|
|
else {
|
|
obj = new Zotero[ObjectType];
|
|
obj.libraryID = libraryID;
|
|
obj.key = json.key;
|
|
await obj.loadPrimaryData();
|
|
|
|
// Don't cache new items immediately,
|
|
// which skips reloading after save
|
|
saveOptions.skipCache = true;
|
|
}
|
|
|
|
let saveResults = await this._saveObjectFromJSON(obj, json, saveOptions);
|
|
results.push(saveResults);
|
|
if (!saveResults.processed) {
|
|
throw saveResults.error;
|
|
}
|
|
}.bind(this));
|
|
|
|
if (notifierQueue.size) {
|
|
notifierQueues.push(notifierQueue);
|
|
}
|
|
}
|
|
catch (e) {
|
|
Zotero.logError(e);
|
|
|
|
if (options.onError) {
|
|
options.onError(e);
|
|
}
|
|
|
|
if (options.stopOnError) {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
finally {
|
|
if (notifierQueues.length) {
|
|
yield Zotero.Notifier.commit(notifierQueues);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}),
|
|
|
|
|
|
showConflictResolutionWindow: function (conflicts) {
|
|
Zotero.debug("Showing conflict resolution window");
|
|
Zotero.debug(conflicts);
|
|
|
|
var io = {
|
|
dataIn: {
|
|
captions: [
|
|
Zotero.getString('sync.conflict.localItem'),
|
|
Zotero.getString('sync.conflict.remoteItem'),
|
|
Zotero.getString('sync.conflict.mergedItem')
|
|
],
|
|
conflicts
|
|
}
|
|
};
|
|
var url = 'chrome://zotero/content/merge.xul';
|
|
var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
|
|
.getService(Components.interfaces.nsIWindowMediator);
|
|
var lastWin = wm.getMostRecentWindow("navigator:browser");
|
|
if (lastWin) {
|
|
lastWin.openDialog(url, '', 'chrome,modal,centerscreen', io);
|
|
}
|
|
else {
|
|
// When using nsIWindowWatcher, the object has to be wrapped here
|
|
// https://developer.mozilla.org/en-US/docs/Working_with_windows_in_chrome_code#Example_5_Using_nsIWindowWatcher_for_passing_an_arbritrary_JavaScript_object
|
|
io.wrappedJSObject = io;
|
|
let ww = Components.classes["@mozilla.org/embedcomp/window-watcher;1"]
|
|
.getService(Components.interfaces.nsIWindowWatcher);
|
|
ww.openWindow(null, url, '', 'chrome,modal,centerscreen,dialog', io);
|
|
}
|
|
if (io.error) {
|
|
throw io.error;
|
|
}
|
|
return io.dataOut;
|
|
},
|
|
|
|
|
|
//
|
|
// 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) {
|
|
var results = {};
|
|
try {
|
|
results.key = json.key;
|
|
json = this._checkCacheJSON(json);
|
|
|
|
if (!options.skipData) {
|
|
obj.fromJSON(json.data, { strict: true });
|
|
}
|
|
if (obj.objectType == 'item') {
|
|
// Update createdByUserID and lastModifiedByUserID
|
|
for (let p of ['createdByUser', 'lastModifiedByUser']) {
|
|
if (json.meta && json.meta[p]) {
|
|
let { id: userID, username, name } = json.meta[p];
|
|
obj[p + 'ID'] = userID;
|
|
name = name !== '' ? name : username;
|
|
// Update stored name if it changed
|
|
if (Zotero.Users.getName(userID) != name) {
|
|
yield Zotero.Users.setName(userID, name);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (obj.isStoredFileAttachment()) {
|
|
yield this._checkAttachmentForDownload(obj, json.data.mtime, options.isNewObject);
|
|
}
|
|
}
|
|
obj.version = json.data.version;
|
|
if (!options.saveAsUnsynced) {
|
|
obj.synced = true;
|
|
}
|
|
yield obj.save({
|
|
skipEditCheck: true,
|
|
skipDateModifiedUpdate: true,
|
|
skipSelect: true,
|
|
skipCache: options.skipCache || false,
|
|
notifierQueue: options.notifierQueue,
|
|
// Errors are logged elsewhere, so skip in DataObject.save()
|
|
errorHandler: function (e) {
|
|
return;
|
|
}
|
|
});
|
|
let cacheJSON = options.cacheObject ? options.cacheObject : json.data;
|
|
yield this.saveCacheObject(obj.objectType, obj.libraryID, cacheJSON);
|
|
// Delete older versions of the object in the cache
|
|
yield this.deleteCacheObjectVersions(
|
|
obj.objectType,
|
|
obj.libraryID,
|
|
json.key,
|
|
null,
|
|
cacheJSON.version - 1
|
|
);
|
|
results.processed = true;
|
|
|
|
// Delete from sync queue
|
|
yield this._removeObjectFromSyncQueue(obj.objectType, obj.libraryID, json.key);
|
|
|
|
// Mark updated attachments for download
|
|
if (obj.objectType == 'item' && obj.isStoredFileAttachment()) {
|
|
// If storage changes were made (attachment mtime or hash), mark
|
|
// library as requiring download
|
|
if (options.isNewObject || options.storageDetailsChanged) {
|
|
Zotero.Libraries.get(obj.libraryID).storageDownloadNeeded = true;
|
|
}
|
|
}
|
|
}
|
|
catch (e) {
|
|
// For now, allow sync to proceed after all errors
|
|
results.processed = false;
|
|
results.error = e;
|
|
results.retry = false;
|
|
}
|
|
return results;
|
|
}),
|
|
|
|
|
|
/**
|
|
* 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);
|
|
|
|
Zotero.debug("CHANGESET1");
|
|
Zotero.debug(changeset1);
|
|
Zotero.debug("CHANGESET2");
|
|
Zotero.debug(changeset2);
|
|
|
|
const isAutoMergeType = objectType != 'item';
|
|
var conflicts = [];
|
|
var matchedLocalChanges = new Set();
|
|
|
|
for (let i = 0; i < changeset1.length; i++) {
|
|
for (let j = 0; j < changeset2.length; j++) {
|
|
let c1 = changeset1[i];
|
|
// If we've removed all local changes, keep remaining remote changes
|
|
if (!c1) {
|
|
break;
|
|
}
|
|
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 '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);
|
|
// We're in the inner loop without an incrementor for i, so don't go
|
|
// below 0
|
|
if (i < 0) i = 0;
|
|
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
|
|
|
|
// Creators changed the same way on both sides
|
|
if (c1.field == 'creators' && c1.op == 'modify' && c2.op == 'modify') {
|
|
let creators1 = c1.value;
|
|
let creators2 = c2.value;
|
|
if (creators1.length == creators2.length
|
|
&& creators1.every((c, index) => Zotero.Creators.equals(c, creators2[index]))) {
|
|
matchedLocalChanges.add(i);
|
|
changeset2.splice(j--, 1);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Removed on both sides
|
|
if (c1.op == 'delete' && c2.op == 'delete') {
|
|
matchedLocalChanges.add(i);
|
|
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')) {
|
|
matchedLocalChanges.add(i);
|
|
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) {
|
|
matchedLocalChanges.add(i);
|
|
changeset2.splice(j--, 1);
|
|
continue;
|
|
}
|
|
|
|
// Automatically apply remote changes if both items are in trash and for non-items,
|
|
// even if in conflict
|
|
if ((objectType == 'item' && currentJSON.deleted && newJSON.deleted)
|
|
|| isAutoMergeType) {
|
|
continue;
|
|
}
|
|
|
|
// Conflict
|
|
matchedLocalChanges.add(i);
|
|
changeset2.splice(j--, 1);
|
|
conflicts.push([c1, c2]);
|
|
}
|
|
}
|
|
|
|
// If there were local changes that weren't made remotely as well, the object needs to be
|
|
// kept as unsynced
|
|
var localChanged = changeset1.length > matchedLocalChanges.size;
|
|
|
|
// If we're applying remote changes automatically, only consider the local object as changed
|
|
// if fields were changed that weren't changed remotely
|
|
if (isAutoMergeType && localChanged) {
|
|
let remoteFields = new Set(changeset2.map(x => x.field));
|
|
if (changeset1.every(x => remoteFields.has(x.field))) {
|
|
localChanged = false;
|
|
}
|
|
}
|
|
|
|
return {
|
|
changes: changeset2,
|
|
conflicts,
|
|
localChanged
|
|
};
|
|
},
|
|
|
|
|
|
/**
|
|
* 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 c2 = changeset[i];
|
|
|
|
// Member changes are additive only, so ignore removals
|
|
if (c2.op.endsWith('-remove')) {
|
|
continue;
|
|
}
|
|
|
|
// Record member changes
|
|
if (c2.op.startsWith('member-') || c2.op.startsWith('property-member-')) {
|
|
changes.push(c2);
|
|
continue;
|
|
}
|
|
|
|
// Automatically apply remote changes for non-items, even if in conflict
|
|
if ((objectType == 'item' && currentJSON.deleted && newJSON.deleted)
|
|
|| objectType != 'item') {
|
|
changes.push(c2);
|
|
continue;
|
|
}
|
|
|
|
// Field changes are conflicts
|
|
//
|
|
// Since we don't know what changed, use only 'add' and 'delete'
|
|
if (c2.op == 'modify') {
|
|
c2.op = 'add';
|
|
}
|
|
let val = currentJSON[c2.field];
|
|
let c1 = {
|
|
field: c2.field,
|
|
op: val !== undefined ? 'add' : 'delete'
|
|
};
|
|
if (val !== undefined) {
|
|
c1.value = val;
|
|
}
|
|
if (c2.op == 'modify') {
|
|
c2.op = 'add';
|
|
}
|
|
conflicts.push([c1, c2]);
|
|
}
|
|
|
|
var localChanged = false;
|
|
var normalizeHTML = (str) => {
|
|
let parser = new DOMParser();
|
|
str = parser.parseFromString(str, 'text/html');
|
|
str = str.body.textContent;
|
|
// Normalize internal spaces
|
|
str = str.replace(/\s+/g, ' ');
|
|
return str;
|
|
};
|
|
|
|
// Massage some old data
|
|
conflicts = conflicts.filter((x) => {
|
|
// If one side has auto-hyphenated ISBN, use that
|
|
if (x[0].field == 'ISBN' && x[0].op == 'add' && x[1].op == 'add') {
|
|
let hyphenatedA = Zotero.Utilities.Internal.hyphenateISBN(x[0].value);
|
|
let hyphenatedB = Zotero.Utilities.Internal.hyphenateISBN(x[1].value);
|
|
if (hyphenatedA && hyphenatedB) {
|
|
// Use remote
|
|
if (hyphenatedA == x[1].value) {
|
|
changes.push(x[1]);
|
|
return false;
|
|
}
|
|
// Use local
|
|
else if (x[0].value == hyphenatedB) {
|
|
localChanged = true;
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
// Ignore notes with the same text content
|
|
//
|
|
// These can happen to people upgrading to 5.0 with notes that were added without going
|
|
// through TinyMCE (e.g., from translators)
|
|
else if (x[0].field == 'note' && x[0].op == 'add' && x[1].op == 'add') {
|
|
let a = x[0].value;
|
|
let b = x[1].value;
|
|
try {
|
|
a = normalizeHTML(a);
|
|
b = normalizeHTML(b);
|
|
if (a == b) {
|
|
Zotero.debug("Notes differ only by markup -- using remote version");
|
|
changes.push(x[1]);
|
|
return false;
|
|
}
|
|
}
|
|
catch (e) {
|
|
Zotero.logError(e);
|
|
return true
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
|
|
return { changes, conflicts, localChanged };
|
|
},
|
|
|
|
|
|
markObjectAsSynced: Zotero.Promise.method(function (obj) {
|
|
obj.synced = true;
|
|
return obj.saveTx({ skipAll: true });
|
|
}),
|
|
|
|
|
|
markObjectAsUnsynced: Zotero.Promise.method(function (obj) {
|
|
obj.synced = false;
|
|
return obj.saveTx({ skipAll: true });
|
|
}),
|
|
|
|
|
|
/**
|
|
* @return {Promise<Date|false>}
|
|
*/
|
|
getDateDeleted: Zotero.Promise.coroutine(function* (objectType, libraryID, key) {
|
|
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
|
|
var sql = "SELECT dateDeleted FROM syncDeleteLog WHERE libraryID=? AND key=? "
|
|
+ "AND syncObjectTypeID=?";
|
|
var date = yield Zotero.DB.valueQueryAsync(sql, [libraryID, key, syncObjectTypeID]);
|
|
return date ? Zotero.Date.sqlToDate(date, true) : false;
|
|
}),
|
|
|
|
|
|
/**
|
|
* @return {Promise<String[]>} - Promise for array of keys
|
|
*/
|
|
getDeleted: Zotero.Promise.coroutine(function* (objectType, libraryID) {
|
|
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
|
|
var sql = "SELECT key FROM syncDeleteLog WHERE libraryID=? AND syncObjectTypeID=?";
|
|
return Zotero.DB.columnQueryAsync(sql, [libraryID, syncObjectTypeID]);
|
|
}),
|
|
|
|
|
|
/**
|
|
* @return {Promise}
|
|
*/
|
|
removeObjectsFromDeleteLog: function (objectType, libraryID, keys) {
|
|
if (!keys.length) Zotero.Promise.resolve();
|
|
|
|
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
|
|
var sql = "DELETE FROM syncDeleteLog WHERE libraryID=? AND syncObjectTypeID=? AND key IN (";
|
|
return Zotero.Utilities.Internal.forEachChunkAsync(
|
|
keys,
|
|
Zotero.DB.MAX_BOUND_PARAMETERS - 2,
|
|
Zotero.Promise.coroutine(function* (chunk) {
|
|
var params = [libraryID, syncObjectTypeID].concat(chunk);
|
|
return Zotero.DB.queryAsync(
|
|
sql + Array(chunk.length).fill('?').join(',') + ")", params
|
|
);
|
|
})
|
|
);
|
|
},
|
|
|
|
|
|
clearDeleteLogForLibrary: async function (libraryID) {
|
|
await Zotero.DB.queryAsync("DELETE FROM syncDeleteLog WHERE libraryID=?", libraryID);
|
|
},
|
|
|
|
|
|
/**
|
|
* @param {String} objectType
|
|
* @param {Integer} libraryID
|
|
* @param {String[]} keys
|
|
* @param {Boolean} [tryImmediately=false] - Assign lastCheck of 0 so item is retried immediately
|
|
*/
|
|
addObjectsToSyncQueue: Zotero.Promise.coroutine(function* (objectType, libraryID, keys, tryImmediately) {
|
|
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
|
|
var now = tryImmediately ? 0 : Zotero.Date.getUnixTimestamp();
|
|
|
|
// Default to first try
|
|
var keyTries = {};
|
|
keys.forEach(key => keyTries[key] = 0);
|
|
|
|
// Check current try counts
|
|
var sql = "SELECT key, tries FROM syncQueue WHERE ";
|
|
yield Zotero.Utilities.Internal.forEachChunkAsync(
|
|
keys,
|
|
Math.floor(Zotero.DB.MAX_BOUND_PARAMETERS / 3),
|
|
Zotero.Promise.coroutine(function* (chunk) {
|
|
var params = chunk.reduce(
|
|
(arr, key) => arr.concat([libraryID, key, syncObjectTypeID]), []
|
|
);
|
|
var rows = yield Zotero.DB.queryAsync(
|
|
sql + Array(chunk.length)
|
|
.fill('(libraryID=? AND key=? AND syncObjectTypeID=?)')
|
|
.join(' OR '),
|
|
params
|
|
);
|
|
for (let row of rows) {
|
|
keyTries[row.key] = row.tries + 1; // increment current count
|
|
}
|
|
})
|
|
);
|
|
|
|
// Insert or update
|
|
var sql = "INSERT OR REPLACE INTO syncQueue "
|
|
+ "(libraryID, key, syncObjectTypeID, lastCheck, tries) VALUES ";
|
|
return Zotero.Utilities.Internal.forEachChunkAsync(
|
|
keys,
|
|
Math.floor(Zotero.DB.MAX_BOUND_PARAMETERS / 5),
|
|
function (chunk) {
|
|
var params = chunk.reduce(
|
|
(arr, key) => arr.concat(
|
|
[libraryID, key, syncObjectTypeID, now, keyTries[key]]
|
|
), []
|
|
);
|
|
return Zotero.DB.queryAsync(
|
|
sql + Array(chunk.length).fill('(?, ?, ?, ?, ?)').join(', '), params
|
|
);
|
|
}
|
|
);
|
|
}),
|
|
|
|
|
|
hasObjectsInSyncQueue: function (libraryID) {
|
|
return Zotero.DB.valueQueryAsync(
|
|
"SELECT ROWID FROM syncQueue WHERE libraryID=? LIMIT 1", libraryID
|
|
).then(x => !!x);
|
|
},
|
|
|
|
|
|
getObjectsFromSyncQueue: function (objectType, libraryID) {
|
|
return Zotero.DB.columnQueryAsync(
|
|
"SELECT key FROM syncQueue WHERE libraryID=? AND "
|
|
+ "syncObjectTypeID IN (SELECT syncObjectTypeID FROM syncObjectTypes WHERE name=?)",
|
|
[libraryID, objectType]
|
|
);
|
|
},
|
|
|
|
|
|
hasObjectsToTryInSyncQueue: Zotero.Promise.coroutine(function* (libraryID) {
|
|
var rows = yield Zotero.DB.queryAsync(
|
|
"SELECT key, lastCheck, tries FROM syncQueue WHERE libraryID=?", libraryID
|
|
);
|
|
for (let row of rows) {
|
|
let interval = this._syncQueueIntervals[row.tries];
|
|
// Keep using last interval if beyond
|
|
if (!interval) {
|
|
interval = this._syncQueueIntervals[this._syncQueueIntervals.length - 1];
|
|
}
|
|
let nextCheck = row.lastCheck + interval * 60 * 60;
|
|
if (nextCheck <= Zotero.Date.getUnixTimestamp()) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}),
|
|
|
|
|
|
getObjectsToTryFromSyncQueue: Zotero.Promise.coroutine(function* (objectType, libraryID) {
|
|
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
|
|
var rows = yield Zotero.DB.queryAsync(
|
|
"SELECT key, lastCheck, tries FROM syncQueue WHERE libraryID=? AND syncObjectTypeID=?",
|
|
[libraryID, syncObjectTypeID]
|
|
);
|
|
var keysToTry = [];
|
|
for (let row of rows) {
|
|
let interval = this._syncQueueIntervals[row.tries];
|
|
// Keep using last interval if beyond
|
|
if (!interval) {
|
|
interval = this._syncQueueIntervals[this._syncQueueIntervals.length - 1];
|
|
}
|
|
let nextCheck = row.lastCheck + interval * 60 * 60;
|
|
if (nextCheck <= Zotero.Date.getUnixTimestamp()) {
|
|
keysToTry.push(row.key);
|
|
}
|
|
}
|
|
return keysToTry;
|
|
}),
|
|
|
|
|
|
removeObjectsFromSyncQueue: function (objectType, libraryID, keys) {
|
|
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
|
|
var sql = "DELETE FROM syncQueue WHERE libraryID=? AND syncObjectTypeID=? AND key IN (";
|
|
return Zotero.Utilities.Internal.forEachChunkAsync(
|
|
keys,
|
|
Zotero.DB.MAX_BOUND_PARAMETERS - 2,
|
|
Zotero.Promise.coroutine(function* (chunk) {
|
|
var params = [libraryID, syncObjectTypeID].concat(chunk);
|
|
return Zotero.DB.queryAsync(
|
|
sql + Array(chunk.length).fill('?').join(',') + ")", params
|
|
);
|
|
})
|
|
);
|
|
},
|
|
|
|
|
|
clearQueueForLibrary: async function (libraryID) {
|
|
await Zotero.DB.queryAsync("DELETE FROM syncQueue WHERE libraryID=?", libraryID);
|
|
},
|
|
|
|
|
|
_removeObjectFromSyncQueue: function (objectType, libraryID, key) {
|
|
return Zotero.DB.queryAsync(
|
|
"DELETE FROM syncQueue WHERE libraryID=? AND key=? AND syncObjectTypeID=?",
|
|
[
|
|
libraryID,
|
|
key,
|
|
Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType)
|
|
]
|
|
);
|
|
},
|
|
|
|
|
|
resetSyncQueue: function () {
|
|
return Zotero.DB.queryAsync("DELETE FROM syncQueue");
|
|
},
|
|
|
|
|
|
resetSyncQueueTries: function () {
|
|
return Zotero.DB.queryAsync("UPDATE syncQueue SET tries=0");
|
|
}
|
|
}
|