www

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

syncLocal.js (58534B)


      1 /*
      2     ***** BEGIN LICENSE BLOCK *****
      3     
      4     Copyright © 2014 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 if (!Zotero.Sync.Data) {
     27 	Zotero.Sync.Data = {};
     28 }
     29 
     30 Zotero.Sync.Data.Local = {
     31 	_syncQueueIntervals: [0.5, 1, 4, 16, 16, 16, 16, 16, 16, 16, 64], // hours
     32 	_loginManagerHost: 'chrome://zotero',
     33 	_loginManagerRealm: 'Zotero Web API',
     34 	_lastSyncTime: null,
     35 	_lastClassicSyncTime: null,
     36 	
     37 	init: Zotero.Promise.coroutine(function* () {
     38 		yield this._loadLastSyncTime();
     39 		if (!_lastSyncTime) {
     40 			yield this._loadLastClassicSyncTime();
     41 		}
     42 	}),
     43 	
     44 	
     45 	/**
     46 	 * @return {Promise}
     47 	 */
     48 	getAPIKey: Zotero.Promise.method(function () {
     49 		var login = this._getAPIKeyLoginInfo();
     50 		return login
     51 			? login.password
     52 			// Fallback to old username/password
     53 			: this._getAPIKeyFromLogin();
     54 	}),
     55 	
     56 	
     57 	/**
     58 	 * Check for an API key or a legacy username/password (which may or may not be valid)
     59 	 */
     60 	hasCredentials: function () {
     61 		var login = this._getAPIKeyLoginInfo();
     62 		if (login) {
     63 			return true;
     64 		}
     65 		// If no API key, check for legacy login
     66 		var username = Zotero.Prefs.get('sync.server.username');
     67 		return username && !!this.getLegacyPassword(username)
     68 	},
     69 	
     70 	
     71 	setAPIKey: function (apiKey) {
     72 		var loginManager = Components.classes["@mozilla.org/login-manager;1"]
     73 			.getService(Components.interfaces.nsILoginManager);
     74 		
     75 		var oldLoginInfo = this._getAPIKeyLoginInfo();
     76 		
     77 		// Clear old login
     78 		if ((!apiKey || apiKey === "")) {
     79 			if (oldLoginInfo) {
     80 				Zotero.debug("Clearing old API key");
     81 				loginManager.removeLogin(oldLoginInfo);
     82 			}
     83 			Zotero.Notifier.trigger('delete', 'api-key', []);
     84 			return;
     85 		}
     86 		
     87 		var nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
     88 				Components.interfaces.nsILoginInfo, "init");
     89 		var loginInfo = new nsLoginInfo(
     90 			this._loginManagerHost,
     91 			null,
     92 			this._loginManagerRealm,
     93 			'API Key',
     94 			apiKey,
     95 			'',
     96 			''
     97 		);
     98 		if (!oldLoginInfo) {
     99 			Zotero.debug("Setting API key");
    100 			loginManager.addLogin(loginInfo);
    101 		}
    102 		else {
    103 			Zotero.debug("Replacing API key");
    104 			loginManager.modifyLogin(oldLoginInfo, loginInfo);
    105 		}
    106 		Zotero.Notifier.trigger('modify', 'api-key', []);
    107 	},
    108 	
    109 	
    110 	/**
    111 	 * Make sure we're syncing with the same account we used last time, and prompt if not.
    112 	 * If user accepts, change the current user and initiate deletion of all user data after a
    113 	 * restart.
    114 	 *
    115 	 * @param {Window|null}
    116 	 * @param {Integer} userID - New userID
    117 	 * @param {Integer} username - New username
    118 	 * @return {Boolean} - True to continue, false to cancel
    119 	 */
    120 	checkUser: Zotero.Promise.coroutine(function* (win, userID, username) {
    121 		var lastUserID = Zotero.Users.getCurrentUserID();
    122 		var lastUsername = Zotero.Users.getCurrentUsername();
    123 		
    124 		if (lastUserID && lastUserID != userID) {
    125 			Zotero.debug(`Last user id ${lastUserID}, current user id ${userID}, `
    126 				+ `last username '${lastUsername}', current username '${username}'`, 2);
    127 			var io = {
    128 				title: Zotero.getString('general.warning'),
    129 				text: [Zotero.getString('account.lastSyncWithDifferentAccount', [ZOTERO_CONFIG.CLIENT_NAME, lastUsername, username])],
    130 				checkboxLabel: Zotero.getString('account.confirmDelete'),
    131 				acceptLabel: Zotero.getString('account.confirmDelete.button')
    132 			};
    133 			win.openDialog("chrome://zotero/content/hardConfirmationDialog.xul", "",
    134 				"chrome, dialog, modal, centerscreen", io);
    135 					
    136 			if (io.accept) {
    137 				var resetDataDirFile = OS.Path.join(Zotero.DataDirectory.dir, 'reset-data-directory');
    138 				yield Zotero.File.putContentsAsync(resetDataDirFile, '');
    139 				
    140 				Zotero.Prefs.clear('sync.storage.downloadMode.groups');
    141 				Zotero.Prefs.clear('sync.storage.groups.enabled');
    142 				Zotero.Prefs.clear('sync.storage.downloadMode.personal');
    143 				Zotero.Prefs.clear('sync.storage.username');
    144 				Zotero.Prefs.clear('sync.storage.url');
    145 				Zotero.Prefs.clear('sync.storage.scheme');
    146 				Zotero.Prefs.clear('sync.storage.protocol');
    147 				Zotero.Prefs.clear('sync.storage.enabled');
    148 				
    149 				Zotero.Utilities.Internal.quitZotero(true);
    150 				
    151 				return true;
    152 			}
    153 			
    154 			return false;
    155 		}
    156 		
    157 		yield Zotero.DB.executeTransaction(function* () {
    158 			if (lastUsername != username) {
    159 				yield Zotero.Users.setCurrentUsername(username);
    160 			} 
    161 			if (!lastUserID) {
    162 				yield Zotero.Users.setCurrentUserID(userID);
    163 				
    164 				// Replace local user key with libraryID, in case duplicates were merged before the
    165 				// first sync
    166 				yield Zotero.Relations.updateUser(null, userID);
    167 			}
    168 		});
    169 		
    170 		return true;
    171 	}),
    172 	
    173 	
    174 	/**
    175 	 * @return {Promise<Boolean>} - True if library updated, false to cancel
    176 	 */
    177 	checkLibraryForAccess: Zotero.Promise.coroutine(function* (win, libraryID, editable, filesEditable) {
    178 		var library = Zotero.Libraries.get(libraryID);
    179 		
    180 		// If library is going from editable to non-editable and there's unsynced local data, prompt
    181 		if (library.editable && !editable && (yield this._libraryHasUnsyncedData(libraryID))) {
    182 			let index = Zotero.Sync.Data.Utilities.showWriteAccessLostPrompt(win, library);
    183 			// Reset library
    184 			if (index == 0) {
    185 				// This check happens before item data is loaded for syncing, so do it now,
    186 				// since the reset requires it
    187 				if (!library.getDataLoaded('item')) {
    188 					yield library.waitForDataLoad('item');
    189 				}
    190 				yield this.resetUnsyncedLibraryData(libraryID);
    191 				return true;
    192 			}
    193 			
    194 			// Skip library
    195 			return false;
    196 		}
    197 		
    198 		if (library.filesEditable && !filesEditable && (yield this._libraryHasUnsyncedFiles(libraryID))) {
    199 			let index = Zotero.Sync.Storage.Utilities.showFileWriteAccessLostPrompt(win, library);
    200 			// Reset library files
    201 			if (index == 0) {
    202 				// This check happens before item data is loaded for syncing, so do it now,
    203 				// since the reset requires it
    204 				if (!library.getDataLoaded('item')) {
    205 					yield library.waitForDataLoad('item');
    206 				}
    207 				yield this.resetUnsyncedLibraryFiles(libraryID);
    208 				return true;
    209 			}
    210 			
    211 			// Skip library
    212 			return false;
    213 		}
    214 		
    215 		return true;
    216 	}),
    217 	
    218 	
    219 	_libraryHasUnsyncedData: Zotero.Promise.coroutine(function* (libraryID) {
    220 		let settings = yield Zotero.SyncedSettings.getUnsynced(libraryID);
    221 		if (Object.keys(settings).length) {
    222 			return true;
    223 		}
    224 		
    225 		for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(libraryID)) {
    226 			let ids = yield Zotero.Sync.Data.Local.getUnsynced(objectType, libraryID);
    227 			if (ids.length) {
    228 				return true;
    229 			}
    230 			
    231 			let keys = yield Zotero.Sync.Data.Local.getDeleted(objectType, libraryID);
    232 			if (keys.length) {
    233 				return true;
    234 			}
    235 		}
    236 		
    237 		return false;
    238 	}),
    239 	
    240 	
    241 	_libraryHasUnsyncedFiles: Zotero.Promise.coroutine(function* (libraryID) {
    242 		// TODO: Check for modified file attachment items, which also can't be uploaded
    243 		// (and which are corrected by resetUnsyncedLibraryFiles())
    244 		yield Zotero.Sync.Storage.Local.checkForUpdatedFiles(libraryID);
    245 		return !!(yield Zotero.Sync.Storage.Local.getFilesToUpload(libraryID)).length;
    246 	}),
    247 	
    248 	
    249 	resetUnsyncedLibraryData: Zotero.Promise.coroutine(function* (libraryID) {
    250 		let settings = yield Zotero.SyncedSettings.getUnsynced(libraryID);
    251 		if (Object.keys(settings).length) {
    252 			yield Zotero.Promise.each(Object.keys(settings), function (key) {
    253 				return Zotero.SyncedSettings.clear(libraryID, key, { skipDeleteLog: true });
    254 			});
    255 		}
    256 		
    257 		for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(libraryID)) {
    258 			// New/modified objects
    259 			let ids = yield this.getUnsynced(objectType, libraryID);
    260 			yield this._resetObjects(libraryID, objectType, ids);
    261 		}
    262 		
    263 		// Mark library for full sync
    264 		var library = Zotero.Libraries.get(libraryID);
    265 		library.libraryVersion = -1;
    266 		yield library.saveTx();
    267 		
    268 		yield this.resetUnsyncedLibraryFiles(libraryID);
    269 	}),
    270 	
    271 	
    272 	/**
    273 	 * Delete unsynced files from library
    274 	 *
    275 	 * _libraryHasUnsyncedFiles(), which checks for updated files, must be called first.
    276 	 */
    277 	resetUnsyncedLibraryFiles: async function (libraryID) {
    278 		// Reset unsynced file attachments
    279 		var itemIDs = await Zotero.Sync.Data.Local.getUnsynced('item', libraryID);
    280 		var toReset = [];
    281 		for (let itemID of itemIDs) {
    282 			let item = Zotero.Items.get(itemID);
    283 			if (item.isFileAttachment()) {
    284 				toReset.push(item.id);
    285 			}
    286 		}
    287 		await this._resetObjects(libraryID, 'item', toReset);
    288 		
    289 		// Delete unsynced files
    290 		var itemIDs = await Zotero.Sync.Storage.Local.getFilesToUpload(libraryID);
    291 		for (let itemID of itemIDs) {
    292 			let item = Zotero.Items.get(itemID);
    293 			await item.deleteAttachmentFile();
    294 		}
    295 	},
    296 	
    297 	
    298 	_resetObjects: async function (libraryID, objectType, ids) {
    299 		var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
    300 		
    301 		var keys = ids.map(id => objectsClass.getLibraryAndKeyFromID(id).key);
    302 		var cacheVersions = await this.getLatestCacheObjectVersions(objectType, libraryID, keys);
    303 		var toDelete = [];
    304 		for (let key of keys) {
    305 			let obj = objectsClass.getByLibraryAndKey(libraryID, key);
    306 			
    307 			// If object is in cache, overwrite with pristine data
    308 			if (cacheVersions[key]) {
    309 				let json = await this.getCacheObject(objectType, libraryID, key, cacheVersions[key]);
    310 				await Zotero.DB.executeTransaction(async function () {
    311 					await this._saveObjectFromJSON(obj, json, {});
    312 				}.bind(this));
    313 			}
    314 			// Otherwise, erase
    315 			else {
    316 				toDelete.push(objectsClass.getIDFromLibraryAndKey(libraryID, key));
    317 			}
    318 		}
    319 		if (toDelete.length) {
    320 			await objectsClass.erase(
    321 				toDelete,
    322 				{
    323 					skipEditCheck: true,
    324 					skipDeleteLog: true
    325 				}
    326 			);
    327 		}
    328 		
    329 		// Deleted objects
    330 		keys = await Zotero.Sync.Data.Local.getDeleted(objectType, libraryID);
    331 		await this.removeObjectsFromDeleteLog(objectType, libraryID, keys);
    332 	},
    333 	
    334 	
    335 	getSkippedLibraries: function () {
    336 		return this._getSkippedLibrariesByPrefix("L");
    337 	},
    338 	
    339 	
    340 	getSkippedGroups: function () {
    341 		return this._getSkippedLibrariesByPrefix("G");
    342 	},
    343 	
    344 	
    345 	_getSkippedLibrariesByPrefix: function (prefix) {
    346 		var pref = 'sync.librariesToSkip';
    347 		try {
    348 			var librariesToSkip = JSON.parse(Zotero.Prefs.get(pref) || '[]');
    349 			return librariesToSkip
    350 				.filter(id => id.startsWith(prefix))
    351 				.map(id => parseInt(id.substr(1)));
    352 		}
    353 		catch (e) {
    354 			Zotero.logError(e);
    355 			Zotero.Prefs.clear(pref);
    356 			return [];
    357 		}
    358 	},
    359 	
    360 	
    361 	/**
    362 	 * @param {Zotero.Library[]} libraries
    363 	 * @return {Zotero.Library[]}
    364 	 */
    365 	filterSkippedLibraries: function (libraries) {
    366 		var skippedLibraries = this.getSkippedLibraries();
    367 		var skippedGroups = this.getSkippedGroups();
    368 		
    369 		return libraries.filter((library) => {
    370 			var libraryType = library.libraryType;
    371 			if (libraryType == 'group') {
    372 				return !skippedGroups.includes(library.groupID);
    373 			}
    374 			return !skippedLibraries.includes(library.libraryID);
    375 		});
    376 	},
    377 	
    378 	
    379 	/**
    380 	 * @return {nsILoginInfo|false}
    381 	 */
    382 	_getAPIKeyLoginInfo: function () {
    383 		try {
    384 			var loginManager = Components.classes["@mozilla.org/login-manager;1"]
    385 				.getService(Components.interfaces.nsILoginManager);
    386 			var logins = loginManager.findLogins(
    387 				{},
    388 				this._loginManagerHost,
    389 				null,
    390 				this._loginManagerRealm
    391 			);
    392 		}
    393 		catch (e) {
    394 			Zotero.logError(e);
    395 			if (Zotero.isStandalone) {
    396 				var msg = Zotero.getString('sync.error.loginManagerCorrupted1', Zotero.appName) + "\n\n"
    397 					+ Zotero.getString('sync.error.loginManagerCorrupted2', [Zotero.appName, Zotero.appName]);
    398 			}
    399 			else {
    400 				var msg = Zotero.getString('sync.error.loginManagerInaccessible') + "\n\n"
    401 					+ Zotero.getString('sync.error.checkMasterPassword', Zotero.appName) + "\n\n"
    402 					+ Zotero.getString('sync.error.corruptedLoginManager', Zotero.appName);
    403 			}
    404 			var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"]
    405 				.getService(Components.interfaces.nsIPromptService);
    406 			ps.alert(null, Zotero.getString('general.error'), msg);
    407 			return false;
    408 		}
    409 		
    410 		// Get API from returned array of nsILoginInfo objects
    411 		return logins.length ? logins[0] : false;
    412 	},
    413 	
    414 	
    415 	_getAPIKeyFromLogin: Zotero.Promise.coroutine(function* () {
    416 		let username = Zotero.Prefs.get('sync.server.username');
    417 		if (username) {
    418 			// Check for legacy password if no password set in current session
    419 			// and no API keys stored yet
    420 			let password = this.getLegacyPassword(username);
    421 			if (!password) {
    422 				return "";
    423 			}
    424 			
    425 			let json = yield Zotero.Sync.Runner.createAPIKeyFromCredentials(username, password);
    426 			this.removeLegacyLogins();
    427 			return json.key;
    428 		}
    429 		return "";
    430 	}),
    431 	
    432 	
    433 	getLegacyPassword: function (username) {
    434 		var loginManagerHost = 'chrome://zotero';
    435 		var loginManagerRealm = 'Zotero Sync Server';
    436 		
    437 		Zotero.debug('Getting Zotero sync password');
    438 		
    439 		var loginManager = Components.classes["@mozilla.org/login-manager;1"]
    440 			.getService(Components.interfaces.nsILoginManager);
    441 		try {
    442 			var logins = loginManager.findLogins({}, loginManagerHost, null, loginManagerRealm);
    443 		}
    444 		catch (e) {
    445 			Zotero.logError(e);
    446 			return '';
    447 		}
    448 		
    449 		// Find user from returned array of nsILoginInfo objects
    450 		for (let i = 0; i < logins.length; i++) {
    451 			if (logins[i].username == username) {
    452 				return logins[i].password;
    453 			}
    454 		}
    455 		
    456 		// Pre-4.0.28.5 format, broken for findLogins and removeLogin in Fx41,
    457 		var logins = loginManager.findLogins({}, loginManagerHost, "", null);
    458 		for (let i = 0; i < logins.length; i++) {
    459 			if (logins[i].username == username
    460 					&& logins[i].formSubmitURL == "Zotero Sync Server") {
    461 				return logins[i].password;
    462 			}
    463 		}
    464 		return '';
    465 	},
    466 	
    467 	
    468 	removeLegacyLogins: function () {
    469 		var loginManagerHost = 'chrome://zotero';
    470 		var loginManagerRealm = 'Zotero Sync Server';
    471 		
    472 		Zotero.debug('Removing legacy Zotero sync credentials (api key acquired)');
    473 		
    474 		var loginManager = Components.classes["@mozilla.org/login-manager;1"]
    475 			.getService(Components.interfaces.nsILoginManager);
    476 		try {
    477 			var logins = loginManager.findLogins({}, loginManagerHost, null, loginManagerRealm);
    478 		}
    479 		catch (e) {
    480 			Zotero.logError(e);
    481 			return '';
    482 		}
    483 		
    484 		// Remove all legacy users
    485 		for (let login of logins) {
    486 			loginManager.removeLogin(login);
    487 		}
    488 		// Remove the legacy pref
    489 		Zotero.Prefs.clear('sync.server.username');
    490 	},
    491 	
    492 	
    493 	getLastSyncTime: function () {
    494 		if (_lastSyncTime === null) {
    495 			throw new Error("Last sync time not yet loaded");
    496 		}
    497 		return _lastSyncTime;
    498 	},
    499 	
    500 	
    501 	/**
    502 	 * @return {Promise}
    503 	 */
    504 	updateLastSyncTime: function () {
    505 		_lastSyncTime = new Date();
    506 		return Zotero.DB.queryAsync(
    507 			"REPLACE INTO version (schema, version) VALUES ('lastsync', ?)",
    508 			Math.round(_lastSyncTime.getTime() / 1000)
    509 		);
    510 	},
    511 	
    512 	
    513 	_loadLastSyncTime: Zotero.Promise.coroutine(function* () {
    514 		var sql = "SELECT version FROM version WHERE schema='lastsync'";
    515 		var lastsync = yield Zotero.DB.valueQueryAsync(sql);
    516 		_lastSyncTime = (lastsync ? new Date(lastsync * 1000) : false);
    517 	}),
    518 	
    519 	
    520 	/**
    521 	 * @param {String} objectType
    522 	 * @param {Integer} libraryID
    523 	 * @return {Promise<String[]>} - A promise for an array of object keys
    524 	 */
    525 	getSynced: function (objectType, libraryID) {
    526 		var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
    527 		var sql = "SELECT key FROM " + objectsClass.table + " WHERE libraryID=? AND synced=1";
    528 		return Zotero.DB.columnQueryAsync(sql, [libraryID]);
    529 	},
    530 	
    531 	
    532 	/**
    533 	 * @param {String} objectType
    534 	 * @param {Integer} libraryID
    535 	 * @return {Promise<Integer[]>} - A promise for an array of object ids
    536 	 */
    537 	getUnsynced: Zotero.Promise.coroutine(function* (objectType, libraryID) {
    538 		var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
    539 		var sql = "SELECT O." + objectsClass.idColumn + " FROM " + objectsClass.table + " O";
    540 		if (objectType == 'item') {
    541 			sql += " LEFT JOIN itemAttachments IA USING (itemID) "
    542 				+ "LEFT JOIN itemNotes INo ON (O.itemID=INo.itemID) ";
    543 		}
    544 		sql += " WHERE libraryID=? AND synced=0";
    545 		// Sort child items last
    546 		if (objectType == 'item') {
    547 			sql += " ORDER BY COALESCE(IA.parentItemID, INo.parentItemID)";
    548 		}
    549 		
    550 		var ids = yield Zotero.DB.columnQueryAsync(sql, [libraryID]);
    551 		
    552 		// Sort descendent collections last
    553 		if (objectType == 'collection') {
    554 			ids = Zotero.Collections.sortByLevel(ids);
    555 		}
    556 		
    557 		return ids;
    558 	}),
    559 	
    560 	
    561 	//
    562 	// Cache management
    563 	//
    564 	/**
    565 	 * Gets the latest version for each object of a given type in the given library
    566 	 *
    567 	 * @return {Promise<Object>} - A promise for an object with object keys as keys and versions
    568 	 *                             as properties
    569 	 */
    570 	getLatestCacheObjectVersions: Zotero.Promise.coroutine(function* (objectType, libraryID, keys=[]) {
    571 		var versions = {};
    572 		
    573 		yield Zotero.Utilities.Internal.forEachChunkAsync(
    574 			keys,
    575 			Zotero.DB.MAX_BOUND_PARAMETERS - 2,
    576 			Zotero.Promise.coroutine(function* (chunk) {
    577 				// The MAX(version) ensures we get the data from the most recent version of the object,
    578 				// thanks to SQLite 3.7.11 (http://www.sqlite.org/releaselog/3_7_11.html)
    579 				var sql = "SELECT key, MAX(version) AS version FROM syncCache "
    580 					+ "WHERE libraryID=? AND "
    581 					+ "syncObjectTypeID IN (SELECT syncObjectTypeID FROM syncObjectTypes WHERE name=?) ";
    582 				var params = [libraryID, objectType]
    583 				if (chunk.length) {
    584 					sql += "AND key IN (" + chunk.map(key => '?').join(', ') + ") ";
    585 					params = params.concat(chunk);
    586 				}
    587 				sql += "GROUP BY libraryID, key";
    588 				var rows = yield Zotero.DB.queryAsync(sql, params);
    589 				
    590 				for (let i = 0; i < rows.length; i++) {
    591 					let row = rows[i];
    592 					versions[row.key] = row.version;
    593 				}
    594 			})
    595 		);
    596 		
    597 		return versions;
    598 	}),
    599 	
    600 	
    601 	/**
    602 	 * @return {Promise<Integer[]>} - A promise for an array of object versions
    603 	 */
    604 	getCacheObjectVersions: function (objectType, libraryID, key) {
    605 		var sql = "SELECT version FROM syncCache WHERE libraryID=? AND key=? "
    606 			+ "AND syncObjectTypeID IN (SELECT syncObjectTypeID FROM "
    607 			+ "syncObjectTypes WHERE name=?) ORDER BY version";
    608 		return Zotero.DB.columnQueryAsync(sql, [libraryID, key, objectType]);
    609 	},
    610 	
    611 	
    612 	/**
    613 	 * @return {Promise<Number>} - A promise for an object version
    614 	 */
    615 	getLatestCacheObjectVersion: function (objectType, libraryID, key) {
    616 		var sql = "SELECT version FROM syncCache WHERE libraryID=? AND key=? "
    617 			+ "AND syncObjectTypeID IN (SELECT syncObjectTypeID FROM "
    618 			+ "syncObjectTypes WHERE name=?) ORDER BY VERSION DESC LIMIT 1";
    619 		return Zotero.DB.valueQueryAsync(sql, [libraryID, key, objectType]);
    620 	},
    621 	
    622 	
    623 	/**
    624 	 * @return {Promise}
    625 	 */
    626 	getCacheObject: Zotero.Promise.coroutine(function* (objectType, libraryID, key, version) {
    627 		var sql = "SELECT data FROM syncCache WHERE libraryID=? AND key=? AND version=? "
    628 			+ "AND syncObjectTypeID IN (SELECT syncObjectTypeID FROM "
    629 			+ "syncObjectTypes WHERE name=?)";
    630 		var data = yield Zotero.DB.valueQueryAsync(sql, [libraryID, key, version, objectType]);
    631 		if (data) {
    632 			return JSON.parse(data);
    633 		}
    634 		return false;
    635 	}),
    636 	
    637 	
    638 	getCacheObjects: Zotero.Promise.coroutine(function* (objectType, libraryID, keyVersionPairs) {
    639 		if (!keyVersionPairs.length) return [];
    640 		var rows = [];
    641 		yield Zotero.Utilities.Internal.forEachChunkAsync(
    642 			keyVersionPairs,
    643 			240, // SQLITE_MAX_COMPOUND_SELECT defaults to 500
    644 			async function (chunk) {
    645 				var sql = "SELECT data FROM syncCache SC JOIN (SELECT "
    646 					+ chunk.map((pair) => {
    647 						Zotero.DataObjectUtilities.checkKey(pair[0]);
    648 						return "'" + pair[0] + "' AS key, " + parseInt(pair[1]) + " AS version";
    649 					}).join(" UNION SELECT ")
    650 					+ ") AS pairs ON (pairs.key=SC.key AND pairs.version=SC.version) "
    651 					+ "WHERE libraryID=? AND "
    652 					+ "syncObjectTypeID IN (SELECT syncObjectTypeID FROM syncObjectTypes WHERE name=?)";
    653 				rows.push(...await Zotero.DB.columnQueryAsync(sql, [libraryID, objectType]));
    654 			}
    655 		)
    656 		return rows.map(row => JSON.parse(row));
    657 	}),
    658 	
    659 	
    660 	saveCacheObject: Zotero.Promise.coroutine(function* (objectType, libraryID, json) {
    661 		json = this._checkCacheJSON(json);
    662 		
    663 		Zotero.debug("Saving to sync cache:");
    664 		Zotero.debug(json);
    665 		
    666 		var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
    667 		var sql = "INSERT OR REPLACE INTO syncCache "
    668 			+ "(libraryID, key, syncObjectTypeID, version, data) VALUES (?, ?, ?, ?, ?)";
    669 		var params = [libraryID, json.key, syncObjectTypeID, json.version, JSON.stringify(json)];
    670 		return Zotero.DB.queryAsync(sql, params);
    671 	}),
    672 	
    673 	
    674 	saveCacheObjects: Zotero.Promise.coroutine(function* (objectType, libraryID, jsonArray) {
    675 		if (!Array.isArray(jsonArray)) {
    676 			throw new Error("'json' must be an array");
    677 		}
    678 		
    679 		if (!jsonArray.length) {
    680 			Zotero.debug("No " + Zotero.DataObjectUtilities.getObjectTypePlural(objectType)
    681 				+ " to save to sync cache");
    682 			return;
    683 		}
    684 		
    685 		jsonArray = jsonArray.map(json => this._checkCacheJSON(json));
    686 		
    687 		Zotero.debug("Saving to sync cache:");
    688 		Zotero.debug(jsonArray);
    689 		
    690 		var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
    691 		var sql = "INSERT OR REPLACE INTO syncCache "
    692 			+ "(libraryID, key, syncObjectTypeID, version, data) VALUES ";
    693 		var chunkSize = Math.floor(Zotero.DB.MAX_BOUND_PARAMETERS / 5);
    694 		return Zotero.Utilities.Internal.forEachChunkAsync(
    695 			jsonArray,
    696 			chunkSize,
    697 			Zotero.Promise.coroutine(function* (chunk) {
    698 				var params = [];
    699 				for (let i = 0; i < chunk.length; i++) {
    700 					let o = chunk[i];
    701 					params.push(libraryID, o.key, syncObjectTypeID, o.version, JSON.stringify(o));
    702 				}
    703 				return Zotero.DB.queryAsync(
    704 					sql + chunk.map(() => "(?, ?, ?, ?, ?)").join(", "), params
    705 				);
    706 			})
    707 		);
    708 	}),
    709 	
    710 	
    711 	/**
    712 	 * Process downloaded JSON and update local objects
    713 	 *
    714 	 * @return {Promise<Object[]>} - Promise for an array of objects with the following properties:
    715 	 *         {String} key
    716 	 *         {Boolean} processed
    717 	 *         {Object} [error]
    718 	 *         {Boolean} [retry]
    719 	 *         {Boolean} [restored=false] - Locally deleted object was added back
    720 	 *         {Boolean} [conflict=false]
    721 	 *         {Object} [left] - Local JSON data for conflict (or .deleted and .dateDeleted)
    722 	 *         {Object} [right] - Remote JSON data for conflict
    723 	 *         {Object[]} [changes] - An array of operations to apply locally to resolve conflicts,
    724 	 *             as returned by _reconcileChanges()
    725 	 *         {Object[]} [conflicts] - An array of conflicting fields that can't be resolved automatically
    726 	 */
    727 	processObjectsFromJSON: Zotero.Promise.coroutine(function* (objectType, libraryID, json, options = {}) {
    728 		var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
    729 		var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
    730 		var ObjectType = Zotero.Utilities.capitalize(objectType);
    731 		var libraryName = Zotero.Libraries.get(libraryID).name;
    732 		
    733 		var knownErrors = [
    734 			'ZoteroUnknownTypeError',
    735 			'ZoteroUnknownFieldError',
    736 			'ZoteroMissingObjectError'
    737 		];
    738 		
    739 		Zotero.debug("Processing " + json.length + " downloaded "
    740 			+ (json.length == 1 ? objectType : objectTypePlural)
    741 			+ " for " + libraryName);
    742 		
    743 		var results = [];
    744 		
    745 		if (!json.length) {
    746 			return results;
    747 		}
    748 		
    749 		json = json.map(o => this._checkCacheJSON(o));
    750 		
    751 		if (options.setStatus) {
    752 			options.setStatus("Downloading " + objectTypePlural + " in " + libraryName); // TODO: localize
    753 		}
    754 		
    755 		// Sort parent objects first, to avoid retries due to unmet dependencies
    756 		if (objectType == 'item' || objectType == 'collection') {
    757 			let parentProp = 'parent' + objectType[0].toUpperCase() + objectType.substr(1);
    758 			json.sort(function (a, b) {
    759 				if (a[parentProp] && !b[parentProp]) return 1;
    760 				if (b[parentProp] && !a[parentProp]) return -1;
    761 				return 0;
    762 			});
    763 		}
    764 		
    765 		var batchSize = options.getNotifierBatchSize ? options.getNotifierBatchSize() : json.length;
    766 		var notifierQueues = [];
    767 		
    768 		try {
    769 			for (let i = 0; i < json.length; i++) {
    770 				// Batch notifier updates
    771 				if (notifierQueues.length == batchSize) {
    772 					yield Zotero.Notifier.commit(notifierQueues);
    773 					notifierQueues = [];
    774 					// Get the current batch size, which might have increased
    775 					if (options.getNotifierBatchSize) {
    776 						batchSize = options.getNotifierBatchSize()
    777 					}
    778 				}
    779 				let notifierQueue = new Zotero.Notifier.Queue;
    780 				
    781 				let jsonObject = json[i];
    782 				let jsonData = jsonObject.data;
    783 				let objectKey = jsonObject.key;
    784 				
    785 				let saveOptions = {};
    786 				Object.assign(saveOptions, options);
    787 				saveOptions.isNewObject = false;
    788 				saveOptions.skipCache = false;
    789 				saveOptions.storageDetailsChanged = false;
    790 				saveOptions.notifierQueue = notifierQueue;
    791 				
    792 				Zotero.debug(`Processing ${objectType} ${libraryID}/${objectKey}`);
    793 				Zotero.debug(jsonObject);
    794 				
    795 				// Skip objects with unmet dependencies
    796 				if (objectType == 'item' || objectType == 'collection') {
    797 					// Missing parent collection or item
    798 					let parentProp = 'parent' + objectType[0].toUpperCase() + objectType.substr(1);
    799 					let parentKey = jsonData[parentProp];
    800 					if (parentKey) {
    801 						let parentObj = yield objectsClass.getByLibraryAndKeyAsync(
    802 							libraryID, parentKey, { noCache: true }
    803 						);
    804 						if (!parentObj) {
    805 							let error = new Error("Parent of " + objectType + " "
    806 								+ libraryID + "/" + jsonData.key + " not found -- skipping");
    807 							error.name = "ZoteroMissingObjectError";
    808 							Zotero.debug(error.message);
    809 							results.push({
    810 								key: objectKey,
    811 								processed: false,
    812 								error,
    813 								retry: true
    814 							});
    815 							continue;
    816 						}
    817 					}
    818 					
    819 					// Missing collection -- this could happen if the collection was deleted
    820 					// locally and an item in it was modified remotely
    821 					if (objectType == 'item' && jsonData.collections) {
    822 						let error;
    823 						for (let key of jsonData.collections) {
    824 							let collection = Zotero.Collections.getByLibraryAndKey(libraryID, key);
    825 							if (!collection) {
    826 								error = new Error(`Collection ${libraryID}/${key} not found `
    827 									+ `-- skipping item`);
    828 								error.name = "ZoteroMissingObjectError";
    829 								Zotero.debug(error.message);
    830 								results.push({
    831 									key: objectKey,
    832 									processed: false,
    833 									error,
    834 									retry: false
    835 								});
    836 								
    837 								// If the collection is in the delete log, the deletion will upload
    838 								// after downloads are done. Otherwise, we somehow missed
    839 								// downloading it and should add it to the queue to try again.
    840 								if (!(yield this.getDateDeleted('collection', libraryID, key))) {
    841 									yield this.addObjectsToSyncQueue('collection', libraryID, [key]);
    842 								}
    843 								break;
    844 							}
    845 						}
    846 						if (error) {
    847 							continue;
    848 						}
    849 					}
    850 				}
    851 				
    852 				// Errors have to be thrown in order to roll back the transaction, so catch those here
    853 				// and continue
    854 				try {
    855 					yield Zotero.DB.executeTransaction(function* () {
    856 						let obj = yield objectsClass.getByLibraryAndKeyAsync(
    857 							libraryID, objectKey, { noCache: true }
    858 						);
    859 						let restored = false;
    860 						if (obj) {
    861 							Zotero.debug("Matching local " + objectType + " exists", 4);
    862 							
    863 							let jsonDataLocal = obj.toJSON();
    864 							
    865 							// For items, check if mtime or file hash changed in metadata,
    866 							// which would indicate that a remote storage sync took place and
    867 							// a download is needed
    868 							if (objectType == 'item' && obj.isImportedAttachment()) {
    869 								if (jsonDataLocal.mtime != jsonData.mtime
    870 										|| jsonDataLocal.md5 != jsonData.md5) {
    871 									saveOptions.storageDetailsChanged = true;
    872 								}
    873 							}
    874 							
    875 							// Local object has been modified since last sync
    876 							if (!obj.synced) {
    877 								Zotero.debug("Local " + objectType + " " + obj.libraryKey
    878 										+ " has been modified since last sync", 4);
    879 								
    880 								let cachedJSON = yield this.getCacheObject(
    881 									objectType, obj.libraryID, obj.key, obj.version
    882 								);
    883 								let result = this._reconcileChanges(
    884 									objectType,
    885 									cachedJSON.data,
    886 									jsonDataLocal,
    887 									jsonData,
    888 									['mtime', 'md5', 'dateAdded', 'dateModified']
    889 								);
    890 								
    891 								// If no changes, just update local version number and mark as synced
    892 								if (!result.changes.length && !result.conflicts.length) {
    893 									Zotero.debug("No remote changes to apply to local "
    894 										+ objectType + " " + obj.libraryKey);
    895 									saveOptions.skipData = true;
    896 									// If local object is different but we ignored the changes
    897 									// (e.g., ISBN hyphenation), save as unsynced. Since we're skipping
    898 									// data, the local version won't be overwritten.
    899 									if (result.localChanged) {
    900 										saveOptions.saveAsUnsynced = true;
    901 									}
    902 									let saveResults = yield this._saveObjectFromJSON(
    903 										obj,
    904 										jsonObject,
    905 										saveOptions
    906 									);
    907 									results.push(saveResults);
    908 									if (!saveResults.processed) {
    909 										throw saveResults.error;
    910 									}
    911 									return;
    912 								}
    913 								
    914 								if (result.conflicts.length) {
    915 									if (objectType != 'item') {
    916 										throw new Error(`Unexpected conflict on ${objectType} object`);
    917 									}
    918 									Zotero.debug("Conflict!", 2);
    919 									Zotero.debug(jsonDataLocal);
    920 									Zotero.debug(jsonData);
    921 									Zotero.debug(result);
    922 									results.push({
    923 										libraryID,
    924 										key: objectKey,
    925 										processed: false,
    926 										conflict: true,
    927 										left: jsonDataLocal,
    928 										right: jsonData,
    929 										changes: result.changes,
    930 										conflicts: result.conflicts
    931 									});
    932 									return;
    933 								}
    934 								
    935 								// If no conflicts, apply remote changes automatically
    936 								Zotero.debug(`Applying remote changes to ${objectType} `
    937 									+ obj.libraryKey);
    938 								Zotero.debug(result.changes);
    939 								Zotero.DataObjectUtilities.applyChanges(
    940 									jsonDataLocal, result.changes
    941 								);
    942 								// Transfer properties that aren't in the changeset
    943 								['version', 'dateAdded', 'dateModified'].forEach(x => {
    944 									if (jsonDataLocal[x] !== jsonData[x]) {
    945 										Zotero.debug(`Applying remote '${x}' value`);
    946 									}
    947 									jsonDataLocal[x] = jsonData[x];
    948 								})
    949 								jsonObject.data = jsonDataLocal;
    950 							}
    951 						}
    952 						// Object doesn't exist locally
    953 						else {
    954 							Zotero.debug(ObjectType + " doesn't exist locally");
    955 							
    956 							saveOptions.isNewObject = true;
    957 							
    958 							// Check if object has been deleted locally
    959 							let dateDeleted = yield this.getDateDeleted(
    960 								objectType, libraryID, objectKey
    961 							);
    962 							if (dateDeleted) {
    963 								Zotero.debug(ObjectType + " was deleted locally");
    964 								
    965 								switch (objectType) {
    966 								case 'item':
    967 									if (jsonData.deleted) {
    968 										Zotero.debug("Remote item is in trash -- allowing local deletion to propagate");
    969 										results.push({
    970 											libraryID,
    971 											key: objectKey,
    972 											processed: true
    973 										});
    974 										return;
    975 									}
    976 									
    977 									results.push({
    978 										libraryID,
    979 										key: objectKey,
    980 										processed: false,
    981 										conflict: true,
    982 										left: {
    983 											deleted: true,
    984 											dateDeleted: Zotero.Date.dateToSQL(dateDeleted, true)
    985 										},
    986 										right: jsonData
    987 									});
    988 									return;
    989 								
    990 								// Auto-restore some locally deleted objects that have changed remotely
    991 								case 'collection':
    992 								case 'search':
    993 									Zotero.debug(`${ObjectType} ${objectKey} was modified remotely `
    994 										+ '-- restoring');
    995 									yield this.removeObjectsFromDeleteLog(
    996 										objectType,
    997 										libraryID,
    998 										[objectKey]
    999 									);
   1000 									restored = true;
   1001 									break;
   1002 								
   1003 								default:
   1004 									throw new Error("Unknown object type '" + objectType + "'");
   1005 								}
   1006 							}
   1007 							
   1008 							// Create new object
   1009 							obj = new Zotero[ObjectType];
   1010 							obj.libraryID = libraryID;
   1011 							obj.key = objectKey;
   1012 							yield obj.loadPrimaryData();
   1013 							
   1014 							// Don't cache new items immediately, which skips reloading after save
   1015 							saveOptions.skipCache = true;
   1016 						}
   1017 						
   1018 						let saveResults = yield this._saveObjectFromJSON(obj, jsonObject, saveOptions);
   1019 						if (restored) {
   1020 							saveResults.restored = true;
   1021 						}
   1022 						results.push(saveResults);
   1023 						if (!saveResults.processed) {
   1024 							throw saveResults.error;
   1025 						}
   1026 					}.bind(this));
   1027 					
   1028 					if (notifierQueue.size) {
   1029 						notifierQueues.push(notifierQueue);
   1030 					}
   1031 				}
   1032 				catch (e) {
   1033 					// Display nicer debug line for known errors
   1034 					if (knownErrors.indexOf(e.name) != -1) {
   1035 						let desc = e.name
   1036 							.replace(/^Zotero/, "")
   1037 							// Convert "MissingObjectError" to "missing object error"
   1038 							.split(/([a-z]+)/).join(' ').trim()
   1039 							.replace(/([A-Z]) ([a-z]+)/g, "$1$2").toLowerCase();
   1040 						let msg = Zotero.Utilities.capitalize(desc) + " for "
   1041 							+ `${objectType} ${jsonObject.key} in ${Zotero.Libraries.get(libraryID).name}`;
   1042 						Zotero.debug(msg, 2);
   1043 						Zotero.debug(e, 2);
   1044 						Components.utils.reportError(msg + ": " + e.message);
   1045 					}
   1046 					else {
   1047 						Zotero.logError(e);
   1048 					}
   1049 					
   1050 					if (options.onError) {
   1051 						options.onError(e);
   1052 					}
   1053 					
   1054 					if (Zotero.DB.closed) {
   1055 						e.fatal = true;
   1056 					}
   1057 					if (options.stopOnError || e.fatal) {
   1058 						throw e;
   1059 					}
   1060 				}
   1061 				finally {
   1062 					if (options.onObjectProcessed) {
   1063 						options.onObjectProcessed();
   1064 					}
   1065 				}
   1066 				
   1067 				yield Zotero.Promise.delay(10);
   1068 			}
   1069 		}
   1070 		finally {
   1071 			if (notifierQueues.length) {
   1072 				yield Zotero.Notifier.commit(notifierQueues);
   1073 			}
   1074 		}
   1075 		
   1076 		let processed = 0;
   1077 		let skipped = 0;
   1078 		results.forEach(x => x.processed ? processed++ : skipped++);
   1079 		
   1080 		Zotero.debug(`Processed ${processed} `
   1081 			+ (processed == 1 ? objectType : objectTypePlural)
   1082 			+ (skipped ? ` and skipped ${skipped}` : "")
   1083 			+ " in " + libraryName);
   1084 		
   1085 		return results;
   1086 	}),
   1087 	
   1088 	
   1089 	_checkCacheJSON: function (json) {
   1090 		if (json.key === undefined) {
   1091 			Zotero.debug(json, 1);
   1092 			throw new Error("Missing 'key' property in JSON");
   1093 		}
   1094 		if (json.version === undefined) {
   1095 			Zotero.debug(json, 1);
   1096 			throw new Error("Missing 'version' property in JSON");
   1097 		}
   1098 		if (json.version === 0) {
   1099 			Zotero.debug(json, 1);
   1100 			// TODO: Fix tests so this doesn't happen
   1101 			Zotero.warn("'version' cannot be 0 in cache JSON");
   1102 			//throw new Error("'version' cannot be 0 in cache JSON");
   1103 		}
   1104 		// If direct data object passed, wrap in fake response object
   1105 		return json.data === undefined ? {
   1106 			key: json.key,
   1107 			version: json.version,
   1108 			data: json
   1109 		} :  json;
   1110 	},
   1111 	
   1112 	
   1113 	/**
   1114 	 * Check whether an attachment's file mod time matches the given mod time, and mark the file
   1115 	 * for download if not (or if this is a new attachment)
   1116 	 */
   1117 	_checkAttachmentForDownload: Zotero.Promise.coroutine(function* (item, mtime, isNewObject) {
   1118 		var markToDownload = false;
   1119 		if (!isNewObject) {
   1120 			// Convert previously used Unix timestamps to ms-based timestamps
   1121 			if (mtime < 10000000000) {
   1122 				Zotero.debug("Converting Unix timestamp '" + mtime + "' to ms");
   1123 				mtime = mtime * 1000;
   1124 			}
   1125 			var fmtime = null;
   1126 			try {
   1127 				fmtime = yield item.attachmentModificationTime;
   1128 			}
   1129 			catch (e) {
   1130 				// This will probably fail later too, but ignore it for now
   1131 				Zotero.logError(e);
   1132 			}
   1133 			if (fmtime) {
   1134 				let state = Zotero.Sync.Storage.Local.checkFileModTime(item, fmtime, mtime);
   1135 				if (state !== false) {
   1136 					markToDownload = true;
   1137 				}
   1138 			}
   1139 			else {
   1140 				markToDownload = true;
   1141 			}
   1142 		}
   1143 		else {
   1144 			markToDownload = true;
   1145 		}
   1146 		if (markToDownload) {
   1147 			item.attachmentSyncState = "to_download";
   1148 		}
   1149 	}),
   1150 	
   1151 	
   1152 	/**
   1153 	 * Delete one or more versions of an object from the sync cache
   1154 	 *
   1155 	 * @param {String} objectType
   1156 	 * @param {Integer} libraryID
   1157 	 * @param {String} key
   1158 	 * @param {Integer} [minVersion]
   1159 	 * @param {Integer} [maxVersion]
   1160 	 */
   1161 	deleteCacheObjectVersions: function (objectType, libraryID, key, minVersion, maxVersion) {
   1162 		var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
   1163 		var sql = "DELETE FROM syncCache WHERE libraryID=? AND key=? AND syncObjectTypeID=?";
   1164 		var params = [libraryID, key, syncObjectTypeID];
   1165 		if (minVersion && minVersion == maxVersion) {
   1166 			sql += " AND version=?";
   1167 			params.push(minVersion);
   1168 		}
   1169 		else {
   1170 			if (minVersion) {
   1171 				sql += " AND version>=?";
   1172 				params.push(minVersion);
   1173 			}
   1174 			if (maxVersion || maxVersion === 0) {
   1175 				sql += " AND version<=?";
   1176 				params.push(maxVersion);
   1177 			}
   1178 		}
   1179 		return Zotero.DB.queryAsync(sql, params);
   1180 	},
   1181 	
   1182 	
   1183 	/**
   1184 	 * Delete entries from sync cache that don't exist or are less than the current object version
   1185 	 */
   1186 	purgeCache: Zotero.Promise.coroutine(function* (objectType, libraryID) {
   1187 		var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
   1188 		var table = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType).table;
   1189 		var sql = "DELETE FROM syncCache WHERE ROWID IN ("
   1190 			+ "SELECT SC.ROWID FROM syncCache SC "
   1191 			+ `LEFT JOIN ${table} O USING (libraryID, key, version) `
   1192 			+ "WHERE syncObjectTypeID=? AND SC.libraryID=? AND "
   1193 			+ "(O.libraryID IS NULL OR SC.version < O.version))";
   1194 		yield Zotero.DB.queryAsync(sql, [syncObjectTypeID, libraryID]);
   1195 	}),
   1196 	
   1197 	
   1198 	clearCacheForLibrary: async function (libraryID) {
   1199 		await Zotero.DB.queryAsync("DELETE FROM syncCache WHERE libraryID=?", libraryID);
   1200 	},
   1201 	
   1202 	
   1203 	processConflicts: Zotero.Promise.coroutine(function* (objectType, libraryID, conflicts, options = {}) {
   1204 		if (!conflicts.length) return [];
   1205 		
   1206 		var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
   1207 		var ObjectType = Zotero.Utilities.capitalize(objectType);
   1208 		
   1209 		// Sort conflicts by local Date Modified/Deleted
   1210 		conflicts.sort(function (a, b) {
   1211 			var d1 = a.left.dateDeleted || a.left.dateModified;
   1212 			var d2 = b.left.dateDeleted || b.left.dateModified;
   1213 			if (d1 > d2) {
   1214 				return 1
   1215 			}
   1216 			if (d1 < d2) {
   1217 				return -1;
   1218 			}
   1219 			return 0;
   1220 		})
   1221 		
   1222 		var results = [];
   1223 		
   1224 		var mergeData = this.showConflictResolutionWindow(conflicts);
   1225 		if (!mergeData) {
   1226 			Zotero.debug("Conflict resolution was cancelled", 2);
   1227 			for (let conflict of conflicts) {
   1228 				results.push({
   1229 					// Use key from either, in case one side is deleted
   1230 					key: conflict.left.key || conflict.right.key,
   1231 					processed: false,
   1232 					retry: false
   1233 				});
   1234 			}
   1235 			return results;
   1236 		}
   1237 		
   1238 		Zotero.debug("Processing resolved conflicts");
   1239 		
   1240 		let batchSize = mergeData.length;
   1241 		let notifierQueues = [];
   1242 		try {
   1243 			for (let i = 0; i < mergeData.length; i++) {
   1244 				// Batch notifier updates, despite multiple transactions
   1245 				if (notifierQueues.length == batchSize) {
   1246 					yield Zotero.Notifier.commit(notifierQueues);
   1247 					notifierQueues = [];
   1248 				}
   1249 				let notifierQueue = new Zotero.Notifier.Queue;
   1250 				
   1251 				let json = mergeData[i].data;
   1252 				
   1253 				let saveOptions = {};
   1254 				Object.assign(saveOptions, options);
   1255 				// If choosing local object, save as unsynced with remote version (or 0 if remote is
   1256 				// deleted) and remote object in cache, to simulate a save and edit
   1257 				if (mergeData[i].selected == 'left') {
   1258 					json.version = conflicts[i].right.version || 0;
   1259 					saveOptions.saveAsUnsynced = true;
   1260 					if (conflicts[i].right.version) {
   1261 						saveOptions.cacheObject = conflicts[i].right;
   1262 					}
   1263 				}
   1264 				saveOptions.notifierQueue = notifierQueue;
   1265 				
   1266 				// Errors have to be thrown in order to roll back the transaction, so catch
   1267 				// those here and continue
   1268 				try {
   1269 					yield Zotero.DB.executeTransaction(function* () {
   1270 						let obj = yield objectsClass.getByLibraryAndKeyAsync(
   1271 							libraryID, json.key, { noCache: true }
   1272 						);
   1273 						// Update object with merge data
   1274 						if (obj) {
   1275 							// Delete local object
   1276 							if (json.deleted) {
   1277 								try {
   1278 									yield obj.erase({
   1279 										notifierQueue
   1280 									});
   1281 								}
   1282 								catch (e) {
   1283 									results.push({
   1284 										key: json.key,
   1285 										processed: false,
   1286 										error: e,
   1287 										retry: false
   1288 									});
   1289 									throw e;
   1290 								}
   1291 								results.push({
   1292 									key: json.key,
   1293 									processed: true
   1294 								});
   1295 								return;
   1296 							}
   1297 							
   1298 							// Save merged changes below
   1299 						}
   1300 						// If no local object and merge wanted a delete, we're good
   1301 						else if (json.deleted) {
   1302 							results.push({
   1303 								key: json.key,
   1304 								processed: true
   1305 							});
   1306 							return;
   1307 						}
   1308 						// Recreate locally deleted object
   1309 						else {
   1310 							obj = new Zotero[ObjectType];
   1311 							obj.libraryID = libraryID;
   1312 							obj.key = json.key;
   1313 							yield obj.loadPrimaryData();
   1314 							
   1315 							// Don't cache new items immediately,
   1316 							// which skips reloading after save
   1317 							saveOptions.skipCache = true;
   1318 						}
   1319 						
   1320 						let saveResults = yield this._saveObjectFromJSON(obj, json, saveOptions);
   1321 						results.push(saveResults);
   1322 						if (!saveResults.processed) {
   1323 							throw saveResults.error;
   1324 						}
   1325 					}.bind(this));
   1326 					
   1327 					if (notifierQueue.size) {
   1328 						notifierQueues.push(notifierQueue);
   1329 					}
   1330 				}
   1331 				catch (e) {
   1332 					Zotero.logError(e);
   1333 					
   1334 					if (options.onError) {
   1335 						options.onError(e);
   1336 					}
   1337 					
   1338 					if (options.stopOnError) {
   1339 						throw e;
   1340 					}
   1341 				}
   1342 			}
   1343 		}
   1344 		finally {
   1345 			if (notifierQueues.length) {
   1346 				yield Zotero.Notifier.commit(notifierQueues);
   1347 			}
   1348 		}
   1349 		
   1350 		return results;
   1351 	}),
   1352 	
   1353 	
   1354 	showConflictResolutionWindow: function (conflicts) {
   1355 		Zotero.debug("Showing conflict resolution window");
   1356 		Zotero.debug(conflicts);
   1357 		
   1358 		var io = {
   1359 			dataIn: {
   1360 				captions: [
   1361 					Zotero.getString('sync.conflict.localItem'),
   1362 					Zotero.getString('sync.conflict.remoteItem'),
   1363 					Zotero.getString('sync.conflict.mergedItem')
   1364 				],
   1365 				conflicts
   1366 			}
   1367 		};
   1368 		var url = 'chrome://zotero/content/merge.xul';
   1369 		var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
   1370 		   .getService(Components.interfaces.nsIWindowMediator);
   1371 		var lastWin = wm.getMostRecentWindow("navigator:browser");
   1372 		if (lastWin) {
   1373 			lastWin.openDialog(url, '', 'chrome,modal,centerscreen', io);
   1374 		}
   1375 		else {
   1376 			// When using nsIWindowWatcher, the object has to be wrapped here
   1377 			// https://developer.mozilla.org/en-US/docs/Working_with_windows_in_chrome_code#Example_5_Using_nsIWindowWatcher_for_passing_an_arbritrary_JavaScript_object
   1378 			io.wrappedJSObject = io;
   1379 			let ww = Components.classes["@mozilla.org/embedcomp/window-watcher;1"]
   1380 				.getService(Components.interfaces.nsIWindowWatcher);
   1381 			ww.openWindow(null, url, '', 'chrome,modal,centerscreen,dialog', io);
   1382 		}
   1383 		if (io.error) {
   1384 			throw io.error;
   1385 		}
   1386 		return io.dataOut;
   1387 	},
   1388 	
   1389 	
   1390 	//
   1391 	// Classic sync
   1392 	//
   1393 	getLastClassicSyncTime: function () {
   1394 		if (_lastClassicSyncTime === null) {
   1395 			throw new Error("Last classic sync time not yet loaded");
   1396 		}
   1397 		return _lastClassicSyncTime;
   1398 	},
   1399 	
   1400 	_loadLastClassicSyncTime: Zotero.Promise.coroutine(function* () {
   1401 		var sql = "SELECT version FROM version WHERE schema='lastlocalsync'";
   1402 		var lastsync = yield Zotero.DB.valueQueryAsync(sql);
   1403 		_lastClassicSyncTime = (lastsync ? new Date(lastsync * 1000) : false);
   1404 	}),
   1405 	
   1406 	_saveObjectFromJSON: Zotero.Promise.coroutine(function* (obj, json, options) {
   1407 		var results = {};
   1408 		try {
   1409 			results.key = json.key;
   1410 			json = this._checkCacheJSON(json);
   1411 			
   1412 			if (!options.skipData) {
   1413 				obj.fromJSON(json.data);
   1414 			}
   1415 			if (obj.objectType == 'item' && obj.isImportedAttachment()) {
   1416 				yield this._checkAttachmentForDownload(obj, json.data.mtime, options.isNewObject);
   1417 			}
   1418 			obj.version = json.data.version;
   1419 			if (!options.saveAsUnsynced) {
   1420 				obj.synced = true;
   1421 			}
   1422 			yield obj.save({
   1423 				skipEditCheck: true,
   1424 				skipDateModifiedUpdate: true,
   1425 				skipSelect: true,
   1426 				skipCache: options.skipCache || false,
   1427 				notifierQueue: options.notifierQueue,
   1428 				// Errors are logged elsewhere, so skip in DataObject.save()
   1429 				errorHandler: function (e) {
   1430 					return;
   1431 				}
   1432 			});
   1433 			let cacheJSON = options.cacheObject ? options.cacheObject : json.data;
   1434 			yield this.saveCacheObject(obj.objectType, obj.libraryID, cacheJSON);
   1435 			// Delete older versions of the object in the cache
   1436 			yield this.deleteCacheObjectVersions(
   1437 				obj.objectType,
   1438 				obj.libraryID,
   1439 				json.key,
   1440 				null,
   1441 				cacheJSON.version - 1
   1442 			);
   1443 			results.processed = true;
   1444 			
   1445 			// Delete from sync queue
   1446 			yield this._removeObjectFromSyncQueue(obj.objectType, obj.libraryID, json.key);
   1447 			
   1448 			// Mark updated attachments for download
   1449 			if (obj.objectType == 'item' && obj.isImportedAttachment()) {
   1450 				// If storage changes were made (attachment mtime or hash), mark
   1451 				// library as requiring download
   1452 				if (options.isNewObject || options.storageDetailsChanged) {
   1453 					Zotero.Libraries.get(obj.libraryID).storageDownloadNeeded = true;
   1454 				}
   1455 			}
   1456 		}
   1457 		catch (e) {
   1458 			// For now, allow sync to proceed after all errors
   1459 			results.processed = false;
   1460 			results.error = e;
   1461 			results.retry = false;
   1462 		}
   1463 		return results;
   1464 	}),
   1465 	
   1466 	
   1467 	/**
   1468 	 * Calculate a changeset to apply locally to resolve an object conflict, plus a list of
   1469 	 * conflicts where not possible
   1470 	 */
   1471 	_reconcileChanges: function (objectType, originalJSON, currentJSON, newJSON, ignoreFields) {
   1472 		if (!originalJSON) {
   1473 			return this._reconcileChangesWithoutCache(objectType, currentJSON, newJSON, ignoreFields);
   1474 		}
   1475 		
   1476 		var changeset1 = Zotero.DataObjectUtilities.diff(originalJSON, currentJSON, ignoreFields);
   1477 		var changeset2 = Zotero.DataObjectUtilities.diff(originalJSON, newJSON, ignoreFields);
   1478 		
   1479 		Zotero.debug("CHANGESET1");
   1480 		Zotero.debug(changeset1);
   1481 		Zotero.debug("CHANGESET2");
   1482 		Zotero.debug(changeset2);
   1483 		
   1484 		var conflicts = [];
   1485 		
   1486 		for (let i = 0; i < changeset1.length; i++) {
   1487 			for (let j = 0; j < changeset2.length; j++) {
   1488 				let c1 = changeset1[i];
   1489 				let c2 = changeset2[j];
   1490 				if (c1.field != c2.field) {
   1491 					continue;
   1492 				}
   1493 				
   1494 				// Disregard member additions/deletions for different values
   1495 				if (c1.op.startsWith('member-') && c2.op.startsWith('member-')) {
   1496 					switch (c1.field) {
   1497 					case 'collections':
   1498 						if (c1.value !== c2.value) {
   1499 							continue;
   1500 						}
   1501 						break;
   1502 					
   1503 					case 'creators':
   1504 						if (!Zotero.Creators.equals(c1.value, c2.value)) {
   1505 							continue;
   1506 						}
   1507 						break;
   1508 					
   1509 					case 'tags':
   1510 						if (!Zotero.Tags.equals(c1.value, c2.value)) {
   1511 							// If just a type difference, treat as modify with type 0 if
   1512 							// not type 0 in changeset1
   1513 							if (c1.op == 'member-add' && c2.op == 'member-add'
   1514 									&& c1.value.tag === c2.value.tag) {
   1515 								changeset1.splice(i--, 1);
   1516 								changeset2.splice(j--, 1);
   1517 								if (c1.value.type > 0) {
   1518 									changeset2.push({
   1519 										field: "tags",
   1520 										op: "member-remove",
   1521 										value: c1.value
   1522 									});
   1523 									changeset2.push({
   1524 										field: "tags",
   1525 										op: "member-add",
   1526 										value: c2.value
   1527 									});
   1528 								}
   1529 							}
   1530 							continue;
   1531 						}
   1532 						break;
   1533 					}
   1534 				}
   1535 				
   1536 				// Disregard member additions/deletions for different properties and values
   1537 				if (c1.op.startsWith('property-member-') && c2.op.startsWith('property-member-')) {
   1538 					if (c1.value.key !== c2.value.key || c1.value.value !== c2.value.value) {
   1539 						continue;
   1540 					}
   1541 				}
   1542 				
   1543 				// Changes are equal or in conflict
   1544 				
   1545 				// Removed on both sides
   1546 				if (c1.op == 'delete' && c2.op == 'delete') {
   1547 					changeset2.splice(j--, 1);
   1548 					continue;
   1549 				}
   1550 				
   1551 				// Added or removed members on both sides
   1552 				if ((c1.op == 'member-add' && c2.op == 'member-add')
   1553 						|| (c1.op == 'member-remove' && c2.op == 'member-remove')
   1554 						|| (c1.op == 'property-member-add' && c2.op == 'property-member-add')
   1555 						|| (c1.op == 'property-member-remove' && c2.op == 'property-member-remove')) {
   1556 					changeset2.splice(j--, 1);
   1557 					continue;
   1558 				}
   1559 				
   1560 				// If both sides have values, see if they're the same, and if so remove the
   1561 				// second one
   1562 				if (c1.op != 'delete' && c2.op != 'delete' && c1.value === c2.value) {
   1563 					changeset2.splice(j--, 1);
   1564 					continue;
   1565 				}
   1566 				
   1567 				// Automatically apply remote changes if both items are in trash and for non-items,
   1568 				// even if in conflict
   1569 				if ((objectType == 'item' && currentJSON.deleted && newJSON.deleted)
   1570 						|| objectType != 'item') {
   1571 					continue;
   1572 				}
   1573 				
   1574 				// Conflict
   1575 				changeset2.splice(j--, 1);
   1576 				conflicts.push([c1, c2]);
   1577 			}
   1578 		}
   1579 		
   1580 		return {
   1581 			changes: changeset2,
   1582 			conflicts
   1583 		};
   1584 	},
   1585 	
   1586 	
   1587 	/**
   1588 	 * Calculate a changeset to apply locally to resolve an object conflict in absence of a
   1589 	 * cached version. Members and property members (e.g., collections, tags, relations)
   1590 	 * are combined, so any removals will be automatically undone. Field changes result in
   1591 	 * conflicts.
   1592 	 */
   1593 	_reconcileChangesWithoutCache: function (objectType, currentJSON, newJSON, ignoreFields) {
   1594 		var changeset = Zotero.DataObjectUtilities.diff(currentJSON, newJSON, ignoreFields);
   1595 		
   1596 		var changes = [];
   1597 		var conflicts = [];
   1598 		
   1599 		for (let i = 0; i < changeset.length; i++) {
   1600 			let c2 = changeset[i];
   1601 			
   1602 			// Member changes are additive only, so ignore removals
   1603 			if (c2.op.endsWith('-remove')) {
   1604 				continue;
   1605 			}
   1606 			
   1607 			// Record member changes
   1608 			if (c2.op.startsWith('member-') || c2.op.startsWith('property-member-')) {
   1609 				changes.push(c2);
   1610 				continue;
   1611 			}
   1612 			
   1613 			// Automatically apply remote changes for non-items, even if in conflict
   1614 			if ((objectType == 'item' && currentJSON.deleted && newJSON.deleted)
   1615 						|| objectType != 'item') {
   1616 				changes.push(c2);
   1617 				continue;
   1618 			}
   1619 			
   1620 			// Field changes are conflicts
   1621 			//
   1622 			// Since we don't know what changed, use only 'add' and 'delete'
   1623 			if (c2.op == 'modify') {
   1624 				c2.op = 'add';
   1625 			}
   1626 			let val = currentJSON[c2.field];
   1627 			let c1 = {
   1628 				field: c2.field,
   1629 				op: val !== undefined ? 'add' : 'delete'
   1630 			};
   1631 			if (val !== undefined) {
   1632 				c1.value = val;
   1633 			}
   1634 			if (c2.op == 'modify') {
   1635 				c2.op = 'add';
   1636 			}
   1637 			conflicts.push([c1, c2]);
   1638 		}
   1639 		
   1640 		var localChanged = false;
   1641 		var normalizeHTML = (str) => {
   1642 			let parser = Components.classes["@mozilla.org/xmlextras/domparser;1"]
   1643 				.createInstance(Components.interfaces.nsIDOMParser);
   1644 			str = parser.parseFromString(str, 'text/html');
   1645 			str = str.body.textContent;
   1646 			// Normalize internal spaces
   1647 			str = str.replace(/\s+/g, ' ');
   1648 			return str;
   1649 		};
   1650 		
   1651 		// Massage some old data
   1652 		conflicts = conflicts.filter((x) => {
   1653 			// If one side has auto-hyphenated ISBN, use that
   1654 			if (x[0].field == 'ISBN' && x[0].op == 'add' && x[1].op == 'add') {
   1655 				let hyphenatedA = Zotero.Utilities.Internal.hyphenateISBN(x[0].value);
   1656 				let hyphenatedB = Zotero.Utilities.Internal.hyphenateISBN(x[1].value);
   1657 				if (hyphenatedA && hyphenatedB) {
   1658 					// Use remote
   1659 					if (hyphenatedA == x[1].value) {
   1660 						changes.push(x[1]);
   1661 						return false;
   1662 					}
   1663 					// Use local
   1664 					else if (x[0].value == hyphenatedB) {
   1665 						localChanged = true;
   1666 						return false;
   1667 					}
   1668 				}
   1669 			}
   1670 			// Ignore notes with the same text content
   1671 			//
   1672 			// These can happen to people upgrading to 5.0 with notes that were added without going
   1673 			// through TinyMCE (e.g., from translators)
   1674 			else if (x[0].field == 'note' && x[0].op == 'add' && x[1].op == 'add') {
   1675 				let a = x[0].value;
   1676 				let b = x[1].value;
   1677 				try {
   1678 					a = normalizeHTML(a);
   1679 					b = normalizeHTML(b);
   1680 					if (a == b) {
   1681 						Zotero.debug("Notes differ only by markup -- using remote version");
   1682 						changes.push(x[1]);
   1683 						return false;
   1684 					}
   1685 				}
   1686 				catch (e) {
   1687 					Zotero.logError(e);
   1688 					return true
   1689 				}
   1690 			}
   1691 			return true;
   1692 		});
   1693 		
   1694 		return { changes, conflicts, localChanged };
   1695 	},
   1696 	
   1697 	
   1698 	markObjectAsSynced: Zotero.Promise.method(function (obj) {
   1699 		obj.synced = true;
   1700 		return obj.saveTx({ skipAll: true });
   1701 	}),
   1702 	
   1703 	
   1704 	markObjectAsUnsynced: Zotero.Promise.method(function (obj) {
   1705 		obj.synced = false;
   1706 		return obj.saveTx({ skipAll: true });
   1707 	}),
   1708 	
   1709 	
   1710 	/**
   1711 	 * @return {Promise<Date|false>}
   1712 	 */
   1713 	getDateDeleted: Zotero.Promise.coroutine(function* (objectType, libraryID, key) {
   1714 		var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
   1715 		var sql = "SELECT dateDeleted FROM syncDeleteLog WHERE libraryID=? AND key=? "
   1716 			+ "AND syncObjectTypeID=?";
   1717 		var date = yield Zotero.DB.valueQueryAsync(sql, [libraryID, key, syncObjectTypeID]);
   1718 		return date ? Zotero.Date.sqlToDate(date, true) : false;
   1719 	}),
   1720 	
   1721 	
   1722 	/**
   1723 	 * @return {Promise<String[]>} - Promise for array of keys
   1724 	 */
   1725 	getDeleted: Zotero.Promise.coroutine(function* (objectType, libraryID) {
   1726 		var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
   1727 		var sql = "SELECT key FROM syncDeleteLog WHERE libraryID=? AND syncObjectTypeID=?";
   1728 		return Zotero.DB.columnQueryAsync(sql, [libraryID, syncObjectTypeID]);
   1729 	}),
   1730 	
   1731 	
   1732 	/**
   1733 	 * @return {Promise}
   1734 	 */
   1735 	removeObjectsFromDeleteLog: function (objectType, libraryID, keys) {
   1736 		if (!keys.length) Zotero.Promise.resolve();
   1737 		
   1738 		var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
   1739 		var sql = "DELETE FROM syncDeleteLog WHERE libraryID=? AND syncObjectTypeID=? AND key IN (";
   1740 		return Zotero.Utilities.Internal.forEachChunkAsync(
   1741 			keys,
   1742 			Zotero.DB.MAX_BOUND_PARAMETERS - 2,
   1743 			Zotero.Promise.coroutine(function* (chunk) {
   1744 				var params = [libraryID, syncObjectTypeID].concat(chunk);
   1745 				return Zotero.DB.queryAsync(
   1746 					sql + Array(chunk.length).fill('?').join(',') + ")", params
   1747 				);
   1748 			})
   1749 		);
   1750 	},
   1751 	
   1752 	
   1753 	clearDeleteLogForLibrary: async function (libraryID) {
   1754 		await Zotero.DB.queryAsync("DELETE FROM syncDeleteLog WHERE libraryID=?", libraryID);
   1755 	},
   1756 	
   1757 	
   1758 	addObjectsToSyncQueue: Zotero.Promise.coroutine(function* (objectType, libraryID, keys) {
   1759 		var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
   1760 		var now = Zotero.Date.getUnixTimestamp();
   1761 		
   1762 		// Default to first try
   1763 		var keyTries = {};
   1764 		keys.forEach(key => keyTries[key] = 0);
   1765 		
   1766 		// Check current try counts
   1767 		var sql = "SELECT key, tries FROM syncQueue WHERE ";
   1768 		yield Zotero.Utilities.Internal.forEachChunkAsync(
   1769 			keys,
   1770 			Math.floor(Zotero.DB.MAX_BOUND_PARAMETERS / 3),
   1771 			Zotero.Promise.coroutine(function* (chunk) {
   1772 				var params = chunk.reduce(
   1773 					(arr, key) => arr.concat([libraryID, key, syncObjectTypeID]), []
   1774 				);
   1775 				var rows = yield Zotero.DB.queryAsync(
   1776 					sql + Array(chunk.length)
   1777 						.fill('(libraryID=? AND key=? AND syncObjectTypeID=?)')
   1778 						.join(' OR '),
   1779 					params
   1780 				);
   1781 				for (let row of rows) {
   1782 					keyTries[row.key] = row.tries + 1; // increment current count
   1783 				}
   1784 			})
   1785 		);
   1786 		
   1787 		// Insert or update
   1788 		var sql = "INSERT OR REPLACE INTO syncQueue "
   1789 			+ "(libraryID, key, syncObjectTypeID, lastCheck, tries) VALUES ";
   1790 		return Zotero.Utilities.Internal.forEachChunkAsync(
   1791 			keys,
   1792 			Math.floor(Zotero.DB.MAX_BOUND_PARAMETERS / 5),
   1793 			function (chunk) {
   1794 				var params = chunk.reduce(
   1795 					(arr, key) => arr.concat(
   1796 						[libraryID, key, syncObjectTypeID, now, keyTries[key]]
   1797 					), []
   1798 				);
   1799 				return Zotero.DB.queryAsync(
   1800 					sql + Array(chunk.length).fill('(?, ?, ?, ?, ?)').join(', '), params
   1801 				);
   1802 			}
   1803 		);
   1804 	}),
   1805 	
   1806 	
   1807 	hasObjectsInSyncQueue: function (libraryID) {
   1808 		return Zotero.DB.valueQueryAsync(
   1809 			"SELECT ROWID FROM syncQueue WHERE libraryID=? LIMIT 1", libraryID
   1810 		).then(x => !!x);
   1811 	},
   1812 	
   1813 	
   1814 	getObjectsFromSyncQueue: function (objectType, libraryID) {
   1815 		return Zotero.DB.columnQueryAsync(
   1816 			"SELECT key FROM syncQueue WHERE libraryID=? AND "
   1817 				+ "syncObjectTypeID IN (SELECT syncObjectTypeID FROM syncObjectTypes WHERE name=?)",
   1818 			[libraryID, objectType]
   1819 		);
   1820 	},
   1821 	
   1822 	
   1823 	hasObjectsToTryInSyncQueue: Zotero.Promise.coroutine(function* (libraryID) {
   1824 		var rows = yield Zotero.DB.queryAsync(
   1825 			"SELECT key, lastCheck, tries FROM syncQueue WHERE libraryID=?", libraryID
   1826 		);
   1827 		for (let row of rows) {
   1828 			let interval = this._syncQueueIntervals[row.tries];
   1829 			// Keep using last interval if beyond
   1830 			if (!interval) {
   1831 				interval = this._syncQueueIntervals[this._syncQueueIntervals.length - 1];
   1832 			}
   1833 			let nextCheck = row.lastCheck + interval * 60 * 60;
   1834 			if (nextCheck <= Zotero.Date.getUnixTimestamp()) {
   1835 				return true;
   1836 			}
   1837 		}
   1838 		return false;
   1839 	}),
   1840 	
   1841 	
   1842 	getObjectsToTryFromSyncQueue: Zotero.Promise.coroutine(function* (objectType, libraryID) {
   1843 		var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
   1844 		var rows = yield Zotero.DB.queryAsync(
   1845 			"SELECT key, lastCheck, tries FROM syncQueue WHERE libraryID=? AND syncObjectTypeID=?",
   1846 			[libraryID, syncObjectTypeID]
   1847 		);
   1848 		var keysToTry = [];
   1849 		for (let row of rows) {
   1850 			let interval = this._syncQueueIntervals[row.tries];
   1851 			// Keep using last interval if beyond
   1852 			if (!interval) {
   1853 				interval = this._syncQueueIntervals[this._syncQueueIntervals.length - 1];
   1854 			}
   1855 			let nextCheck = row.lastCheck + interval * 60 * 60;
   1856 			if (nextCheck <= Zotero.Date.getUnixTimestamp()) {
   1857 				keysToTry.push(row.key);
   1858 			}
   1859 		}
   1860 		return keysToTry;
   1861 	}),
   1862 	
   1863 	
   1864 	removeObjectsFromSyncQueue: function (objectType, libraryID, keys) {
   1865 		var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
   1866 		var sql = "DELETE FROM syncQueue WHERE libraryID=? AND syncObjectTypeID=? AND key IN (";
   1867 		return Zotero.Utilities.Internal.forEachChunkAsync(
   1868 			keys,
   1869 			Zotero.DB.MAX_BOUND_PARAMETERS - 2,
   1870 			Zotero.Promise.coroutine(function* (chunk) {
   1871 				var params = [libraryID, syncObjectTypeID].concat(chunk);
   1872 				return Zotero.DB.queryAsync(
   1873 					sql + Array(chunk.length).fill('?').join(',') + ")", params
   1874 				);
   1875 			})
   1876 		);
   1877 	},
   1878 	
   1879 	
   1880 	clearQueueForLibrary: async function (libraryID) {
   1881 		await Zotero.DB.queryAsync("DELETE FROM syncQueue WHERE libraryID=?", libraryID);
   1882 	},
   1883 	
   1884 	
   1885 	_removeObjectFromSyncQueue: function (objectType, libraryID, key) {
   1886 		return Zotero.DB.queryAsync(
   1887 			"DELETE FROM syncQueue WHERE libraryID=? AND key=? AND syncObjectTypeID=?",
   1888 			[
   1889 				libraryID,
   1890 				key,
   1891 				Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType)
   1892 			]
   1893 		);
   1894 	},
   1895 	
   1896 	
   1897 	resetSyncQueue: function () {
   1898 		return Zotero.DB.queryAsync("DELETE FROM syncQueue");
   1899 	},
   1900 	
   1901 	
   1902 	resetSyncQueueTries: function () {
   1903 		return Zotero.DB.queryAsync("UPDATE syncQueue SET tries=0");
   1904 	}
   1905 }