diff --git a/chrome/content/zotero/preferences/preferences_sync.js b/chrome/content/zotero/preferences/preferences_sync.js index eaf97c92db..0255d32454 100644 --- a/chrome/content/zotero/preferences/preferences_sync.js +++ b/chrome/content/zotero/preferences/preferences_sync.js @@ -26,6 +26,7 @@ "use strict"; Components.utils.import("resource://gre/modules/Services.jsm"); Components.utils.import("resource://gre/modules/osfile.jsm"); +Components.utils.import("resource://zotero/config.js"); Zotero_Preferences.Sync = { init: Zotero.Promise.coroutine(function* () { @@ -63,8 +64,10 @@ Zotero_Preferences.Sync = { } } } + + this.initResetPane(); }), - + displayFields: function (username) { document.getElementById('sync-unauthorized').hidden = !!username; document.getElementById('sync-authorized').hidden = !username; @@ -425,7 +428,7 @@ Zotero_Preferences.Sync = { var newEnabled = document.getElementById('pref-storage-enabled').value; if (oldProtocol != newProtocol) { - yield Zotero.Sync.Storage.Local.resetAllSyncStates(); + yield Zotero.Sync.Storage.Local.resetAllSyncStates(Zotero.Libraries.userLibraryID); } if (oldProtocol == 'webdav') { @@ -570,38 +573,87 @@ Zotero_Preferences.Sync = { }, - handleSyncResetSelect: function (obj) { - var index = obj.selectedIndex; - var rows = obj.getElementsByTagName('row'); + // + // Reset pane + // + initResetPane: function () { + // + // Build library selector + // + var libraryMenu = document.getElementById('sync-reset-library-menu'); + // Some options need to be disabled when certain libraries are selected + libraryMenu.onchange = (event) => { + this.onResetLibraryChange(parseInt(event.target.value)); + } + this.onResetLibraryChange(Zotero.Libraries.userLibraryID); + var libraries = Zotero.Libraries.getAll() + .filter(x => x.libraryType == 'user' || x.libraryType == 'group'); + Zotero.Utilities.Internal.buildLibraryMenuHTML(libraryMenu, libraries); + // Disable read-only libraries, at least until there are options that make sense for those + Array.from(libraryMenu.querySelectorAll('option')) + .filter(x => x.getAttribute('data-editable') == 'false') + .forEach(x => x.disabled = true); - for (var i=0; i x.selected)[0] + .value + ); + var library = Zotero.Libraries.get(libraryID); + var action = Array.from(document.querySelectorAll('#sync-reset-list input[name=sync-reset-radiogroup]')) + .filter(x => x.checked)[0] + .getAttribute('value'); + switch (action) { - case 'full-sync': + /*case 'full-sync': var buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING) + (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_CANCEL) + ps.BUTTON_POS_1_DEFAULT; @@ -622,7 +674,7 @@ Zotero_Preferences.Sync = { switch (index) { case 0: let libraries = Zotero.Libraries.getAll().filter(library => library.syncable); - yield Zotero.DB.executeTransaction(function* () { + await Zotero.DB.executeTransaction(function* () { for (let library of libraries) { library.libraryVersion = -1; yield library.save(); @@ -655,13 +707,13 @@ Zotero_Preferences.Sync = { // TODO: better error handling // Verify username and password - var callback = Zotero.Promise.coroutine(function* () { + var callback = async function () { Zotero.Schema.stopRepositoryTimer(); Zotero.Sync.Runner.clearSyncTimeout(); Zotero.DB.skipBackup = true; - yield Zotero.File.putContentsAsync( + await Zotero.File.putContentsAsync( OS.Path.join(Zotero.DataDirectory.dir, 'restore-from-server'), '' ); @@ -679,7 +731,7 @@ Zotero_Preferences.Sync = { var appStartup = Components.classes["@mozilla.org/toolkit/app-startup;1"] .getService(Components.interfaces.nsIAppStartup); appStartup.quit(Components.interfaces.nsIAppStartup.eRestart | Components.interfaces.nsIAppStartup.eAttemptQuit); - }); + }; // TODO: better way of checking for an active session? if (Zotero.Sync.Server.sessionIDComponent == 'sessionid=') { @@ -696,52 +748,37 @@ Zotero_Preferences.Sync = { case 1: return; } - break; + break;*/ case 'restore-to-server': var buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING) - + (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_CANCEL) - + ps.BUTTON_POS_1_DEFAULT; + + (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_CANCEL) + + ps.BUTTON_POS_1_DEFAULT; var index = ps.confirmEx( null, Zotero.getString('general.warning'), - Zotero.getString('zotero.preferences.sync.reset.restoreToServer', account), + Zotero.getString( + 'zotero.preferences.sync.reset.restoreToServer', + [Zotero.clientName, library.name, ZOTERO_CONFIG.DOMAIN_NAME] + ), buttonFlags, - Zotero.getString('zotero.preferences.sync.reset.replaceServerData'), + Zotero.getString('zotero.preferences.sync.reset.restoreToServer.button'), null, null, null, {} ); switch (index) { case 0: - // TODO: better error handling - Zotero.Sync.Server.clear(function () { - Zotero.Sync.Server.sync(/*{ - - // TODO: this doesn't work if the pref window is - closed. fix, perhaps by making original callbacks - available to the custom callbacks - - onSuccess: function () { - Zotero.Sync.Runner.updateIcons(); - ps.alert( - null, - "Restore Completed", - "Data on the Zotero server has been successfully restored." - ); - }, - onError: function (msg) { - // TODO: combine with error dialog for regular syncs - ps.alert( - null, - "Restore Failed", - "An error occurred uploading your data to the server.\n\n" - + "Click the sync error icon in the Zotero toolbar " - + "for further information." - ); - Zotero.Sync.Runner.error(msg); - } - }*/); - }); + var resetButton = document.getElementById('sync-reset-button'); + resetButton.disabled = true; + try { + await Zotero.Sync.Runner.sync({ + libraries: [libraryID], + resetMode: Zotero.Sync.Runner.RESET_MODE_TO_SERVER + }); + } + finally { + resetButton.disabled = false; + } break; // Cancel @@ -752,14 +789,17 @@ Zotero_Preferences.Sync = { break; - case 'reset-storage-history': - var buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING) - + (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_CANCEL) - + ps.BUTTON_POS_1_DEFAULT; + case 'reset-file-sync-history': + var buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING + + ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL + + ps.BUTTON_POS_1_DEFAULT; var index = ps.confirmEx( null, Zotero.getString('general.warning'), - Zotero.getString('zotero.preferences.sync.reset.fileSyncHistory', Zotero.clientName), + Zotero.getString( + 'zotero.preferences.sync.reset.fileSyncHistory', + [Zotero.clientName, library.name] + ), buttonFlags, Zotero.getString('general.reset'), null, null, null, {} @@ -767,11 +807,14 @@ Zotero_Preferences.Sync = { switch (index) { case 0: - yield Zotero.Sync.Storage.Local.resetAllSyncStates(); + await Zotero.Sync.Storage.Local.resetAllSyncStates(libraryID); ps.alert( null, - "File Sync History Cleared", - "The file sync history has been cleared." + Zotero.getString('general.success'), + Zotero.getString( + 'zotero.preferences.sync.reset.fileSyncHistory.cleared', + library.name + ) ); break; @@ -783,7 +826,7 @@ Zotero_Preferences.Sync = { break; default: - throw ("Invalid action '" + action + "' in handleSyncReset()"); + throw new Error(`Invalid action '${action}' in handleSyncReset()`); } - }) + } }; diff --git a/chrome/content/zotero/preferences/preferences_sync.xul b/chrome/content/zotero/preferences/preferences_sync.xul index fbc776d726..f13e9e2f58 100644 --- a/chrome/content/zotero/preferences/preferences_sync.xul +++ b/chrome/content/zotero/preferences/preferences_sync.xul @@ -271,82 +271,47 @@ - + &zotero.preferences.sync.reset.warning1;&zotero.preferences.sync.reset.warning3; - - +
+
+ + + + + +
  • + +
  • + - - +
    diff --git a/chrome/content/zotero/xpcom/data/dataObject.js b/chrome/content/zotero/xpcom/data/dataObject.js index e5efe1dfb7..795047db5e 100644 --- a/chrome/content/zotero/xpcom/data/dataObject.js +++ b/chrome/content/zotero/xpcom/data/dataObject.js @@ -1285,6 +1285,9 @@ Zotero.DataObject.prototype._postToJSON = function (env) { if (env.mode == 'patch') { env.obj = Zotero.DataObjectUtilities.patch(env.options.patchBase, env.obj); } + if (env.options.includeVersion === false) { + delete env.obj.version; + } return env.obj; } diff --git a/chrome/content/zotero/xpcom/data/dataObjects.js b/chrome/content/zotero/xpcom/data/dataObjects.js index 40512140e7..3d56c65d11 100644 --- a/chrome/content/zotero/xpcom/data/dataObjects.js +++ b/chrome/content/zotero/xpcom/data/dataObjects.js @@ -231,6 +231,12 @@ Zotero.DataObjects.prototype.getLoaded = function () { } +Zotero.DataObjects.prototype.getAllIDs = function (libraryID) { + var sql = `SELECT ${this._ZDO_id} FROM ${this._ZDO_table} WHERE libraryID=?`; + return Zotero.DB.columnQueryAsync(sql, [libraryID]); +}; + + Zotero.DataObjects.prototype.getAllKeys = function (libraryID) { var sql = "SELECT key FROM " + this._ZDO_table + " WHERE libraryID=?"; return Zotero.DB.columnQueryAsync(sql, [libraryID]); @@ -319,6 +325,11 @@ Zotero.DataObjects.prototype.exists = function (id) { } +Zotero.DataObjects.prototype.existsByKey = function (key) { + return !!this.getIDFromLibraryAndKey(id); +} + + /** * @return {Object} Object with 'libraryID' and 'key' */ diff --git a/chrome/content/zotero/xpcom/storage/storageLocal.js b/chrome/content/zotero/xpcom/storage/storageLocal.js index 84be63517d..fb18e70679 100644 --- a/chrome/content/zotero/xpcom/storage/storageLocal.js +++ b/chrome/content/zotero/xpcom/storage/storageLocal.js @@ -552,23 +552,29 @@ Zotero.Sync.Storage.Local = { * This is used when switching between storage modes in the preferences so that all existing files * are uploaded via the new mode if necessary. */ - resetAllSyncStates: Zotero.Promise.coroutine(function* () { - var sql = "SELECT itemID FROM items JOIN itemAttachments USING (itemID) " - + "WHERE libraryID=? AND itemTypeID=? AND linkMode IN (?, ?)"; - var params = [ - Zotero.Libraries.userLibraryID, - Zotero.ItemTypes.getID('attachment'), - Zotero.Attachments.LINK_MODE_IMPORTED_FILE, - Zotero.Attachments.LINK_MODE_IMPORTED_URL, - ]; - var itemIDs = yield Zotero.DB.columnQueryAsync(sql, params); - for (let itemID of itemIDs) { - let item = Zotero.Items.get(itemID); - item._attachmentSyncState = this.SYNC_STATE_TO_UPLOAD; + resetAllSyncStates: async function (libraryID) { + if (!libraryID) { + throw new Error("libraryID not provided"); } - sql = "UPDATE itemAttachments SET syncState=? WHERE itemID IN (" + sql + ")"; - yield Zotero.DB.queryAsync(sql, [this.SYNC_STATE_TO_UPLOAD].concat(params)); - }), + + return Zotero.DB.executeTransaction(async function () { + var sql = "SELECT itemID FROM items JOIN itemAttachments USING (itemID) " + + "WHERE libraryID=? AND itemTypeID=? AND linkMode IN (?, ?)"; + var params = [ + libraryID, + Zotero.ItemTypes.getID('attachment'), + Zotero.Attachments.LINK_MODE_IMPORTED_FILE, + Zotero.Attachments.LINK_MODE_IMPORTED_URL, + ]; + var itemIDs = await Zotero.DB.columnQueryAsync(sql, params); + for (let itemID of itemIDs) { + let item = Zotero.Items.get(itemID); + item._attachmentSyncState = this.SYNC_STATE_TO_UPLOAD; + } + sql = "UPDATE itemAttachments SET syncState=? WHERE itemID IN (" + sql + ")"; + await Zotero.DB.queryAsync(sql, [this.SYNC_STATE_TO_UPLOAD].concat(params)); + }.bind(this)); + }, /** @@ -987,7 +993,9 @@ Zotero.Sync.Storage.Local = { continue; } remoteItemJSON = remoteItemJSON.data; - remoteItemJSON.dateModified = Zotero.Date.dateToISO(new Date(remoteItemJSON.mtime)); + if (remoteItemJSON.mtime) { + remoteItemJSON.dateModified = Zotero.Date.dateToISO(new Date(remoteItemJSON.mtime)); + } items.push({ libraryID, left: localItemJSON, diff --git a/chrome/content/zotero/xpcom/sync/syncEngine.js b/chrome/content/zotero/xpcom/sync/syncEngine.js index 39a26ebb17..c59018bd6b 100644 --- a/chrome/content/zotero/xpcom/sync/syncEngine.js +++ b/chrome/content/zotero/xpcom/sync/syncEngine.js @@ -56,7 +56,14 @@ Zotero.Sync.Data.Engine = function (options) { this.failedItems = []; // Options to pass through to processing functions - this.optionNames = ['setStatus', 'onError', 'stopOnError', 'background', 'firstInSession']; + this.optionNames = [ + 'setStatus', + 'onError', + 'stopOnError', + 'background', + 'firstInSession', + 'resetMode' + ]; this.options = {}; this.optionNames.forEach(x => { // Create dummy functions if not set @@ -93,10 +100,14 @@ Zotero.Sync.Data.Engine.prototype.start = Zotero.Promise.coroutine(function* () } this._statusCheck(); + this._restoringToServer = false; // Check if we've synced this library with the current architecture yet var libraryVersion = this.library.libraryVersion; - if (!libraryVersion || libraryVersion == -1) { + if (this.resetMode == Zotero.Sync.Runner.RESET_MODE_TO_SERVER) { + yield this._restoreToServer(); + } + else if (!libraryVersion || libraryVersion == -1) { let versionResults = yield this._upgradeCheck(); if (versionResults) { libraryVersion = this.library.libraryVersion; @@ -248,10 +259,6 @@ Zotero.Sync.Data.Engine.prototype._startDownload = Zotero.Promise.coroutine(func break; } } - else if (results.result == this.DOWNLOAD_RESULT_RESTART) { - yield this._onLibraryVersionChange(); - continue loop; - } newLibraryVersion = results.libraryVersion; // @@ -291,7 +298,7 @@ Zotero.Sync.Data.Engine.prototype._startDownload = Zotero.Promise.coroutine(func } let deletionsResult = yield this._downloadDeletions(libraryVersion, newLibraryVersion); - if (deletionsResult == this.DOWNLOAD_RESULT_RESTART) { + if (deletionsResult.result == this.DOWNLOAD_RESULT_RESTART) { yield this._onLibraryVersionChange(); continue loop; } @@ -778,7 +785,7 @@ Zotero.Sync.Data.Engine.prototype._restoreRestoredCollectionItems = async functi * @param {Integer} since - Last-known library version; get changes sinces this version * @param {Integer} [newLibraryVersion] - Newest library version seen in this sync process; if newer * version is seen, restart the sync - * @return {Promise} - A download result code (this.DOWNLOAD_RESULT_*) + * @return {Object} - Object with 'result' (DOWNLOAD_RESULT_*) and 'libraryVersion' */ Zotero.Sync.Data.Engine.prototype._downloadDeletions = Zotero.Promise.coroutine(function* (since, newLibraryVersion) { const batchSize = 50; @@ -788,14 +795,20 @@ Zotero.Sync.Data.Engine.prototype._downloadDeletions = Zotero.Promise.coroutine( this.libraryTypeID, since ); - if (newLibraryVersion !== undefined && newLibraryVersion != results.libraryVersion) { - return this.DOWNLOAD_RESULT_RESTART; + if (newLibraryVersion && newLibraryVersion != results.libraryVersion) { + return { + result: this.DOWNLOAD_RESULT_RESTART, + libraryVersion: results.libraryVersion + }; } var numObjects = Object.keys(results.deleted).reduce((n, k) => n + results.deleted[k].length, 0); if (!numObjects) { Zotero.debug("No objects deleted remotely since last check"); - return this.DOWNLOAD_RESULT_CONTINUE; + return { + result: this.DOWNLOAD_RESULT_CONTINUE, + libraryVersion: results.libraryVersion + }; } Zotero.debug(numObjects + " objects deleted remotely since last check"); @@ -915,7 +928,10 @@ Zotero.Sync.Data.Engine.prototype._downloadDeletions = Zotero.Promise.coroutine( } } - return this.DOWNLOAD_RESULT_CONTINUE; + return { + result: this.DOWNLOAD_RESULT_CONTINUE, + libraryVersion: results.libraryVersion + }; }); @@ -1166,11 +1182,13 @@ Zotero.Sync.Data.Engine.prototype._uploadObjects = Zotero.Promise.coroutine(func objectType, o.id, { - // Only include storage properties ('mtime', 'md5') for WebDAV files + restoreToServer: this._restoringToServer, + // Only include storage properties ('mtime', 'md5') when restoring to + // server and for WebDAV files skipStorageProperties: objectType == 'item' - ? Zotero.Sync.Storage.Local.getModeForLibrary(this.library.libraryID) - != 'webdav' + ? !this._restoringToServer + && Zotero.Sync.Storage.Local.getModeForLibrary(this.library.libraryID) != 'webdav' : undefined } ); @@ -1395,20 +1413,31 @@ Zotero.Sync.Data.Engine.prototype._getJSONForObject = function (objectType, id, objectType, obj.libraryID, obj.key, obj.version ); } + var patchBase = false; + // If restoring to server, use full mode. (The version and cache are cleared, so we would + // use "new" otherwise, which might be slightly different.) + if (options.restoreToServer) { + var mode = 'full'; + } + // If copy of object in cache, use patch mode with cache data as the base + else if (cacheObj) { + var mode = 'patch'; + patchBase = cacheObj.data; + } + // Otherwise use full mode if there's a version + else { + var mode = obj.version ? "full" : "new"; + } return obj.toJSON({ - // JSON generation mode depends on whether a copy is in the cache - // and, failing that, whether the object is new - mode: cacheObj - ? "patch" - : (obj.version ? "full" : "new"), + mode, includeKey: true, - includeVersion: true, // DEBUG: remove? + includeVersion: !options.restoreToServer, includeDate: true, // Whether to skip 'mtime' and 'md5' skipStorageProperties: options.skipStorageProperties, // Use last-synced mtime/md5 instead of current values from the file itself syncedStorageProperties: true, - patchBase: cacheObj ? cacheObj.data : false + patchBase }); }); } @@ -1597,11 +1626,8 @@ Zotero.Sync.Data.Engine.prototype._fullSync = Zotero.Promise.coroutine(function* this._statusCheck(); // Reprocess all deletions available from API - let result = yield this._downloadDeletions(0, lastLibraryVersion); - if (result == this.DOWNLOAD_RESULT_RESTART) { - yield this._onLibraryVersionChange(); - continue loop; - } + let results = yield this._downloadDeletions(0); + lastLibraryVersion = results.libraryVersion; // Get synced settings results = yield this._downloadSettings(0, lastLibraryVersion); @@ -1718,6 +1744,131 @@ Zotero.Sync.Data.Engine.prototype._fullSync = Zotero.Promise.coroutine(function* }); +Zotero.Sync.Data.Engine.prototype._restoreToServer = async function () { + Zotero.debug("Performing a restore-to-server for " + this.library.name); + + var libraryVersion; + + // Flag engine as restore-to-server mode so it uses library version only + this._restoringToServer = true; + + await Zotero.DB.executeTransaction(function* () { + yield Zotero.Sync.Data.Local.clearCacheForLibrary(this.libraryID); + yield Zotero.Sync.Data.Local.clearQueueForLibrary(this.libraryID); + yield Zotero.Sync.Data.Local.clearDeleteLogForLibrary(this.libraryID); + + // Mark all local settings as unsynced + yield Zotero.SyncedSettings.markAllAsUnsynced(this.libraryID); + + // Mark all objects as unsynced + for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(this.libraryID)) { + let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); + // Reset version on all objects and mark as unsynced + let ids = yield objectsClass.getAllIDs(this.libraryID) + yield objectsClass.updateVersion(ids, 0); + yield objectsClass.updateSynced(ids, false); + } + }.bind(this)); + + var remoteUpdatedError = "Online library updated since restore began"; + + for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(this.libraryID)) { + this._statusCheck(); + + let objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); + let ObjectType = Zotero.Utilities.capitalize(objectType); + let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); + + // Get all object versions from the API + let results = await this.apiClient.getVersions( + this.library.libraryType, + this.libraryTypeID, + objectType + ); + if (libraryVersion && libraryVersion != results.libraryVersion) { + throw new Error(remoteUpdatedError + + ` (${libraryVersion} != ${results.libraryVersion})`); + } + libraryVersion = results.libraryVersion; + + // Filter to objects that don't exist locally and delete those objects remotely + let remoteKeys = Object.keys(results.versions); + let locallyMissingKeys = remoteKeys.filter((key) => { + return !objectsClass.getIDFromLibraryAndKey(this.libraryID, key); + }); + if (locallyMissingKeys.length) { + Zotero.debug(`Deleting remote ${objectTypePlural} that don't exist locally`); + try { + libraryVersion = await this._uploadDeletions( + objectType, locallyMissingKeys, libraryVersion + ); + } + catch (e) { + if (e instanceof Zotero.HTTP.UnexpectedStatusException) { + // Let's just hope this doesn't happen + if (e.status == 412) { + throw new Error(remoteUpdatedError); + } + } + throw e; + } + } + else { + Zotero.debug(`No remote ${objectTypePlural} that don't exist locally`); + } + } + + this.library.libraryVersion = libraryVersion; + await this.library.saveTx(); + + // Upload the local data, which has all been marked as unsynced. We could just fall through to + // the normal _startUpload() in start(), but we don't want to accidentally restart and + // start downloading data if there's an error condition, so it's safer to call it explicitly + // here. + var uploadResult; + try { + uploadResult = await this._startUpload(); + } + catch (e) { + if (e instanceof Zotero.Sync.UserCancelledException) { + throw e; + } + Zotero.logError("Restore-to-server failed for " + this.library.name); + throw e; + } + + Zotero.debug("Upload result is " + uploadResult, 4); + + switch (uploadResult) { + case this.UPLOAD_RESULT_SUCCESS: + case this.UPLOAD_RESULT_NOTHING_TO_UPLOAD: + // Force all files to be checked for upload. If an attachment's hash was changed, it will + // no longer have an associated file, and then upload check will cause a file to be + // uploaded (or, more likely if this is a restoration from a backup, reassociated with + // another existing file). If the attachment's hash wasn't changed, it should already + // have the correct file. + await Zotero.Sync.Storage.Local.resetAllSyncStates(this.libraryID); + + Zotero.debug("Restore-to-server completed"); + break; + + case this.UPLOAD_RESULT_LIBRARY_CONFLICT: + throw new Error(remoteUpdatedError); + + case this.UPLOAD_RESULT_RESTART: + return this._restoreToServer() + + case this.UPLOAD_RESULT_CANCEL: + throw new Zotero.Sync.UserCancelledException; + + default: + throw new Error("Restore-to-server failed for " + this.library.name); + } + + this._restoringToServer = false; +}; + + Zotero.Sync.Data.Engine.prototype._getOptions = function (additionalOpts = {}) { var options = {}; this.optionNames.forEach(x => options[x] = this[x]); diff --git a/chrome/content/zotero/xpcom/sync/syncLocal.js b/chrome/content/zotero/xpcom/sync/syncLocal.js index fbc8cfd57c..393cebd4a1 100644 --- a/chrome/content/zotero/xpcom/sync/syncLocal.js +++ b/chrome/content/zotero/xpcom/sync/syncLocal.js @@ -1150,6 +1150,11 @@ Zotero.Sync.Data.Local = { }), + 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 []; @@ -1700,6 +1705,11 @@ Zotero.Sync.Data.Local = { }, + clearDeleteLogForLibrary: async function (libraryID) { + await Zotero.DB.queryAsync("DELETE FROM syncDeleteLog WHERE libraryID=?", libraryID); + }, + + addObjectsToSyncQueue: Zotero.Promise.coroutine(function* (objectType, libraryID, keys) { var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType); var now = Zotero.Date.getUnixTimestamp(); @@ -1822,6 +1832,11 @@ Zotero.Sync.Data.Local = { }, + 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=?", diff --git a/chrome/content/zotero/xpcom/sync/syncRunner.js b/chrome/content/zotero/xpcom/sync/syncRunner.js index 673e409f5d..b1eade2ce9 100644 --- a/chrome/content/zotero/xpcom/sync/syncRunner.js +++ b/chrome/content/zotero/xpcom/sync/syncRunner.js @@ -41,6 +41,9 @@ Zotero.Sync.Runner_Module = function (options = {}) { Zotero.defineProperty(this, 'syncInProgress', { get: () => _syncInProgress }); Zotero.defineProperty(this, 'lastSyncStatus', { get: () => _lastSyncStatus }); + Zotero.defineProperty(this, 'RESET_MODE_FROM_SERVER', { value: 1 }); + Zotero.defineProperty(this, 'RESET_MODE_TO_SERVER', { value: 2 }); + Zotero.defineProperty(this, 'baseURL', { get: () => { let url = options.baseURL || Zotero.Prefs.get("api.url") || ZOTERO_CONFIG.API_URL; @@ -172,7 +175,8 @@ Zotero.Sync.Runner_Module = function (options = {}) { } }.bind(this), background: !!options.background, - firstInSession: _firstInSession + firstInSession: _firstInSession, + resetMode: options.resetMode }; var librariesToSync = options.libraries = yield this.checkLibraries( diff --git a/chrome/content/zotero/xpcom/syncedSettings.js b/chrome/content/zotero/xpcom/syncedSettings.js index 30d26037a3..62edde3ed4 100644 --- a/chrome/content/zotero/xpcom/syncedSettings.js +++ b/chrome/content/zotero/xpcom/syncedSettings.js @@ -161,7 +161,6 @@ Zotero.SyncedSettings = (function () { }), markAsSynced: Zotero.Promise.coroutine(function* (libraryID, settings, version) { - Zotero.debug(settings); var sql = "UPDATE syncedSettings SET synced=1, version=? WHERE libraryID=? AND setting IN " + "(" + settings.map(x => '?').join(', ') + ")"; yield Zotero.DB.queryAsync(sql, [version, libraryID].concat(settings)); @@ -172,6 +171,19 @@ Zotero.SyncedSettings = (function () { } }), + /** + * Used for restore-to-server + */ + markAllAsUnsynced: async function (libraryID) { + var sql = "UPDATE syncedSettings SET synced=0, version=0 WHERE libraryID=?"; + await Zotero.DB.queryAsync(sql, libraryID); + for (let key in _cache[libraryID]) { + let setting = _cache[libraryID][key]; + setting.synced = false; + setting.version = 0; + } + }, + set: Zotero.Promise.coroutine(function* (libraryID, setting, value, version = 0, synced) { if (typeof value == undefined) { throw new Error("Value not provided"); diff --git a/chrome/locale/en-US/zotero/preferences.dtd b/chrome/locale/en-US/zotero/preferences.dtd index 238a477aa9..a15d2f1430 100644 --- a/chrome/locale/en-US/zotero/preferences.dtd +++ b/chrome/locale/en-US/zotero/preferences.dtd @@ -80,12 +80,12 @@ - - - - - - + + + + + + diff --git a/chrome/locale/en-US/zotero/zotero.properties b/chrome/locale/en-US/zotero/zotero.properties index 2add7ff116..b1770531b4 100644 --- a/chrome/locale/en-US/zotero/zotero.properties +++ b/chrome/locale/en-US/zotero/zotero.properties @@ -622,9 +622,10 @@ zotero.preferences.sync.reset.userInfoMissing = You must enter a username and zotero.preferences.sync.reset.restoreFromServer = All data in this copy of Zotero will be erased and replaced with data belonging to user '%S' on the Zotero server. zotero.preferences.sync.reset.replaceLocalData = Replace Local Data zotero.preferences.sync.reset.restartToComplete = Firefox must be restarted to complete the restore process. -zotero.preferences.sync.reset.restoreToServer = All data belonging to user '%S' on the Zotero server will be erased and replaced with data from this copy of Zotero.\n\nDepending on the size of your library, there may be a delay before your data is available on the server. -zotero.preferences.sync.reset.replaceServerData = Replace Server Data -zotero.preferences.sync.reset.fileSyncHistory = On the next sync, %S will check all attachment files against the storage service. Any remote attachment files that are missing locally will be downloaded, and local attachment files missing remotely will be uploaded.\n\nThis option is not necessary during normal usage. +zotero.preferences.sync.reset.restoreToServer = %S will replace data in “%S” on %S with data from this computer. +zotero.preferences.sync.reset.restoreToServer.button = Replace Data in Online Library +zotero.preferences.sync.reset.fileSyncHistory = On the next sync, %S will check all attachment files in “%S” against the storage service. Any remote attachment files that are missing locally will be downloaded, and local attachment files missing remotely will be uploaded.\n\nThis option is not necessary during normal usage. +zotero.preferences.sync.reset.fileSyncHistory.cleared = The file sync history for “%S” has been cleared. zotero.preferences.search.rebuildIndex = Rebuild Index zotero.preferences.search.rebuildWarning = Do you want to rebuild the entire index? This may take a while.\n\nTo index only items that haven't been indexed, use %S. diff --git a/chrome/skin/default/zotero/preferences.css b/chrome/skin/default/zotero/preferences.css index d6065a78e2..51994cefdb 100644 --- a/chrome/skin/default/zotero/preferences.css +++ b/chrome/skin/default/zotero/preferences.css @@ -173,44 +173,72 @@ grid row hbox:first-child } /* Reset tab */ -#zotero-reset row -{ - margin: 0; - padding: 8px; +#sync-reset-form { + margin-left: 1em; } -#zotero-reset row:not(:last-child) -{ +#sync-reset-form { + margin-top: 1em; } -#zotero-reset row vbox -{ - -moz-box-align: start; -} - -#zotero-reset row[selected="true"] -{ -} - - -#zotero-reset row vbox label -{ - margin-left: 3px; +#sync-reset-library-menu-container { font-weight: bold; - font-size: 14px; + font-size: 16px; } -#zotero-reset description -{ - margin-left: 3px; - margin-top: 1px; +#sync-reset-library-menu { + width: 14em; + margin-left: .25em; + font-size: 15px; + height: 1.6em; +} + +#sync-reset-list { + margin: 0; + padding: 0; + height: 9em; +} + +#sync-reset-list li { + margin: 0; + margin-top: 1.6em; + padding: 0; + list-style: none; + height: 2.8em; +} + +/* Allow a click between lines to select the radio */ +#sync-reset-list li label { + display: block; +} + +#sync-reset-list li:first-child { + margin-top: 1.4em; +} + +#sync-reset-list li .sync-reset-option-name { + font-weight: bold; + display: block; + font-size: 15px; + margin-bottom: .2em; +} + +#sync-reset-list li .sync-reset-option-desc { font-size: 12px; } -/* Reset button */ -#zotero-reset > hbox -{ - margin-top: 5px; +#sync-reset-list li input { + float: left; + margin-top: 1em; + margin-right: 1.05em; +} + +#sync-reset-list li[disabled] span { + color: gray; +} + +#sync-reset button { + font-size: 14px; } diff --git a/test/tests/storageLocalTest.js b/test/tests/storageLocalTest.js index 904c536fb5..b63a7ae1c0 100644 --- a/test/tests/storageLocalTest.js +++ b/test/tests/storageLocalTest.js @@ -122,7 +122,7 @@ describe("Zotero.Sync.Storage.Local", function () { yield attachment.saveTx(); var local = Zotero.Sync.Storage.Local; - yield local.resetAllSyncStates() + yield local.resetAllSyncStates(attachment.libraryID) assert.strictEqual(attachment.attachmentSyncState, local.SYNC_STATE_TO_UPLOAD); var state = yield Zotero.DB.valueQueryAsync( "SELECT syncState FROM itemAttachments WHERE itemID=?", attachment.id diff --git a/test/tests/syncEngineTest.js b/test/tests/syncEngineTest.js index 945eaaba56..d1e5aa32cd 100644 --- a/test/tests/syncEngineTest.js +++ b/test/tests/syncEngineTest.js @@ -4033,5 +4033,236 @@ describe("Zotero.Sync.Data.Engine", function () { assert.strictEqual(objects[type][1].synced, false); } }); - }) + }); + + + describe("#_restoreToServer()", function () { + it("should delete remote objects that don't exist locally and upload all local objects", async function () { + ({ engine, client, caller } = await setup()); + var library = Zotero.Libraries.userLibrary; + var libraryID = library.id; + var lastLibraryVersion = 10; + library.libraryVersion = library.storageVersion = lastLibraryVersion; + await library.saveTx(); + lastLibraryVersion = 20; + + var postData = {}; + var deleteData = {}; + + var types = Zotero.DataObjectUtilities.getTypes(); + var objects = {}; + var objectJSON = {}; + for (let type of types) { + objectJSON[type] = []; + } + + var obj; + for (let type of types) { + objects[type] = [null]; + // Create JSON for object that exists remotely and not locally, + // which should be deleted + objectJSON[type].push(makeJSONFunctions[type]({ + key: Zotero.DataObjectUtilities.generateKey(), + version: lastLibraryVersion, + name: Zotero.Utilities.randomString() + })); + + // All other objects should be uploaded + + // Object with outdated version + obj = await createDataObject(type, { synced: true, version: 5 }); + objects[type].push(obj); + objectJSON[type].push(makeJSONFunctions[type]({ + key: obj.key, + version: lastLibraryVersion, + name: Zotero.Utilities.randomString() + })); + + // Object marked as synced that doesn't exist remotely + obj = await createDataObject(type, { synced: true, version: 10 }); + objects[type].push(obj); + objectJSON[type].push(makeJSONFunctions[type]({ + key: obj.key, + version: lastLibraryVersion, + name: Zotero.Utilities.randomString() + })); + + // Object marked as synced that doesn't exist remotely + // but is in the remote delete log + obj = await createDataObject(type, { synced: true, version: 10 }); + objects[type].push(obj); + objectJSON[type].push(makeJSONFunctions[type]({ + key: obj.key, + version: lastLibraryVersion, + name: Zotero.Utilities.randomString() + })); + } + + // Child attachment + obj = await importFileAttachment( + 'test.png', + { + parentID: objects.item[1].id, + synced: true, + version: 5 + } + ); + obj.attachmentSyncedModificationTime = new Date().getTime(); + obj.attachmentSyncedHash = 'b32e33f529942d73bea4ed112310f804'; + obj.attachmentSyncState = Zotero.Sync.Storage.Local.SYNC_STATE_IN_SYNC; + await obj.saveTx(); + objects.item.push(obj); + objectJSON.item.push(makeJSONFunctions.item({ + key: obj.key, + version: lastLibraryVersion, + name: Zotero.Utilities.randomString(), + itemType: 'attachment' + })); + + for (let type of types) { + let plural = Zotero.DataObjectUtilities.getObjectTypePlural(type); + let suffix = type == 'item' ? '&includeTrashed=1' : ''; + + let 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/${plural}?format=versions${suffix}`, + status: 200, + headers: { + "Last-Modified-Version": lastLibraryVersion + }, + json + }); + + deleteData[type] = { + expectedVersion: lastLibraryVersion++, + keys: [objectJSON[type][0].key] + }; + } + + await Zotero.SyncedSettings.set(libraryID, "testSetting", { foo: 2 }); + var settingsJSON = { testSetting: { value: { foo: 2 } } } + postData.setting = { + expectedVersion: lastLibraryVersion++ + }; + + for (let type of types) { + postData[type] = { + expectedVersion: lastLibraryVersion++ + }; + } + + server.respond(function (req) { + try { + + let plural = req.url.match(/users\/\d+\/([a-z]+e?s)/)[1]; + let type = Zotero.DataObjectUtilities.getObjectTypeSingular(plural); + // Deletions + if (req.method == "DELETE") { + let data = deleteData[type]; + let version = data.expectedVersion + 1; + if (req.url == baseURL + `users/1/${plural}?${type}Key=${data.keys.join(',')}`) { + req.respond( + 204, + { + "Last-Modified-Version": version + }, + "" + ); + } + } + // Settings + else if (req.method == "POST" && req.url.match(/users\/\d+\/settings/)) { + let data = postData.setting; + assert.equal( + req.requestHeaders["If-Unmodified-Since-Version"], + data.expectedVersion + ); + let version = data.expectedVersion + 1; + let json = JSON.parse(req.requestBody); + assert.deepEqual(json, settingsJSON); + req.respond( + 204, + { + "Last-Modified-Version": version + }, + "" + ); + } + // Uploads + else if (req.method == "POST") { + let data = postData[type]; + assert.equal( + req.requestHeaders["If-Unmodified-Since-Version"], + data.expectedVersion + ); + let version = data.expectedVersion + 1; + let json = JSON.parse(req.requestBody); + let o1 = json.find(o => o.key == objectJSON[type][1].key); + assert.notProperty(o1, 'version'); + let o2 = json.find(o => o.key == objectJSON[type][2].key); + assert.notProperty(o2, 'version'); + let o3 = json.find(o => o.key == objectJSON[type][3].key); + assert.notProperty(o3, 'version'); + let response = { + successful: { + "0": Object.assign(objectJSON[type][1], { version }), + "1": Object.assign(objectJSON[type][2], { version }), + "2": Object.assign(objectJSON[type][3], { version }) + }, + unchanged: {}, + failed: {} + }; + if (type == 'item') { + let o = json.find(o => o.key == objectJSON.item[4].key); + assert.notProperty(o, 'version'); + // Attachment items should include storage properties + assert.propertyVal(o, 'mtime', objects.item[4].attachmentSyncedModificationTime); + assert.propertyVal(o, 'md5', objects.item[4].attachmentSyncedHash); + response.successful["3"] = Object.assign(objectJSON[type][4], { version }) + } + req.respond( + 200, + { + "Last-Modified-Version": version + }, + JSON.stringify(response) + ); + } + + } + catch (e) { + Zotero.logError(e); + throw e; + } + }); + + await engine._restoreToServer(); + + // Check settings + var setting = Zotero.SyncedSettings.get(libraryID, "testSetting"); + assert.deepEqual(setting, { foo: 2 }); + var settingMetadata = Zotero.SyncedSettings.getMetadata(libraryID, "testSetting"); + assert.equal(settingMetadata.version, postData.setting.expectedVersion + 1); + assert.isTrue(settingMetadata.synced); + + // Objects should all be marked as synced and in the cache + for (let type of types) { + let version = postData[type].expectedVersion + 1; + for (let i = 1; i <= 3; i++) { + assert.equal(objects[type][i].version, version); + assert.isTrue(objects[type][i].synced); + await assertInCache(objects[type][i]); + } + } + + // Files should be marked as unsynced + assert.equal( + objects.item[4].attachmentSyncState, + Zotero.Sync.Storage.Local.SYNC_STATE_TO_UPLOAD + ); + }); + }); })