www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

syncRunner.js (45716B)


      1 /*
      2     ***** BEGIN LICENSE BLOCK *****
      3     
      4     Copyright © 2009 Center for History and New Media
      5                      George Mason University, Fairfax, Virginia, USA
      6                      http://zotero.org
      7     
      8     This file is part of Zotero.
      9     
     10     Zotero is free software: you can redistribute it and/or modify
     11     it under the terms of the GNU Affero General Public License as published by
     12     the Free Software Foundation, either version 3 of the License, or
     13     (at your option) any later version.
     14     
     15     Zotero is distributed in the hope that it will be useful,
     16     but WITHOUT ANY WARRANTY; without even the implied warranty of
     17     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     18     GNU Affero General Public License for more details.
     19     
     20     You should have received a copy of the GNU Affero General Public License
     21     along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
     22     
     23     ***** END LICENSE BLOCK *****
     24 */
     25 
     26 "use strict";
     27 
     28 if (!Zotero.Sync) {
     29 	Zotero.Sync = {};
     30 }
     31 
     32 // Initialized as Zotero.Sync.Runner in zotero.js
     33 Zotero.Sync.Runner_Module = function (options = {}) {
     34 	const stopOnError = false;
     35 	
     36 	Zotero.defineProperty(this, 'enabled', {
     37 		get: () => {
     38 			return _apiKey || Zotero.Sync.Data.Local.hasCredentials();
     39 		}
     40 	});
     41 	Zotero.defineProperty(this, 'syncInProgress', { get: () => _syncInProgress });
     42 	Zotero.defineProperty(this, 'lastSyncStatus', { get: () => _lastSyncStatus });
     43 	
     44 	Zotero.defineProperty(this, 'RESET_MODE_FROM_SERVER', { value: 1 });
     45 	Zotero.defineProperty(this, 'RESET_MODE_TO_SERVER', { value: 2 });
     46 	
     47 	Zotero.defineProperty(this, 'baseURL', {
     48 		get: () => {
     49 			let url = options.baseURL || Zotero.Prefs.get("api.url") || ZOTERO_CONFIG.API_URL;
     50 			if (!url.endsWith('/')) {
     51 				url += '/';
     52 			}
     53 			return url;
     54 		}
     55 	});
     56 	this.apiVersion = options.apiVersion || ZOTERO_CONFIG.API_VERSION;
     57 	
     58 	// Allows tests to set apiKey in options or as property, overriding login manager
     59 	var _apiKey = options.apiKey;
     60 	Zotero.defineProperty(this, 'apiKey', { set: val => _apiKey = val });
     61 	
     62 	Components.utils.import("resource://zotero/concurrentCaller.js");
     63 	this.caller = new ConcurrentCaller({
     64 		numConcurrent: 4,
     65 		stopOnError,
     66 		logger: msg => Zotero.debug(msg),
     67 		onError: e => Zotero.logError(e)
     68 	});
     69 	
     70 	var _enabled = false;
     71 	var _autoSyncTimer;
     72 	var _delaySyncUntil;
     73 	var _delayPromises = [];
     74 	var _firstInSession = true;
     75 	var _syncInProgress = false;
     76 	var _stopping = false;
     77 	var _manualSyncRequired = false; // TODO: make public?
     78 	
     79 	var _currentEngine = null;
     80 	var _storageControllers = {};
     81 	
     82 	var _lastSyncStatus;
     83 	var _currentSyncStatusLabel;
     84 	var _currentLastSyncLabel;
     85 	var _errors = [];
     86 	
     87 	Zotero.addShutdownListener(() => this.stop());
     88 	
     89 	this.getAPIClient = function (options = {}) {
     90 		return new Zotero.Sync.APIClient({
     91 			baseURL: this.baseURL,
     92 			apiVersion: this.apiVersion,
     93 			apiKey: options.apiKey,
     94 			caller: this.caller
     95 		});
     96 	}
     97 	
     98 	
     99 	/**
    100 	 * Begin a sync session
    101 	 *
    102 	 * @param {Object}    [options]
    103 	 * @param {Boolean}   [options.background=false]  Whether this is a background request, which
    104 	 *                                                prevents some alerts from being shown
    105 	 * @param {Integer[]} [options.libraries]         IDs of libraries to sync; skipped libraries must
    106 	 *     be removed if unwanted
    107 	 * @param {Function}  [options.onError]           Function to pass errors to instead of
    108 	 *                                                handling internally (used for testing)
    109 	 */
    110 	this.sync = Zotero.serial(function (options = {}) {
    111 		return this._sync(options);
    112 	});
    113 	
    114 	
    115 	this._sync = Zotero.Promise.coroutine(function* (options) {
    116 		// Clear message list
    117 		_errors = [];
    118 		
    119 		// Shouldn't be possible because of serial()
    120 		if (_syncInProgress) {
    121 			let msg = Zotero.getString('sync.error.syncInProgress');
    122 			let e = new Zotero.Error(msg, 0, { dialogButtonText: null, frontWindowOnly: true });
    123 			this.updateIcons(e);
    124 			return false;
    125 		}
    126 		_syncInProgress = true;
    127 		_stopping = false;
    128 		
    129 		try {
    130 			yield Zotero.Notifier.trigger('start', 'sync', []);
    131 			
    132 			let apiKey = yield _getAPIKey();
    133 			if (!apiKey) {
    134 				throw new Zotero.Error("API key not set", Zotero.Error.ERROR_API_KEY_NOT_SET);
    135 			}
    136 			
    137 			if (_firstInSession) {
    138 				options.firstInSession = true;
    139 				_firstInSession = false;
    140 			}
    141 			
    142 			this.updateIcons('animate');
    143 			
    144 			// If a delay is set (e.g., from the connector target selector), wait to sync
    145 			while (_delaySyncUntil && new Date() < _delaySyncUntil) {
    146 				this.setSyncStatus(Zotero.getString('sync.status.waiting'));
    147 				let delay = _delaySyncUntil - new Date();
    148 				Zotero.debug(`Waiting ${delay} ms to sync`);
    149 				yield Zotero.Promise.delay(delay);
    150 			}
    151 			
    152 			// If paused, wait until we're done
    153 			while (true) {
    154 				if (_delayPromises.some(p => p.isPending())) {
    155 					this.setSyncStatus(Zotero.getString('sync.status.waiting'));
    156 					Zotero.debug("Syncing is paused -- waiting to sync");
    157 					yield Zotero.Promise.all(_delayPromises);
    158 					// If more were added, continue
    159 					if (_delayPromises.some(p => p.isPending())) {
    160 						continue;
    161 					}
    162 					_delayPromises = [];
    163 				}
    164 				break;
    165 			}
    166 			
    167 			// purgeDataObjects() starts a transaction, so if there's an active one then show a
    168 			// nice message and wait until there's not. Another transaction could still start
    169 			// before purgeDataObjects() and result in a wait timeout, but this should reduce the
    170 			// frequency of that.
    171 			while (Zotero.DB.inTransaction()) {
    172 				this.setSyncStatus(Zotero.getString('sync.status.waiting'));
    173 				Zotero.debug("Transaction in progress -- waiting to sync");
    174 				yield Zotero.DB.waitForTransaction('sync');
    175 				_stopCheck();
    176 			}
    177 			
    178 			this.setSyncStatus(Zotero.getString('sync.status.preparing'));
    179 			
    180 			// Purge deleted objects so they don't cause sync errors (e.g., long tags)
    181 			yield Zotero.purgeDataObjects(true);
    182 			
    183 			let client = this.getAPIClient({ apiKey });
    184 			let keyInfo = yield this.checkAccess(client, options);
    185 			
    186 			_stopCheck();
    187 			
    188 			let emptyLibraryContinue = yield this.checkEmptyLibrary(keyInfo);
    189 			if (!emptyLibraryContinue) {
    190 				Zotero.debug("Syncing cancelled because user library is empty");
    191 				return false;
    192 			}
    193 			
    194 			let wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
    195 				.getService(Components.interfaces.nsIWindowMediator);
    196 			let lastWin = wm.getMostRecentWindow("navigator:browser");
    197 			if (!(yield Zotero.Sync.Data.Local.checkUser(lastWin, keyInfo.userID, keyInfo.username))) {
    198 				Zotero.debug("User cancelled sync on username mismatch");
    199 				return false;
    200 			}
    201 			
    202 			let engineOptions = {
    203 				userID: keyInfo.userID,
    204 				apiClient: client,
    205 				caller: this.caller,
    206 				setStatus: this.setSyncStatus.bind(this),
    207 				stopOnError,
    208 				onError: function (e) {
    209 					if (options.onError) {
    210 						options.onError(e);
    211 					}
    212 					else {
    213 						this.addError(e);
    214 					}
    215 				}.bind(this),
    216 				background: !!options.background,
    217 				firstInSession: _firstInSession,
    218 				resetMode: options.resetMode
    219 			};
    220 			
    221 			var librariesToSync = options.libraries = yield this.checkLibraries(
    222 				client,
    223 				options,
    224 				keyInfo,
    225 				options.libraries ? Array.from(options.libraries) : []
    226 			);
    227 			
    228 			_stopCheck();
    229 			
    230 			// If items not yet loaded for libraries we need, load them now
    231 			for (let libraryID of librariesToSync) {
    232 				let library = Zotero.Libraries.get(libraryID);
    233 				if (!library.getDataLoaded('item')) {
    234 					yield library.waitForDataLoad('item');
    235 				}
    236 			}
    237 			
    238 			_stopCheck();
    239 			
    240 			// Sync data and files, and then repeat if necessary
    241 			let attempt = 1;
    242 			let successfulLibraries = new Set(librariesToSync);
    243 			while (librariesToSync.length) {
    244 				_stopCheck();
    245 				
    246 				if (attempt > 3) {
    247 					// TODO: Back off and/or nicer error
    248 					throw new Error("Too many sync attempts -- stopping");
    249 				}
    250 				let nextLibraries = yield _doDataSync(librariesToSync, engineOptions);
    251 				// Remove failed libraries from the successful set
    252 				Zotero.Utilities.arrayDiff(librariesToSync, nextLibraries).forEach(libraryID => {
    253 					successfulLibraries.delete(libraryID);
    254 				});
    255 				
    256 				_stopCheck();
    257 				
    258 				// Run file sync on all libraries that passed the last data sync
    259 				librariesToSync = yield _doFileSync(nextLibraries, engineOptions);
    260 				if (librariesToSync.length) {
    261 					attempt++;
    262 					continue;
    263 				}
    264 				
    265 				_stopCheck();
    266 				
    267 				// Run full-text sync on all libraries that haven't failed a data sync
    268 				librariesToSync = yield _doFullTextSync([...successfulLibraries], engineOptions);
    269 				if (librariesToSync.length) {
    270 					attempt++;
    271 					continue;
    272 				}
    273 				break;
    274 			}
    275 		}
    276 		catch (e) {
    277 			if (e instanceof Zotero.HTTP.BrowserOfflineException) {
    278 				let msg = Zotero.getString('general.browserIsOffline', Zotero.appName);
    279 				e = new Zotero.Error(msg, 0, { dialogButtonText: null })
    280 				Zotero.logError(e);
    281 				_errors = [];
    282 			}
    283 			
    284 			if (e instanceof Zotero.Sync.UserCancelledException) {
    285 				Zotero.debug("Sync was cancelled");
    286 			}
    287 			else if (options.onError) {
    288 				options.onError(e);
    289 			}
    290 			else {
    291 				this.addError(e);
    292 			}
    293 		}
    294 		finally {
    295 			yield this.end(options);
    296 			
    297 			if (options.restartSync) {
    298 				delete options.restartSync;
    299 				Zotero.debug("Restarting sync");
    300 				yield this._sync(options);
    301 				return;
    302 			}
    303 			
    304 			Zotero.debug("Done syncing");
    305 			Zotero.Notifier.trigger('finish', 'sync', librariesToSync || []);
    306 		}
    307 	});
    308 	
    309 	
    310 	/**
    311 	 * Check key for current user info and return access info
    312 	 */
    313 	this.checkAccess = Zotero.Promise.coroutine(function* (client, options={}) {
    314 		var json = yield client.getKeyInfo(options);
    315 		Zotero.debug(json);
    316 		if (!json) {
    317 			throw new Zotero.Error("API key not set", Zotero.Error.ERROR_API_KEY_INVALID);
    318 		}
    319 		
    320 		// Sanity check
    321 		if (!json.userID) throw new Error("userID not found in key response");
    322 		if (!json.username) throw new Error("username not found in key response");
    323 		if (!json.access) throw new Error("'access' not found in key response");
    324 		
    325 		return json;
    326 	});
    327 
    328 
    329 	// Prompt if library empty and there is no userID stored
    330 	this.checkEmptyLibrary = Zotero.Promise.coroutine(function* (keyInfo) {
    331 		let library = Zotero.Libraries.userLibrary;
    332 		let feeds = Zotero.Feeds.getAll();
    333 		let userID = Zotero.Users.getCurrentUserID();
    334 
    335 		if (!userID) {
    336 			let hasItems = yield library.hasItems();
    337 			if (!hasItems && feeds.length <= 0 && !Zotero.resetDataDir) {
    338 				let ps = Services.prompt;
    339 				let index = ps.confirmEx(
    340 					null,
    341 					Zotero.getString('general.warning'),
    342 					Zotero.getString('account.warning.emptyLibrary', [keyInfo.username, Zotero.clientName]) + "\n\n"
    343 						+ Zotero.getString('account.warning.existingDataElsewhere', Zotero.clientName),
    344 					(ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING) 
    345 						+ (ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL)
    346 						+ (ps.BUTTON_POS_2 * ps.BUTTON_TITLE_IS_STRING),
    347 					Zotero.getString('sync.sync'),
    348 					null, 
    349 					Zotero.getString('dataDir.changeDataDirectory'), 
    350 					null, {}
    351 				);
    352 				if (index == 1) {
    353 					return false;
    354 				}
    355 				else if (index == 2) {
    356 					var win = Services.wm.getMostRecentWindow("navigator:browser");
    357 					win.openDialog("chrome://zotero/content/preferences/preferences.xul", null, null, {
    358 						pane: 'zotero-prefpane-advanced',
    359 						tabIndex: 1
    360 					});
    361 					return false;
    362 				}
    363 			}
    364 		}
    365 		return true;
    366 	});
    367 	
    368 	
    369 	/**
    370 	 * @return {Promise<Integer[]> - IDs of libraries to sync
    371 	 */
    372 	this.checkLibraries = Zotero.Promise.coroutine(function* (client, options, keyInfo, libraries = []) {
    373 		var access = keyInfo.access;
    374 		
    375 		var syncAllLibraries = !libraries || !libraries.length;
    376 		
    377 		// TODO: Ability to remove or disable editing of user library?
    378 		
    379 		if (syncAllLibraries) {
    380 			if (access.user && access.user.library) {
    381 				libraries = [Zotero.Libraries.userLibraryID];
    382 				let skippedLibraries = Zotero.Sync.Data.Local.getSkippedLibraries();
    383 				
    384 				// If syncing all libraries, remove skipped libraries
    385 				if (skippedLibraries.length) {
    386 					Zotero.debug("Skipped libraries:");
    387 					Zotero.debug(skippedLibraries);
    388 					libraries = Zotero.Utilities.arrayDiff(libraries, skippedLibraries);
    389 				}
    390 			}
    391 		}
    392 		else {
    393 			// Check access to specified libraries
    394 			for (let libraryID of libraries) {
    395 				let type = Zotero.Libraries.get(libraryID).libraryType;
    396 				if (type == 'user') {
    397 					if (!access.user || !access.user.library) {
    398 						// TODO: Alert
    399 						throw new Error("Key does not have access to library " + libraryID);
    400 					}
    401 				}
    402 			}
    403 		}
    404 		
    405 		//
    406 		// Check group access
    407 		//
    408 		let remotelyMissingGroups = [];
    409 		let groupsToDownload = [];
    410 		
    411 		if (!Zotero.Utilities.isEmpty(access.groups)) {
    412 			// TEMP: Require all-group access for now
    413 			if (access.groups.all) {
    414 				
    415 			}
    416 			else {
    417 				throw new Error("Full group access is currently required");
    418 			}
    419 			
    420 			let remoteGroupVersions = yield client.getGroupVersions(keyInfo.userID);
    421 			let remoteGroupIDs = Object.keys(remoteGroupVersions).map(id => parseInt(id));
    422 			let skippedGroups = Zotero.Sync.Data.Local.getSkippedGroups();
    423 			
    424 			// Remove skipped groups
    425 			if (syncAllLibraries) {
    426 				let newGroups = Zotero.Utilities.arrayDiff(remoteGroupIDs, skippedGroups);
    427 				Zotero.Utilities.arrayDiff(remoteGroupIDs, newGroups)
    428 					.forEach(id => { delete remoteGroupVersions[id] });
    429 				remoteGroupIDs = newGroups;
    430 			}
    431 			
    432 			for (let id in remoteGroupVersions) {
    433 				id = parseInt(id);
    434 				let group = Zotero.Groups.get(id);
    435 				
    436 				if (syncAllLibraries) {
    437 					// If syncing all libraries, mark any that don't exist, are outdated, or are
    438 					// archived locally for update. Group is added to the library list after downloading.
    439 					if (!group || group.version < remoteGroupVersions[id] || group.archived) {
    440 						Zotero.debug(`Marking group ${id} to download`);
    441 						groupsToDownload.push(id);
    442 					}
    443 					// If not outdated, just add to library list
    444 					else {
    445 						Zotero.debug(`Adding group library ${group.libraryID} to sync`);
    446 						libraries.push(group.libraryID);
    447 					}
    448 				}
    449 				else {
    450 					// If specific libraries were provided, ignore remote groups that don't
    451 					// exist locally or aren't in the given list
    452 					if (!group || libraries.indexOf(group.libraryID) == -1) {
    453 						continue;
    454 					}
    455 					// If group metadata is outdated, mark for update
    456 					if (group.version < remoteGroupVersions[id]) {
    457 						groupsToDownload.push(id);
    458 					}
    459 				}
    460 			}
    461 			
    462 			// Get local groups (all if syncing all libraries or just selected ones) that don't
    463 			// exist remotely
    464 			// TODO: Use explicit removals?
    465 			let localGroups;
    466 			if (syncAllLibraries) {
    467 				localGroups = Zotero.Groups.getAll()
    468 					.map(g => g.id)
    469 					// Don't include skipped groups
    470 					.filter(id => skippedGroups.indexOf(id) == -1);
    471 			}
    472 			else {
    473 				localGroups = libraries
    474 					.filter(id => Zotero.Libraries.get(id).libraryType == 'group')
    475 					.map(id => Zotero.Groups.getGroupIDFromLibraryID(id))
    476 			}
    477 			Zotero.debug("Local groups:");
    478 			Zotero.debug(localGroups);
    479 			remotelyMissingGroups = Zotero.Utilities.arrayDiff(localGroups, remoteGroupIDs)
    480 				.map(id => Zotero.Groups.get(id));
    481 		}
    482 		// No group access
    483 		else {
    484 			remotelyMissingGroups = Zotero.Groups.getAll();
    485 		}
    486 		
    487 		if (remotelyMissingGroups.length) {
    488 			// TODO: What about explicit deletions?
    489 			
    490 			let removedGroups = [];
    491 			let keptGroups = [];
    492 			
    493 			let ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"]
    494 				.getService(Components.interfaces.nsIPromptService);
    495 			let buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING)
    496 				+ (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_IS_STRING)
    497 				+ (ps.BUTTON_POS_2) * (ps.BUTTON_TITLE_IS_STRING)
    498 				+ ps.BUTTON_DELAY_ENABLE;
    499 			
    500 			// Prompt for each group
    501 			//
    502 			// TODO: Localize
    503 			for (let group of remotelyMissingGroups) {
    504 				// Ignore remotely missing archived groups
    505 				if (group.archived) {
    506 					groupsToDownload = groupsToDownload.filter(groupID => groupID != group.id);
    507 					continue;
    508 				}
    509 				
    510 				let msg;
    511 				// If all-groups access but group is missing, user left it
    512 				if (access.groups && access.groups.all) {
    513 					msg = "You are no longer a member of the group \u2018" + group.name + "\u2019.";
    514 				}
    515 				// If not all-groups access, key might just not have access
    516 				else {
    517 					msg = "You no longer have access to the group \u2018" + group.name + "\u2019.";
    518 				}
    519 				
    520 				msg += "\n\n" + "Would you like to remove it from this computer or keep it "
    521 					+ "as a read-only library?";
    522 				
    523 				let index = ps.confirmEx(
    524 					null,
    525 					"Group Not Found",
    526 					msg,
    527 					buttonFlags,
    528 					"Remove Group",
    529 					// TODO: Any way to have Esc trigger extra1 instead so it doesn't
    530 					// have to be in this order?
    531 					"Cancel Sync",
    532 					"Keep Group",
    533 					null, {}
    534 				);
    535 				
    536 				if (index == 0) {
    537 					removedGroups.push(group);
    538 				}
    539 				else if (index == 1) {
    540 					Zotero.debug("Cancelling sync");
    541 					return [];
    542 				}
    543 				else if (index == 2) {
    544 					keptGroups.push(group);
    545 				}
    546 			}
    547 			
    548 			let removedLibraryIDs = [];
    549 			for (let group of removedGroups) {
    550 				removedLibraryIDs.push(group.libraryID);
    551 				yield group.eraseTx();
    552 			}
    553 			libraries = Zotero.Utilities.arrayDiff(libraries, removedLibraryIDs);
    554 			
    555 			let keptLibraryIDs = [];
    556 			for (let group of keptGroups) {
    557 				keptLibraryIDs.push(group.libraryID);
    558 				group.editable = false;
    559 				group.archived = true;
    560 				yield group.saveTx();
    561 			}
    562 			libraries = Zotero.Utilities.arrayDiff(libraries, keptLibraryIDs);
    563 		}
    564 		
    565 		// Update metadata and permissions on missing or outdated groups
    566 		for (let groupID of groupsToDownload) {
    567 			let info = yield client.getGroup(groupID);
    568 			if (!info) {
    569 				throw new Error("Group " + groupID + " not found");
    570 			}
    571 			let group = Zotero.Groups.get(groupID);
    572 			if (group) {
    573 				// Check if the user's permissions for the group have changed, and prompt to reset
    574 				// data if so
    575 				let { editable, filesEditable } = Zotero.Groups.getPermissionsFromJSON(
    576 					info.data, keyInfo.userID
    577 				);
    578 				let keepGoing = yield Zotero.Sync.Data.Local.checkLibraryForAccess(
    579 					null, group.libraryID, editable, filesEditable
    580 				);
    581 				// User chose to skip library
    582 				if (!keepGoing) {
    583 					Zotero.debug("Skipping sync of group " + group.id);
    584 					continue;
    585 				}
    586 			}
    587 			else {
    588 				group = new Zotero.Group;
    589 				group.id = groupID;
    590 			}
    591 			group.version = info.version;
    592 			group.archived = false;
    593 			group.fromJSON(info.data, Zotero.Users.getCurrentUserID());
    594 			yield group.saveTx();
    595 			
    596 			// Add group to library list
    597 			libraries.push(group.libraryID);
    598 		}
    599 		
    600 		// Note: If any non-group library types become archivable, they'll need to be unarchived here.
    601 		Zotero.debug("Final libraries to sync:");
    602 		Zotero.debug(libraries);
    603 		
    604 		return [...new Set(libraries)];
    605 	});
    606 	
    607 	
    608 	/**
    609 	 * Run sync engine for passed libraries
    610 	 *
    611 	 * @param {Integer[]} libraries
    612 	 * @param {Object} options
    613 	 * @param {Boolean} skipUpdateLastSyncTime
    614 	 * @return {Integer[]} - Array of libraryIDs that completed successfully
    615 	 */
    616 	var _doDataSync = Zotero.Promise.coroutine(function* (libraries, options, skipUpdateLastSyncTime) {
    617 		var successfulLibraries = [];
    618 		for (let libraryID of libraries) {
    619 			_stopCheck();
    620 			try {
    621 				let opts = {};
    622 				Object.assign(opts, options);
    623 				opts.libraryID = libraryID;
    624 				
    625 				_currentEngine = new Zotero.Sync.Data.Engine(opts);
    626 				yield _currentEngine.start();
    627 				_currentEngine = null;
    628 				successfulLibraries.push(libraryID);
    629 			}
    630 			catch (e) {
    631 				if (e instanceof Zotero.Sync.UserCancelledException) {
    632 					if (e.advanceToNextLibrary) {
    633 						Zotero.debug("Sync cancelled for library " + libraryID + " -- "
    634 							+ "advancing to next library");
    635 						continue;
    636 					}
    637 					throw e;
    638 				}
    639 				
    640 				Zotero.debug("Sync failed for library " + libraryID, 1);
    641 				Zotero.logError(e);
    642 				this.checkError(e);
    643 				options.onError(e);
    644 				if (stopOnError || e.fatal) {
    645 					Zotero.debug("Stopping on error", 1);
    646 					options.caller.stop();
    647 					break;
    648 				}
    649 			}
    650 		}
    651 		// Update last-sync time if any libraries synced
    652 		// TEMP: Do we want to show updated time if some libraries haven't synced?
    653 		if (!libraries.length || successfulLibraries.length) {
    654 			yield Zotero.Sync.Data.Local.updateLastSyncTime();
    655 		}
    656 		return successfulLibraries;
    657 	}.bind(this));
    658 	
    659 	
    660 	/**
    661 	 * @return {Integer[]} - Array of libraries that need data syncing again
    662 	 */
    663 	var _doFileSync = Zotero.Promise.coroutine(function* (libraries, options) {
    664 		Zotero.debug("Starting file syncing");
    665 		var resyncLibraries = []
    666 		for (let libraryID of libraries) {
    667 			_stopCheck();
    668 			let libraryName = Zotero.Libraries.get(libraryID).name;
    669 			this.setSyncStatus(
    670 				Zotero.getString('sync.status.syncingFilesInLibrary', libraryName)
    671 			);
    672 			try {
    673 				let opts = {
    674 					onProgress: (progress, progressMax) => {
    675 						var remaining = progressMax - progress;
    676 						this.setSyncStatus(
    677 							Zotero.getString(
    678 								'sync.status.syncingFilesInLibraryWithRemaining',
    679 								[libraryName, remaining],
    680 								remaining
    681 							)
    682 						);
    683 					}
    684 				};
    685 				Object.assign(opts, options);
    686 				opts.libraryID = libraryID;
    687 				
    688 				let mode = Zotero.Sync.Storage.Local.getModeForLibrary(libraryID);
    689 				opts.controller = this.getStorageController(mode, opts);
    690 				
    691 				let tries = 3;
    692 				while (true) {
    693 					if (tries == 0) {
    694 						throw new Error("Too many file sync attempts for library " + libraryID);
    695 					}
    696 					tries--;
    697 					_currentEngine = new Zotero.Sync.Storage.Engine(opts);
    698 					let results = yield _currentEngine.start();
    699 					_currentEngine = null;
    700 					if (results.syncRequired) {
    701 						resyncLibraries.push(libraryID);
    702 					}
    703 					else if (results.fileSyncRequired) {
    704 						Zotero.debug("Another file sync required -- restarting");
    705 						continue;
    706 					}
    707 					break;
    708 				}
    709 			}
    710 			catch (e) {
    711 				if (e instanceof Zotero.Sync.UserCancelledException) {
    712 					if (e.advanceToNextLibrary) {
    713 						Zotero.debug("Storage sync cancelled for library " + libraryID + " -- "
    714 							+ "advancing to next library");
    715 						continue;
    716 					}
    717 					throw e;
    718 				}
    719 				
    720 				Zotero.debug("File sync failed for library " + libraryID);
    721 				Zotero.logError(e);
    722 				this.checkError(e);
    723 				options.onError(e);
    724 				if (stopOnError || e.fatal) {
    725 					options.caller.stop();
    726 					break;
    727 				}
    728 			}
    729 		}
    730 		Zotero.debug("Done with file syncing");
    731 		if (resyncLibraries.length) {
    732 			Zotero.debug("Libraries to resync: " + resyncLibraries.join(", "));
    733 		}
    734 		return resyncLibraries;
    735 	}.bind(this));
    736 	
    737 	
    738 	/**
    739 	 * @return {Integer[]} - Array of libraries that need data syncing again
    740 	 */
    741 	var _doFullTextSync = Zotero.Promise.coroutine(function* (libraries, options) {
    742 		if (!Zotero.Prefs.get("sync.fulltext.enabled")) return [];
    743 		
    744 		Zotero.debug("Starting full-text syncing");
    745 		this.setSyncStatus(Zotero.getString('sync.status.syncingFullText'));
    746 		var resyncLibraries = [];
    747 		for (let libraryID of libraries) {
    748 			_stopCheck();
    749 			try {
    750 				let opts = {};
    751 				Object.assign(opts, options);
    752 				opts.libraryID = libraryID;
    753 				
    754 				_currentEngine = new Zotero.Sync.Data.FullTextEngine(opts);
    755 				yield _currentEngine.start();
    756 				_currentEngine = null;
    757 			}
    758 			catch (e) {
    759 				if (e instanceof Zotero.Sync.UserCancelledException) {
    760 					throw e;
    761 				}
    762 				
    763 				if (e instanceof Zotero.HTTP.UnexpectedStatusException && e.status == 412) {
    764 					resyncLibraries.push(libraryID);
    765 					continue;
    766 				}
    767 				Zotero.debug("Full-text sync failed for library " + libraryID);
    768 				Zotero.logError(e);
    769 				this.checkError(e);
    770 				options.onError(e);
    771 				if (stopOnError || e.fatal) {
    772 					options.caller.stop();
    773 					break;
    774 				}
    775 			}
    776 		}
    777 		Zotero.debug("Done with full-text syncing");
    778 		if (resyncLibraries.length) {
    779 			Zotero.debug("Libraries to resync: " + resyncLibraries.join(", "));
    780 		}
    781 		return resyncLibraries;
    782 	}.bind(this));
    783 	
    784 	
    785 	/**
    786 	 * Get a storage controller for a given mode ('zfs', 'webdav'),
    787 	 * caching it if necessary
    788 	 */
    789 	this.getStorageController = function (mode, options) {
    790 		if (_storageControllers[mode]) {
    791 			return _storageControllers[mode];
    792 		}
    793 		var modeClass = Zotero.Sync.Storage.Utilities.getClassForMode(mode);
    794 		return _storageControllers[mode] = new modeClass(options);
    795 	},
    796 	
    797 	
    798 	// TODO: Call on API key change
    799 	this.resetStorageController = function (mode) {
    800 		delete _storageControllers[mode];
    801 	},
    802 	
    803 	
    804 	/**
    805 	 * Download a single file on demand (not within a sync process)
    806 	 */
    807 	this.downloadFile = Zotero.Promise.coroutine(function* (item, requestCallbacks) {
    808 		if (Zotero.HTTP.browserIsOffline()) {
    809 			Zotero.debug("Browser is offline", 2);
    810 			return false;
    811 		}
    812 		
    813 		var apiKey = yield _getAPIKey();
    814 		if (!apiKey) {
    815 			Zotero.debug("API key not set -- skipping download");
    816 			return false;
    817 		}
    818 		
    819 		// TEMP
    820 		var options = {};
    821 		
    822 		var itemID = item.id;
    823 		var modeClass = Zotero.Sync.Storage.Local.getClassForLibrary(item.libraryID);
    824 		var controller = new modeClass({
    825 			apiClient: this.getAPIClient({apiKey })
    826 		});
    827 		
    828 		// TODO: verify WebDAV on-demand?
    829 		if (!controller.verified) {
    830 			Zotero.debug("File syncing is not active for item's library -- skipping download");
    831 			return false;
    832 		}
    833 		
    834 		if (!item.isImportedAttachment()) {
    835 			throw new Error("Not an imported attachment");
    836 		}
    837 		
    838 		if (yield item.getFilePathAsync()) {
    839 			Zotero.debug("File already exists -- replacing");
    840 		}
    841 		
    842 		// TODO: start sync icon?
    843 		// TODO: create queue for cancelling
    844 		
    845 		if (!requestCallbacks) {
    846 			requestCallbacks = {};
    847 		}
    848 		var onStart = function (request) {
    849 			return controller.downloadFile(request);
    850 		};
    851 		var request = new Zotero.Sync.Storage.Request({
    852 			type: 'download',
    853 			libraryID: item.libraryID,
    854 			name: item.libraryKey,
    855 			onStart: requestCallbacks.onStart
    856 				? [onStart, requestCallbacks.onStart]
    857 				: [onStart]
    858 		});
    859 		return request.start();
    860 	});
    861 	
    862 	
    863 	this.stop = function () {
    864 		this.setSyncStatus(Zotero.getString('sync.stopping'));
    865 		_stopping = true;
    866 		if (_currentEngine) {
    867 			_currentEngine.stop();
    868 		}
    869 	}
    870 	
    871 	
    872 	this.end = Zotero.Promise.coroutine(function* (options) {
    873 		_syncInProgress = false;
    874 		yield this.checkErrors(_errors, options);
    875 		if (!options.restartSync) {
    876 			this.updateIcons(_errors);
    877 		}
    878 		_errors = [];
    879 	});
    880 	
    881 	
    882 	/**
    883 	 * @param {Integer} timeout - Timeout in seconds
    884 	 * @param {Boolean} [recurring=false]
    885 	 * @param {Object} [options] - Sync options
    886 	 */
    887 	this.setSyncTimeout = function (timeout, recurring, options = {}) {
    888 		if (!Zotero.Prefs.get('sync.autoSync') || !this.enabled) {
    889 			return;
    890 		}
    891 		
    892 		if (!timeout) {
    893 			throw new Error("Timeout not provided");
    894 		}
    895 		
    896 		if (_autoSyncTimer) {
    897 			Zotero.debug("Cancelling auto-sync timer");
    898 			_autoSyncTimer.cancel();
    899 		}
    900 		else {
    901 			_autoSyncTimer = Components.classes["@mozilla.org/timer;1"].
    902 				createInstance(Components.interfaces.nsITimer);
    903 		}
    904 		
    905 		var mergedOpts = {
    906 			background: true
    907 		};
    908 		Object.assign(mergedOpts, options);
    909 		
    910 		// Implements nsITimerCallback
    911 		var callback = {
    912 			notify: async function (timer) {
    913 				if (!_getAPIKey()) {
    914 					return;
    915 				}
    916 				
    917 				// If a delay is set (e.g., from the connector target selector), wait to sync.
    918 				// We do this in sync() too for manual syncs, but no need to start spinning if
    919 				// it's just an auto-sync.
    920 				while (_delaySyncUntil && new Date() < _delaySyncUntil) {
    921 					let delay = _delaySyncUntil - new Date();
    922 					Zotero.debug(`Waiting ${delay} ms to start auto-sync`);
    923 					await Zotero.Promise.delay(delay);
    924 				}
    925 				
    926 				if (Zotero.locked) {
    927 					Zotero.debug('Zotero is locked -- skipping auto-sync', 4);
    928 					return;
    929 				}
    930 				
    931 				if (_syncInProgress) {
    932 					Zotero.debug('Sync already in progress -- skipping auto-sync', 4);
    933 					return;
    934 				}
    935 				
    936 				if (_manualSyncRequired) {
    937 					Zotero.debug('Manual sync required -- skipping auto-sync', 4);
    938 					return;
    939 				}
    940 				
    941 				this.sync(mergedOpts);
    942 			}.bind(this)
    943 		}
    944 		
    945 		if (recurring) {
    946 			Zotero.debug('Setting auto-sync interval to ' + timeout + ' seconds');
    947 			_autoSyncTimer.initWithCallback(
    948 				callback, timeout * 1000, Components.interfaces.nsITimer.TYPE_REPEATING_SLACK
    949 			);
    950 		}
    951 		else {
    952 			if (_syncInProgress) {
    953 				Zotero.debug('Sync in progress -- not setting auto-sync timeout', 4);
    954 				return;
    955 			}
    956 			
    957 			Zotero.debug('Setting auto-sync timeout to ' + timeout + ' seconds');
    958 			_autoSyncTimer.initWithCallback(
    959 				callback, timeout * 1000, Components.interfaces.nsITimer.TYPE_ONE_SHOT
    960 			);
    961 		}
    962 	}
    963 	
    964 	
    965 	this.clearSyncTimeout = function () {
    966 		if (_autoSyncTimer) {
    967 			_autoSyncTimer.cancel();
    968 		}
    969 	}
    970 	
    971 	
    972 	this.delaySync = function (ms) {
    973 		_delaySyncUntil = new Date(Date.now() + ms);
    974 	};
    975 	
    976 	
    977 	/**
    978 	 * Delay syncs until the returned function is called
    979 	 *
    980 	 * @return {Function} - Resolve function
    981 	 */
    982 	this.delayIndefinite = function () {
    983 		var resolve;
    984 		var promise = new Zotero.Promise(function () {
    985 			resolve = arguments[0];
    986 		});
    987 		_delayPromises.push(promise);
    988 		return resolve;
    989 	};
    990 	
    991 	
    992 	/**
    993 	 * Trigger updating of the main sync icon, the sync error icon, and
    994 	 * library-specific sync error icons across all windows
    995 	 */
    996 	this.addError = function (e, libraryID) {
    997 		if (e.added) return;
    998 		e.added = true;
    999 		if (libraryID) {
   1000 			e.libraryID = libraryID;
   1001 		}
   1002 		Zotero.logError(e);
   1003 		_errors.push(this.parseError(e));
   1004 	}
   1005 	
   1006 	
   1007 	this.getErrorsByLibrary = function (libraryID) {
   1008 		return _errors.filter(e => e.libraryID === libraryID);
   1009 	}
   1010 	
   1011 	
   1012 	/**
   1013 	 * Get most severe error type from an array of parsed errors
   1014 	 */
   1015 	this.getPrimaryErrorType = function (errors) {
   1016 		// Set highest priority error as the primary (sync error icon)
   1017 		var errorTypes = {
   1018 			info: 1,
   1019 			warning: 2,
   1020 			error: 3,
   1021 			upgrade: 4,
   1022 			
   1023 			// Skip these
   1024 			animate: -1
   1025 		};
   1026 		var state = false;
   1027 		for (let i = 0; i < errors.length; i++) {
   1028 			let e = errors[i];
   1029 			
   1030 			let errorType = e.errorType;
   1031 				
   1032 			if (e.fatal) {
   1033 				return 'error';
   1034 			}
   1035 			
   1036 			if (!errorType || errorTypes[errorType] < 0) {
   1037 				continue;
   1038 			}
   1039 			if (!state || errorTypes[errorType] > errorTypes[state]) {
   1040 				state = errorType;
   1041 			}
   1042 		}
   1043 		return state;
   1044 	}
   1045 	
   1046 	
   1047 	this.checkErrors = Zotero.Promise.coroutine(function* (errors, options = {}) {
   1048 		for (let e of errors) {
   1049 			let handled = yield this.checkError(e, options);
   1050 			if (handled) {
   1051 				break;
   1052 			}
   1053 		}
   1054 	});
   1055 	
   1056 	
   1057 	this.checkError = Zotero.Promise.coroutine(function* (e, options = {}) {
   1058 		if (e.name && e.name == 'Zotero Error') {
   1059 			switch (e.error) {
   1060 				case Zotero.Error.ERROR_API_KEY_NOT_SET:
   1061 				case Zotero.Error.ERROR_API_KEY_INVALID:
   1062 					// TODO: the setTimeout() call below should just simulate a click on the sync error icon
   1063 					// instead of creating its own dialog, but updateIcons() doesn't yet provide full control
   1064 					// over dialog title and primary button text/action, which is why this version of the
   1065 					// dialog is a bit uglier than the manual click version
   1066 					// TODO: localize (=>done) and combine with below (=>?)
   1067 					var msg = Zotero.getString('sync.error.invalidLogin.text');
   1068 					e.message = msg;
   1069 					e.dialogButtonText = Zotero.getString('sync.openSyncPreferences');
   1070 					e.dialogButtonCallback = function () {
   1071 						var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
   1072 								.getService(Components.interfaces.nsIWindowMediator);
   1073 						var win = wm.getMostRecentWindow("navigator:browser");
   1074 						win.ZoteroPane.openPreferences("zotero-prefpane-sync");
   1075 					};
   1076 					
   1077 					// Manual click
   1078 					if (!options.background) {
   1079 						setTimeout(function () {
   1080 							var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
   1081 										.getService(Components.interfaces.nsIWindowMediator);
   1082 							var win = wm.getMostRecentWindow("navigator:browser");
   1083 							
   1084 							var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"]
   1085 										.getService(Components.interfaces.nsIPromptService);
   1086 							var buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING)
   1087 												+ (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_CANCEL);
   1088 							if (e.error == Zotero.Error.ERROR_API_KEY_NOT_SET) {
   1089 								var title = Zotero.getString('sync.error.usernameNotSet');
   1090 								var msg = Zotero.getString('sync.error.usernameNotSet.text');
   1091 							}
   1092 							else {
   1093 								var title = Zotero.getString('sync.error.invalidLogin');
   1094 								var msg = Zotero.getString('sync.error.invalidLogin.text');
   1095 							}
   1096 							var index = ps.confirmEx(
   1097 								win,
   1098 								title,
   1099 								msg,
   1100 								buttonFlags,
   1101 								Zotero.getString('sync.openSyncPreferences'),
   1102 								null, null, null, {}
   1103 							);
   1104 							
   1105 							if (index == 0) {
   1106 								Zotero.Utilities.Internal.openPreferences("zotero-prefpane-sync");
   1107 								return;
   1108 							}
   1109 						}, 1);
   1110 					}
   1111 					break;
   1112 			}
   1113 		}
   1114 		else if (e.name && e.name == 'ZoteroObjectUploadError') {
   1115 			let { code, data, objectType, object } = e;
   1116 			
   1117 			if (code == 413) {
   1118 				// Collection name too long
   1119 				if (objectType == 'collection' && data && data.value) {
   1120 					e.message = Zotero.getString('sync.error.collectionTooLong', [data.value]);
   1121 					
   1122 					e.dialogButtonText = Zotero.getString('pane.collections.showCollectionInLibrary');
   1123 					e.dialogButtonCallback = () => {
   1124 						var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
   1125 							.getService(Components.interfaces.nsIWindowMediator);
   1126 						var win = wm.getMostRecentWindow("navigator:browser");
   1127 						win.ZoteroPane.collectionsView.selectCollection(object.id);
   1128 					};
   1129 				}
   1130 				else if (objectType == 'item') {
   1131 					// Tag too long
   1132 					if (data && data.tag !== undefined) {
   1133 						// Show long tag fixer and handle result
   1134 						e.dialogButtonText = Zotero.getString('general.fix');
   1135 						e.dialogButtonCallback = Zotero.Promise.coroutine(function* () {
   1136 							var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
   1137 							   .getService(Components.interfaces.nsIWindowMediator);
   1138 							var lastWin = wm.getMostRecentWindow("navigator:browser");
   1139 							
   1140 							// Open long tag fixer for every long tag in every editable library we're syncing
   1141 							var editableLibraries = options.libraries
   1142 								.filter(x => Zotero.Libraries.get(x).editable);
   1143 							for (let libraryID of editableLibraries) {
   1144 								let oldTagIDs = yield Zotero.Tags.getLongTagsInLibrary(libraryID);
   1145 								for (let oldTagID of oldTagIDs) {
   1146 									let oldTag = Zotero.Tags.getName(oldTagID);
   1147 									let dataOut = { result: null };
   1148 									lastWin.openDialog(
   1149 										'chrome://zotero/content/longTagFixer.xul',
   1150 										'',
   1151 										'chrome,modal,centerscreen',
   1152 										oldTag,
   1153 										dataOut
   1154 									);
   1155 									// If dialog was cancelled, stop
   1156 									if (!dataOut.result) {
   1157 										return;
   1158 									}
   1159 									switch (dataOut.result.op) {
   1160 									case 'split':
   1161 										for (let libraryID of editableLibraries) {
   1162 											let itemIDs = yield Zotero.Tags.getTagItems(libraryID, oldTagID);
   1163 											yield Zotero.DB.executeTransaction(function* () {
   1164 												for (let itemID of itemIDs) {
   1165 													let item = yield Zotero.Items.getAsync(itemID);
   1166 													for (let tag of dataOut.result.tags) {
   1167 														item.addTag(tag);
   1168 													}
   1169 													item.removeTag(oldTag);
   1170 													yield item.save();
   1171 												}
   1172 												yield Zotero.Tags.purge(oldTagID);
   1173 											});
   1174 										}
   1175 										break;
   1176 									
   1177 									case 'edit':
   1178 										for (let libraryID of editableLibraries) {
   1179 											let itemIDs = yield Zotero.Tags.getTagItems(libraryID, oldTagID);
   1180 											yield Zotero.DB.executeTransaction(function* () {
   1181 												for (let itemID of itemIDs) {
   1182 													let item = yield Zotero.Items.getAsync(itemID);
   1183 													item.replaceTag(oldTag, dataOut.result.tag);
   1184 													yield item.save();
   1185 												}
   1186 											});
   1187 										}
   1188 										break;
   1189 									
   1190 									case 'delete':
   1191 										for (let libraryID of editableLibraries) {
   1192 											yield Zotero.Tags.removeFromLibrary(libraryID, oldTagID);
   1193 										}
   1194 										break;
   1195 									}
   1196 								}
   1197 							}
   1198 							
   1199 							options.restartSync = true;
   1200 						});
   1201 					}
   1202 					else {
   1203 						// Note too long
   1204 						if (object.isNote() || object.isAttachment()) {
   1205 							// Throw an error that adds a button for selecting the item to the sync error dialog
   1206 							if (e.message.includes('<img src="data:image')) {
   1207 								e.message = Zotero.getString('sync.error.noteEmbeddedImage');
   1208 							}
   1209 							else if (e.message.match(/^Note '.*' too long for item/)) {
   1210 								e.message = Zotero.getString(
   1211 									'sync.error.noteTooLong',
   1212 									Zotero.Utilities.ellipsize(object.getNoteTitle(), 40)
   1213 								);
   1214 							}
   1215 						}
   1216 						// Field or creator too long
   1217 						else if (data && data.field) {
   1218 							e.message = (data.field == 'creator'
   1219 								? Zotero.getString(
   1220 									'sync.error.creatorTooLong',
   1221 									[data.value]
   1222 								)
   1223 								: Zotero.getString(
   1224 									'sync.error.fieldTooLong',
   1225 									[data.field, data.value]
   1226 								))
   1227 								+ '\n\n'
   1228 								+ Zotero.getString(
   1229 									'sync.error.reportSiteIssuesToForums',
   1230 									Zotero.clientName
   1231 								);
   1232 						}
   1233 						
   1234 						// Include "Show Item in Library" button
   1235 						e.dialogButtonText = Zotero.getString('pane.items.showItemInLibrary');
   1236 						e.dialogButtonCallback = () => {
   1237 							var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
   1238 								.getService(Components.interfaces.nsIWindowMediator);
   1239 							var win = wm.getMostRecentWindow("navigator:browser");
   1240 							win.ZoteroPane.selectItem(object.id);
   1241 						};
   1242 					}
   1243 				}
   1244 				
   1245 				// If not a background sync, show dialog immediately
   1246 				if (!options.background && e.dialogButtonCallback) {
   1247 					let maybePromise = e.dialogButtonCallback();
   1248 					if (maybePromise && maybePromise.then) {
   1249 						yield maybePromise;
   1250 					}
   1251 				}
   1252 			}
   1253 		}
   1254 	});
   1255 	
   1256 	
   1257 	/**
   1258 	 * Set the sync icon and sync error icon across all windows
   1259 	 *
   1260 	 * @param {Error|Error[]|'animate'} errors - An error, an array of errors, or 'animate' to
   1261 	 *                                           spin the icon. An empty array will reset the
   1262 	 *                                           icons.
   1263 	 */
   1264 	this.updateIcons = function (errors) {
   1265 		if (typeof errors == 'string') {
   1266 			var state = errors;
   1267 			errors = [];
   1268 		}
   1269 		else {
   1270 			if (!Array.isArray(errors)) {
   1271 				errors = [errors];
   1272 			}
   1273 			var state = this.getPrimaryErrorType(errors);
   1274 		}
   1275 		
   1276 		// Refresh source list
   1277 		//yield Zotero.Notifier.trigger('redraw', 'collection', []);
   1278 		
   1279 		if (errors.length == 1 && errors[0].frontWindowOnly) {
   1280 			// Fake an nsISimpleEnumerator with just the topmost window
   1281 			var enumerator = {
   1282 				_returned: false,
   1283 				hasMoreElements: function () {
   1284 					return !this._returned;
   1285 				},
   1286 				getNext: function () {
   1287 					if (this._returned) {
   1288 						throw ("No more windows to return in Zotero.Sync.Runner.updateIcons()");
   1289 					}
   1290 					this._returned = true;
   1291 					var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
   1292 								.getService(Components.interfaces.nsIWindowMediator);
   1293 					return wm.getMostRecentWindow("navigator:browser");
   1294 				}
   1295 			};
   1296 		}
   1297 		// Update all windows
   1298 		else {
   1299 			var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
   1300 						.getService(Components.interfaces.nsIWindowMediator);
   1301 			var enumerator = wm.getEnumerator('navigator:browser');
   1302 		}
   1303 		
   1304 		while (enumerator.hasMoreElements()) {
   1305 			var win = enumerator.getNext();
   1306 			if (!win.ZoteroPane) continue;
   1307 			var doc = win.ZoteroPane.document;
   1308 			
   1309 			// Update sync error icon
   1310 			var icon = doc.getElementById('zotero-tb-sync-error');
   1311 			this.updateErrorIcon(icon, state, errors);
   1312 			
   1313 			// Update sync icon
   1314 			var syncIcon = doc.getElementById('zotero-tb-sync');
   1315 			var stopIcon = doc.getElementById('zotero-tb-sync-stop');
   1316 			if (state == 'animate') {
   1317 				syncIcon.setAttribute('status', state);
   1318 				// Disable button while spinning
   1319 				syncIcon.disabled = true;
   1320 				stopIcon.hidden = false;
   1321 			}
   1322 			else {
   1323 				syncIcon.removeAttribute('status');
   1324 				syncIcon.disabled = false;
   1325 				stopIcon.hidden = true;
   1326 			}
   1327 		}
   1328 		
   1329 		// Clear status
   1330 		this.setSyncStatus();
   1331 	}
   1332 	
   1333 	
   1334 	/**
   1335 	 * Set the sync icon tooltip message
   1336 	 */
   1337 	this.setSyncStatus = function (msg) {
   1338 		_lastSyncStatus = msg;
   1339 		
   1340 		// If a label is registered, update it
   1341 		if (_currentSyncStatusLabel) {
   1342 			_updateSyncStatusLabel();
   1343 		}
   1344 	}
   1345 	
   1346 	
   1347 	this.parseError = function (e) {
   1348 		if (!e) {
   1349 			return { parsed: true };
   1350 		}
   1351 		
   1352 		// Already parsed
   1353 		if (e.parsed) {
   1354 			return e;
   1355 		}
   1356 		
   1357 		e.parsed = true;
   1358 		e.errorType = e.errorType ? e.errorType : 'error';
   1359 		
   1360 		return e;
   1361 	}
   1362 	
   1363 	
   1364 	/**
   1365 	 * Set the state of the sync error icon and add an onclick to populate
   1366 	 * the error panel
   1367 	 */
   1368 	this.updateErrorIcon = function (icon, state, errors) {
   1369 		if (!errors || !errors.length) {
   1370 			icon.hidden = true;
   1371 			icon.onclick = null;
   1372 			return;
   1373 		}
   1374 		
   1375 		icon.hidden = false;
   1376 		icon.setAttribute('state', state);
   1377 		var self = this;
   1378 		icon.onclick = function () {
   1379 			var panel = self.updateErrorPanel(this.ownerDocument, errors);
   1380 			panel.openPopup(this, "after_end", 16, 0, false, false);
   1381 		};
   1382 	}
   1383 	
   1384 	
   1385 	this.updateErrorPanel = function (doc, errors) {
   1386 		var panel = doc.getElementById('zotero-sync-error-panel');
   1387 		
   1388 		// Clear existing panel content
   1389 		while (panel.hasChildNodes()) {
   1390 			panel.removeChild(panel.firstChild);
   1391 		}
   1392 		
   1393 		for (let e of errors) {
   1394 			var box = doc.createElement('vbox');
   1395 			var label = doc.createElement('label');
   1396 			if (e.libraryID !== undefined) {
   1397 				label.className = "zotero-sync-error-panel-library-name";
   1398 				if (e.libraryID == 0) {
   1399 					var libraryName = Zotero.getString('pane.collections.library');
   1400 				}
   1401 				else {
   1402 					let group = Zotero.Groups.getByLibraryID(e.libraryID);
   1403 					var libraryName = group.name;
   1404 				}
   1405 				label.setAttribute('value', libraryName);
   1406 			}
   1407 			var content = doc.createElement('hbox');
   1408 			var buttons = doc.createElement('hbox');
   1409 			buttons.pack = 'end';
   1410 			box.appendChild(label);
   1411 			box.appendChild(content);
   1412 			box.appendChild(buttons);
   1413 			
   1414 			// Show our own error mesages directly
   1415 			if (e instanceof Zotero.Error) {
   1416 				var msg = e.message;
   1417 			}
   1418 			// For unexpected ones, just show a generic message
   1419 			else {
   1420 				if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
   1421 					msg = Zotero.Utilities.ellipsize(e.xmlhttp.responseText, 1000, true);
   1422 				}
   1423 				else {
   1424 					// TODO: Localize
   1425 					var msg = "An error occurred during syncing:\n\n" + e.message;
   1426 				}
   1427 			}
   1428 			
   1429 			var desc = doc.createElement('description');
   1430 			desc.textContent = msg;
   1431 			// Make the text selectable
   1432 			desc.setAttribute('style', '-moz-user-select: text; cursor: text');
   1433 			content.appendChild(desc);
   1434 			
   1435 			/*// If not an error and there's no explicit button text, don't show
   1436 			// button to report errors
   1437 			if (e.errorType != 'error' && e.dialogButtonText === undefined) {
   1438 				e.dialogButtonText = null;
   1439 			}*/
   1440 			
   1441 			if (e.dialogButtonText !== null) {
   1442 				if (e.dialogButtonText === undefined) {
   1443 					var buttonText = Zotero.getString('errorReport.reportError');
   1444 					var buttonCallback = function () {
   1445 						doc.defaultView.ZoteroPane.reportErrors();
   1446 					};
   1447 				}
   1448 				else {
   1449 					var buttonText = e.dialogButtonText;
   1450 					var buttonCallback = e.dialogButtonCallback;
   1451 				}
   1452 				
   1453 				var button = doc.createElement('button');
   1454 				button.setAttribute('label', buttonText);
   1455 				button.onclick = function () {
   1456 					buttonCallback();
   1457 					panel.hidePopup();
   1458 				};
   1459 				buttons.appendChild(button);
   1460 			}
   1461 			
   1462 			panel.appendChild(box)
   1463 			break;
   1464 		}
   1465 		
   1466 		return panel;
   1467 	}
   1468 	
   1469 	
   1470 	/**
   1471 	 * Register labels in sync icon tooltip to receive updates
   1472 	 *
   1473 	 * If no label passed, unregister current label
   1474 	 *
   1475 	 * @param {Tooltip} [tooltip]
   1476 	 */
   1477 	this.registerSyncStatus = function (tooltip) {
   1478 		if (tooltip) {
   1479 			_currentSyncStatusLabel = tooltip.firstChild.nextSibling;
   1480 			_currentLastSyncLabel = tooltip.firstChild.nextSibling.nextSibling;
   1481 		}
   1482 		else {
   1483 			_currentSyncStatusLabel = null;
   1484 			_currentLastSyncLabel = null;
   1485 		}
   1486 		if (_currentSyncStatusLabel) {
   1487 			_updateSyncStatusLabel();
   1488 		}
   1489 	}
   1490 
   1491 
   1492 	this.createAPIKeyFromCredentials = Zotero.Promise.coroutine(function* (username, password) {
   1493 		var client = this.getAPIClient();
   1494 		var json = yield client.createAPIKeyFromCredentials(username, password);
   1495 		if (!json) {
   1496 			return false;
   1497 		}
   1498 
   1499 		// Sanity check
   1500 		if (!json.userID) throw new Error("userID not found in key response");
   1501 		if (!json.username) throw new Error("username not found in key response");
   1502 		if (!json.access) throw new Error("'access' not found in key response");
   1503 
   1504 		Zotero.Sync.Data.Local.setAPIKey(json.key);
   1505 
   1506 		return json;
   1507 	})
   1508 
   1509 
   1510 	this.deleteAPIKey = Zotero.Promise.coroutine(function* (){
   1511 		var apiKey = yield Zotero.Sync.Data.Local.getAPIKey();
   1512 		var client = this.getAPIClient({apiKey});
   1513 		Zotero.Sync.Data.Local.setAPIKey();
   1514 		yield client.deleteAPIKey();
   1515 	})
   1516 
   1517 	
   1518 	function _updateSyncStatusLabel() {
   1519 		if (_lastSyncStatus) {
   1520 			_currentSyncStatusLabel.value = _lastSyncStatus;
   1521 			_currentSyncStatusLabel.hidden = false;
   1522 		}
   1523 		else {
   1524 			_currentSyncStatusLabel.hidden = true;
   1525 		}
   1526 		
   1527 		// Always update last sync time
   1528 		var lastSyncTime = Zotero.Sync.Data.Local.getLastSyncTime();
   1529 		if (!lastSyncTime) {
   1530 			try {
   1531 				lastSyncTime = Zotero.Sync.Data.Local.getLastClassicSyncTime();
   1532 			}
   1533 			catch (e) {
   1534 				Zotero.debug(e, 2);
   1535 				Components.utils.reportError(e);
   1536 				_currentLastSyncLabel.hidden = true;
   1537 				return;
   1538 			}
   1539 		}
   1540 		if (lastSyncTime) {
   1541 			var msg = Zotero.Date.toRelativeDate(lastSyncTime);
   1542 		}
   1543 		// Don't show "Not yet synced" if a sync is in progress
   1544 		else if (_syncInProgress) {
   1545 			_currentLastSyncLabel.hidden = true;
   1546 			return;
   1547 		}
   1548 		else {
   1549 			var msg = Zotero.getString('sync.status.notYetSynced');
   1550 		}
   1551 		
   1552 		_currentLastSyncLabel.value = Zotero.getString('sync.status.lastSync') + " " + msg;
   1553 		_currentLastSyncLabel.hidden = false;
   1554 	}
   1555 	
   1556 	
   1557 	var _getAPIKey = Zotero.Promise.method(function () {
   1558 		// Set as .apiKey on Runner in tests or set in login manager
   1559 		return _apiKey || Zotero.Sync.Data.Local.getAPIKey()
   1560 	})
   1561 	
   1562 	
   1563 	function _stopCheck() {
   1564 		if (_stopping) {
   1565 			throw new Zotero.Sync.UserCancelledException;
   1566 		}
   1567 	}
   1568 }