API-based "Restore to Online Library"

Restores the "Restore to Zotero Server" functionality, now using the
API:

1. Get all remote keys and send `DELETE` for any that don't exist
   locally.
2. Upload all local objects in full (non-patch) mode using only library
   version so that the remotes are overwritten.
3. Reset file sync history, causing all files to be uploaded (or, more
   likely, reassociated with existing remote files).

Since these are treated as regular updates on the server, they'll sync
down to other clients normally. Unsynced changes by other clients might
still trigger conflicts.

This and Reset File Sync History can also now be run on group libraries,
with a library selector in the Reset pane (which I forgot to do with
React).

The full sync option is now removed from the Reset pane, since there
wasn't ever really a reason to run it manually.

We should be able to reimplement Restore from Online Library (#1386)
using the inverse of this approach.

Closes #914
This commit is contained in:
Dan Stillman 2017-12-08 00:27:29 -05:00
parent 885ed6039f
commit f353b7ca61
14 changed files with 697 additions and 225 deletions

View file

@ -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<rows.length; i++) {
if (i == index) {
rows[i].setAttribute('selected', 'true');
}
else {
rows[i].removeAttribute('selected');
}
var list = document.getElementById('sync-reset-list');
for (let li of document.querySelectorAll('#sync-reset-list li')) {
li.addEventListener('click', function (event) {
// Ignore clicks if disabled
if (this.hasAttribute('disabled')) {
event.stopPropagation();
return;
}
document.getElementById('sync-reset-button').disabled = false;
});
}
},
handleSyncReset: Zotero.Promise.coroutine(function* (action) {
var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"]
.getService(Components.interfaces.nsIPromptService);
onResetLibraryChange: function (libraryID) {
var library = Zotero.Libraries.get(libraryID);
var section = document.getElementById('reset-file-sync-history');
var input = section.querySelector('input');
if (library.filesEditable) {
section.removeAttribute('disabled');
input.disabled = false;
}
else {
section.setAttribute('disabled', '');
// If radio we're disabling is already selected, select the first one in the list
// instead
if (input.checked) {
document.querySelector('#sync-reset-list li:first-child input').checked = true;
}
input.disabled = true;
}
},
reset: async function () {
var ps = Services.prompt;
if (!Zotero.Sync.Runner.enabled) {
ps.alert(
if (Zotero.Sync.Runner.syncInProgress) {
Zotero.alert(
null,
Zotero.getString('general.error'),
Zotero.getString('zotero.preferences.sync.reset.userInfoMissing',
document.getElementById('zotero-prefpane-sync')
.getElementsByTagName('tab')[0].label)
Zotero.getString('sync.error.syncInProgress')
+ "\n\n"
+ Zotero.getString('general.operationInProgress.waitUntilFinishedAndTryAgain')
);
return;
}
var libraryID = parseInt(
Array.from(document.querySelectorAll('#sync-reset-library-menu option'))
.filter(x => 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()`);
}
})
}
};

View file

@ -271,82 +271,47 @@
</vbox>
</tabpanel>
<tabpanel id="zotero-reset" orient="vertical">
<tabpanel id="sync-reset" orient="vertical">
<!-- This doesn't wrap without an explicit width, for some reason -->
<description width="45em">&zotero.preferences.sync.reset.warning1;<label style="margin-left: 0; margin-right: 0" class="zotero-text-link" href="http://zotero.org/support/kb/sync_reset_options">&zotero.preferences.sync.reset.warning2;</label>&zotero.preferences.sync.reset.warning3;</description>
<groupbox>
<caption label="&zotero.preferences.sync.syncServer;"/>
<div id="sync-reset-form" xmlns="http://www.w3.org/1999/xhtml">
<div id="sync-reset-library-menu-container">
<label>Library: <select id="sync-reset-library-menu"/></label>
</div>
<radiogroup oncommand="Zotero_Preferences.Sync.handleSyncResetSelect(this)">
<grid>
<columns>
<column/>
<column align="start" pack="start" flex="1"/>
</columns>
<rows id="sync-reset-rows">
<!--
<row id="zotero-restore-from-server" selected="true">
<radio/>
<vbox onclick="this.previousSibling.click()">
<label value="&zotero.preferences.sync.reset.restoreFromServer;"/>
<description>&zotero.preferences.sync.reset.restoreFromServer.desc;</description>
</vbox>
</row>
<row id="zotero-restore-to-server">
<radio/>
<vbox onclick="this.previousSibling.click()">
<label value="&zotero.preferences.sync.reset.restoreToServer;"/>
<description>&zotero.preferences.sync.reset.restoreToServer.desc;</description>
</vbox>
</row>
-->
<row id="zotero-reset-data-sync-history">
<radio hidden="true"/>
<vbox onclick="this.previousSibling.click()">
<label value="&zotero.preferences.sync.reset.resetDataSyncHistory;"/>
<description>&zotero.preferences.sync.reset.resetDataSyncHistory.desc;</description>
</vbox>
</row>
</rows>
</grid>
</radiogroup>
<ul id="sync-reset-list">
<!--<li>
<p>&zotero.preferences.sync.reset.restoreFromServer;</p>
<p>&zotero.preferences.sync.reset.restoreFromServer.desc;</p>
</li>-->
<li id="restore-to-server">
<label>
<input name="sync-reset-radiogroup" value="restore-to-server" type="radio"/>
<span class="sync-reset-option-name">&zotero.preferences.sync.reset.restoreToServer;</span>
<span class="sync-reset-option-desc">&zotero.preferences.sync.reset.restoreToServer.desc;</span>
</label>
</li>
<!--<li>
<h2>&zotero.preferences.sync.reset.resetDataSyncHistory;</h2>
<description>&zotero.preferences.sync.reset.resetDataSyncHistory.desc;</p>
</li>-->
<li id="reset-file-sync-history">
<label>
<input name="sync-reset-radiogroup" value="reset-file-sync-history" type="radio"/>
<span class="sync-reset-option-name">&zotero.preferences.sync.reset.resetFileSyncHistory;</span>
<span class="sync-reset-option-desc">&zotero.preferences.sync.reset.resetFileSyncHistory.desc;</span>
</label>
</li>
</ul>
<hbox>
<button label="&zotero.preferences.sync.reset.button;"
oncommand="document.getElementById('sync-reset-rows').firstChild.firstChild.click(); Zotero_Preferences.Sync.handleSyncReset('full-sync')"/>
</hbox>
</groupbox>
<groupbox>
<caption label="&zotero.preferences.sync.fileSyncing;"/>
<radiogroup oncommand="Zotero_Preferences.Sync.handleSyncResetSelect(this)">
<grid>
<columns>
<column/>
<column align="start" pack="start" flex="1"/>
</columns>
<rows id="storage-reset-rows">
<row id="zotero-reset-storage-history">
<radio hidden="true"/>
<vbox onclick="this.previousSibling.click()">
<label value="&zotero.preferences.sync.reset.resetFileSyncHistory;"/>
<description>&zotero.preferences.sync.reset.resetFileSyncHistory.desc;</description>
</vbox>
</row>
</rows>
</grid>
</radiogroup>
<hbox>
<button label="&zotero.preferences.sync.reset.button;"
oncommand="document.getElementById('storage-reset-rows').firstChild.firstChild.click(); Zotero_Preferences.Sync.handleSyncReset('reset-storage-history')"/>
</hbox>
</groupbox>
<button id="sync-reset-button"
disabled="disabled"
onclick="Zotero_Preferences.Sync.reset()">&zotero.preferences.sync.reset.button;</button>
</div>
</tabpanel>
</tabpanels>
</tabbox>

View file

@ -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;
}

View file

@ -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'
*/

View file

@ -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,

View file

@ -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<Integer>} - 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]);

View file

@ -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=?",

View file

@ -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(

View file

@ -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");

View file

@ -80,12 +80,12 @@
<!ENTITY zotero.preferences.sync.reset.warning3 " for more information.">
<!ENTITY zotero.preferences.sync.reset.resetDataSyncHistory "Reset Data Sync History">
<!ENTITY zotero.preferences.sync.reset.resetDataSyncHistory.desc "Merge local data with remote data, ignoring sync history">
<!ENTITY zotero.preferences.sync.reset.restoreFromServer "Restore from Zotero Server">
<!ENTITY zotero.preferences.sync.reset.restoreFromServer.desc "Erase all local Zotero data and restore from the sync server.">
<!ENTITY zotero.preferences.sync.reset.restoreToServer "Restore to Zotero Server">
<!ENTITY zotero.preferences.sync.reset.restoreToServer.desc "Erase all server data and overwrite with local Zotero data.">
<!ENTITY zotero.preferences.sync.reset.resetFileSyncHistory "Reset File Sync History">
<!ENTITY zotero.preferences.sync.reset.resetFileSyncHistory.desc "Compare all attachment files with storage service">
<!ENTITY zotero.preferences.sync.reset.restoreFromServer "Restore from Online Library">
<!ENTITY zotero.preferences.sync.reset.restoreFromServer.desc "Overwrite local Zotero data with data from the online library.">
<!ENTITY zotero.preferences.sync.reset.restoreToServer "Restore to Online Library">
<!ENTITY zotero.preferences.sync.reset.restoreToServer.desc "Overwrite online library with local Zotero data">
<!ENTITY zotero.preferences.sync.reset.resetFileSyncHistory "Reset File Sync History">
<!ENTITY zotero.preferences.sync.reset.resetFileSyncHistory.desc "Compare all attachment files with the storage service">
<!ENTITY zotero.preferences.sync.reset "Reset">
<!ENTITY zotero.preferences.sync.reset.button "Reset…">

View file

@ -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.

View file

@ -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;
}

View file

@ -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

View file

@ -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
);
});
});
})