www

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

storageLocal.js (31995B)


      1 Zotero.Sync.Storage.Local = {
      2 	//
      3 	// Constants
      4 	//
      5 	SYNC_STATE_TO_UPLOAD: 0,
      6 	SYNC_STATE_TO_DOWNLOAD: 1,
      7 	SYNC_STATE_IN_SYNC: 2,
      8 	SYNC_STATE_FORCE_UPLOAD: 3,
      9 	SYNC_STATE_FORCE_DOWNLOAD: 4,
     10 	SYNC_STATE_IN_CONFLICT: 5,
     11 	
     12 	lastFullFileCheck: {},
     13 	uploadCheckFiles: [],
     14 	
     15 	getEnabledForLibrary: function (libraryID) {
     16 		var libraryType = Zotero.Libraries.get(libraryID).libraryType;
     17 		switch (libraryType) {
     18 		case 'user':
     19 			return Zotero.Prefs.get("sync.storage.enabled");
     20 		
     21 		// TEMP: Always sync publications files, at least until we have a better interface for
     22 		// setting library-specific settings
     23 		case 'publications':
     24 			return true;
     25 		
     26 		case 'group':
     27 			return Zotero.Prefs.get("sync.storage.groups.enabled");
     28 		
     29 		case 'feed':
     30 			return false;
     31 		
     32 		default:
     33 			throw new Error(`Unexpected library type '${libraryType}'`);
     34 		}
     35 	},
     36 	
     37 	getClassForLibrary: function (libraryID) {
     38 		return Zotero.Sync.Storage.Utilities.getClassForMode(this.getModeForLibrary(libraryID));
     39 	},
     40 	
     41 	getModeForLibrary: function (libraryID) {
     42 		var libraryType = Zotero.Libraries.get(libraryID).libraryType;
     43 		switch (libraryType) {
     44 		case 'user':
     45 			return Zotero.Prefs.get("sync.storage.protocol") == 'webdav' ? 'webdav' : 'zfs';
     46 		
     47 		case 'publications':
     48 		case 'group':
     49 		// TODO: Remove after making sure this is never called for feed libraries
     50 		case 'feed':
     51 			return 'zfs';
     52 		
     53 		default:
     54 			throw new Error(`Unexpected library type '${libraryType}'`);
     55 		}
     56 	},
     57 	
     58 	setModeForLibrary: function (libraryID, mode) {
     59 		var libraryType = Zotero.Libraries.get(libraryID).libraryType;
     60 		
     61 		if (libraryType != 'user') {
     62 			throw new Error(`Cannot set storage mode for ${libraryType} library`);
     63 		}
     64 		
     65 		switch (mode) {
     66 		case 'webdav':
     67 		case 'zfs':
     68 			Zotero.Prefs.set("sync.storage.protocol", mode);
     69 			break;
     70 		
     71 		default:
     72 			throw new Error(`Unexpected storage mode '${mode}'`);
     73 		}
     74 	},
     75 	
     76 	/**
     77 	 * Check or enable download-as-needed mode
     78 	 *
     79 	 * @param {Integer} [libraryID]
     80 	 * @param {Boolean} [enable] - If true, enable download-as-needed mode for the given library
     81 	 * @return {Boolean|undefined} - If 'enable' isn't set to true, return true if
     82 	 *     download-as-needed mode enabled and false if not
     83 	 */
     84 	downloadAsNeeded: function (libraryID, enable) {
     85 		var pref = this._getDownloadPrefFromLibrary(libraryID);
     86 		var val = 'on-demand';
     87 		if (enable) {
     88 			Zotero.Prefs.set(pref, val);
     89 			return;
     90 		}
     91 		return Zotero.Prefs.get(pref) == val;
     92 	},
     93 	
     94 	/**
     95 	 * Check or enable download-on-sync mode
     96 	 *
     97 	 * @param {Integer} [libraryID]
     98 	 * @param {Boolean} [enable] - If true, enable download-on-demand mode for the given library
     99 	 * @return {Boolean|undefined} - If 'enable' isn't set to true, return true if
    100 	 *     download-as-needed mode enabled and false if not
    101 	 */
    102 	downloadOnSync: function (libraryID, enable) {
    103 		var pref = this._getDownloadPrefFromLibrary(libraryID);
    104 		var val = 'on-sync';
    105 		if (enable) {
    106 			Zotero.Prefs.set(pref, val);
    107 			return;
    108 		}
    109 		return Zotero.Prefs.get(pref) == val;
    110 	},
    111 	
    112 	_getDownloadPrefFromLibrary: function (libraryID) {
    113 		if (libraryID == Zotero.Libraries.userLibraryID) {
    114 			return 'sync.storage.downloadMode.personal';
    115 		}
    116 		// TODO: Library-specific settings
    117 		
    118 		// Group library
    119 		return 'sync.storage.downloadMode.groups';
    120 	},
    121 	
    122 	/**
    123 	 * Get files to check for local modifications for uploading
    124 	 *
    125 	 * This includes files previously modified or opened externally via Zotero within maxCheckAge
    126 	 */
    127 	getFilesToCheck: Zotero.Promise.coroutine(function* (libraryID, maxCheckAge) {
    128 		var minTime = new Date().getTime() - (maxCheckAge * 1000);
    129 		
    130 		// Get files modified and synced since maxCheckAge
    131 		var sql = "SELECT itemID FROM itemAttachments JOIN items USING (itemID) "
    132 			+ "WHERE libraryID=? AND linkMode IN (?,?) AND syncState IN (?) AND "
    133 			+ "storageModTime>=?";
    134 		var params = [
    135 			libraryID,
    136 			Zotero.Attachments.LINK_MODE_IMPORTED_FILE,
    137 			Zotero.Attachments.LINK_MODE_IMPORTED_URL,
    138 			this.SYNC_STATE_IN_SYNC,
    139 			minTime
    140 		];
    141 		var itemIDs = yield Zotero.DB.columnQueryAsync(sql, params);
    142 		
    143 		// Get files opened since maxCheckAge
    144 		itemIDs = itemIDs.concat(
    145 			this.uploadCheckFiles.filter(x => x.timestamp >= minTime).map(x => x.itemID)
    146 		);
    147 		
    148 		return Zotero.Utilities.arrayUnique(itemIDs);
    149 	}),
    150 	
    151 	
    152 	/**
    153 	 * Scans local files and marks any that have changed for uploading
    154 	 * and any that are missing for downloading
    155 	 *
    156 	 * @param {Integer} libraryID
    157 	 * @param {Integer[]} [itemIDs]
    158 	 * @param {Object} [itemModTimes]  Item mod times indexed by item ids;
    159 	 *                                 items with stored mod times
    160 	 *                                 that differ from the provided
    161 	 *                                 time but file mod times
    162 	 *                                 matching the stored time will
    163 	 *                                 be marked for download
    164 	 * @return {Promise} Promise resolving to TRUE if any items changed state,
    165 	 *                   FALSE otherwise
    166 	 */
    167 	checkForUpdatedFiles: Zotero.Promise.coroutine(function* (libraryID, itemIDs, itemModTimes) {
    168 		var libraryName = Zotero.Libraries.getName(libraryID);
    169 		var msg = "Checking for locally changed attachment files in " + libraryName;
    170 		
    171 		var memmgr = Components.classes["@mozilla.org/memory-reporter-manager;1"]
    172 			.getService(Components.interfaces.nsIMemoryReporterManager);
    173 		memmgr.init();
    174 		//Zotero.debug("Memory usage: " + memmgr.resident);
    175 		
    176 		if (itemIDs) {
    177 			if (!itemIDs.length) {
    178 				Zotero.debug("No files to check for local changes");
    179 				return false;
    180 			}
    181 		}
    182 		if (itemModTimes) {
    183 			if (!Object.keys(itemModTimes).length) {
    184 				return false;
    185 			}
    186 			msg += " in download-marking mode";
    187 		}
    188 		
    189 		Zotero.debug(msg);
    190 		
    191 		var changed = false;
    192 		
    193 		if (!itemIDs) {
    194 			itemIDs = Object.keys(itemModTimes ? itemModTimes : {});
    195 		}
    196 		
    197 		// Can only handle a certain number of bound parameters at a time
    198 		var numIDs = itemIDs.length;
    199 		var maxIDs = Zotero.DB.MAX_BOUND_PARAMETERS - 10;
    200 		var done = 0;
    201 		var rows = [];
    202 		
    203 		do {
    204 			let chunk = itemIDs.splice(0, maxIDs);
    205 			let sql = "SELECT itemID, linkMode, path, storageModTime, storageHash, syncState "
    206 						+ "FROM itemAttachments JOIN items USING (itemID) "
    207 						+ "WHERE linkMode IN (?,?) AND syncState IN (?,?)";
    208 			let params = [
    209 				Zotero.Attachments.LINK_MODE_IMPORTED_FILE,
    210 				Zotero.Attachments.LINK_MODE_IMPORTED_URL,
    211 				this.SYNC_STATE_TO_UPLOAD,
    212 				this.SYNC_STATE_IN_SYNC
    213 			];
    214 			if (libraryID !== false) {
    215 				sql += " AND libraryID=?";
    216 				params.push(libraryID);
    217 			}
    218 			if (chunk.length) {
    219 				sql += " AND itemID IN (" + chunk.map(() => '?').join() + ")";
    220 				params = params.concat(chunk);
    221 			}
    222 			let chunkRows = yield Zotero.DB.queryAsync(sql, params);
    223 			if (chunkRows) {
    224 				rows = rows.concat(chunkRows);
    225 			}
    226 			done += chunk.length;
    227 		}
    228 		while (done < numIDs);
    229 		
    230 		// If no files, or everything is already marked for download,
    231 		// we don't need to do anything
    232 		if (!rows.length) {
    233 			Zotero.debug("No in-sync or to-upload files found in " + libraryName);
    234 			return false;
    235 		}
    236 		
    237 		// Index attachment data by item id
    238 		itemIDs = [];
    239 		var attachmentData = {};
    240 		for (let row of rows) {
    241 			var id = row.itemID;
    242 			itemIDs.push(id);
    243 			attachmentData[id] = {
    244 				linkMode: row.linkMode,
    245 				path: row.path,
    246 				mtime: row.storageModTime,
    247 				hash: row.storageHash,
    248 				state: row.syncState
    249 			};
    250 		}
    251 		rows = null;
    252 		
    253 		var t = new Date();
    254 		var items = yield Zotero.Items.getAsync(itemIDs, { noCache: true });
    255 		var numItems = items.length;
    256 		var updatedStates = {};
    257 		
    258 		//Zotero.debug("Memory usage: " + memmgr.resident);
    259 		
    260 		var changed = false;
    261 		var statesToSet = {};
    262 		for (let item of items) {
    263 			// TODO: Catch error?
    264 			let state = yield this._checkForUpdatedFile(item, attachmentData[item.id]);
    265 			if (state !== false) {
    266 				if (!statesToSet[state]) {
    267 					statesToSet[state] = [];
    268 				}
    269 				statesToSet[state].push(item);
    270 				changed = true;
    271 			}
    272 		}
    273 		// Update sync states in bulk
    274 		if (changed) {
    275 			yield Zotero.DB.executeTransaction(function* () {
    276 				for (let state in statesToSet) {
    277 					yield this.updateSyncStates(statesToSet[state], parseInt(state));
    278 				}
    279 			}.bind(this));
    280 		}
    281 		
    282 		if (!items.length) {
    283 			Zotero.debug("No synced files have changed locally");
    284 		}
    285 		
    286 		Zotero.debug(`Checked ${numItems} files in ${libraryName} in ` + (new Date() - t) + " ms");
    287 		
    288 		return changed;
    289 	}),
    290 	
    291 	
    292 	_checkForUpdatedFile: Zotero.Promise.coroutine(function* (item, attachmentData, remoteModTime) {
    293 		var lk = item.libraryKey;
    294 		Zotero.debug("Checking attachment file for item " + lk, 4);
    295 		
    296 		var path = item.getFilePath();
    297 		if (!path) {
    298 			Zotero.debug("Marking pathless attachment " + lk + " as in-sync");
    299 			return this.SYNC_STATE_IN_SYNC;
    300 		}
    301 		var fileName = OS.Path.basename(path);
    302 		var file;
    303 		
    304 		try {
    305 			file = yield OS.File.open(path);
    306 			let info = yield file.stat();
    307 			//Zotero.debug("Memory usage: " + memmgr.resident);
    308 			
    309 			let fmtime = info.lastModificationDate.getTime();
    310 			//Zotero.debug("File modification time for item " + lk + " is " + fmtime);
    311 			
    312 			if (fmtime < 0) {
    313 				Zotero.debug("File mod time " + fmtime + " is less than 0 -- interpreting as 0", 2);
    314 				fmtime = 0;
    315 			}
    316 			
    317 			// If file is already marked for upload, skip check. Even if the file was changed
    318 			// both locally and remotely, conflicts are checked at upload time, so we don't need
    319 			// to worry about it here.
    320 			if (item.attachmentSyncState == this.SYNC_STATE_TO_UPLOAD) {
    321 				Zotero.debug("File is already marked for upload");
    322 				return false;
    323 			}
    324 			
    325 			//Zotero.debug("Stored mtime is " + attachmentData.mtime);
    326 			//Zotero.debug("File mtime is " + fmtime);
    327 			
    328 			//BAIL AFTER DOWNLOAD MARKING MODE, OR CHECK LOCAL?
    329 			let mtime = attachmentData ? attachmentData.mtime : false;
    330 			
    331 			// Download-marking mode
    332 			if (remoteModTime) {
    333 				Zotero.debug(`Remote mod time for item ${lk} is ${remoteModTime}`);
    334 				
    335 				// Ignore attachments whose stored mod times haven't changed
    336 				mtime = mtime !== false ? mtime : item.attachmentSyncedModificationTime;
    337 				if (mtime == remoteModTime) {
    338 					Zotero.debug(`Synced mod time (${mtime}) hasn't changed for item ${lk}`);
    339 					return false;
    340 				}
    341 				
    342 				Zotero.debug(`Marking attachment ${lk} for download (stored mtime: ${mtime})`);
    343 				// DEBUG: Always set here, or allow further steps?
    344 				return this.SYNC_STATE_FORCE_DOWNLOAD;
    345 			}
    346 			
    347 			var same = !this.checkFileModTime(item, fmtime, mtime);
    348 			if (same) {
    349 				Zotero.debug("File has not changed");
    350 				return false;
    351 			}
    352 			
    353 			// If file hash matches stored hash, only the mod time changed, so skip
    354 			let fileHash = yield Zotero.Utilities.Internal.md5Async(file);
    355 			
    356 			var hash = attachmentData ? attachmentData.hash : (yield this.getSyncedHash(item.id));
    357 			if (hash && hash == fileHash) {
    358 				// We have to close the file before modifying it from the main
    359 				// thread (at least on Windows, where assigning lastModifiedTime
    360 				// throws an NS_ERROR_FILE_IS_LOCKED otherwise)
    361 				yield file.close();
    362 				
    363 				Zotero.debug("Mod time didn't match (" + fmtime + " != " + mtime + ") "
    364 					+ "but hash did for " + fileName + " for item " + lk
    365 					+ " -- updating file mod time");
    366 				try {
    367 					yield OS.File.setDates(path, null, mtime);
    368 				}
    369 				catch (e) {
    370 					Zotero.File.checkFileAccessError(e, path, 'update');
    371 				}
    372 				return false;
    373 			}
    374 			
    375 			// Mark file for upload
    376 			Zotero.debug("Marking attachment " + lk + " as changed "
    377 				+ "(" + mtime + " != " + fmtime + ")");
    378 			return this.SYNC_STATE_TO_UPLOAD;
    379 		}
    380 		catch (e) {
    381 			if (e instanceof OS.File.Error) {
    382 				let missing = e.becauseNoSuchFile
    383 					// ERROR_PATH_NOT_FOUND: This can happen if a path is too long on Windows, e.g. a
    384 					// file is being accessed on a VM through a share (and probably in other cases)
    385 					|| e.winLastError == 3
    386 					// ERROR_INVALID_NAME: This can happen if there's a colon in the name from before
    387 					// we were filtering
    388 					|| e.winLastError == 123
    389 					// ERROR_BAD_PATHNAME
    390 					|| e.winLastError == 161;
    391 				if (!missing) {
    392 					Components.classes["@mozilla.org/net/osfileconstantsservice;1"]
    393 						.getService(Components.interfaces.nsIOSFileConstantsService)
    394 						.init();
    395 					missing = e.unixErrno == OS.Constants.libc.ENOTDIR
    396 						// Handle long filenames on OS X/Linux
    397 						|| e.unixErrno == OS.Constants.libc.ENAMETOOLONG;
    398 				}
    399 				if (missing) {
    400 					if (!e.becauseNoSuchFile) {
    401 						Zotero.debug(e, 1);
    402 					}
    403 					Zotero.debug("Marking attachment " + lk + " as missing");
    404 					return this.SYNC_STATE_TO_DOWNLOAD;
    405 				}
    406 				if (e.becauseClosed) {
    407 					Zotero.debug("File was closed", 2);
    408 				}
    409 				Zotero.debug(e, 1);
    410 				Zotero.debug(e.unixErrno, 1);
    411 				Zotero.debug(e.winLastError, 1);
    412 				throw new Error(`Error for operation '${e.operation}' for ${path}: ${e}`);
    413 			}
    414 			throw e;
    415 		}
    416 		finally {
    417 			if (file) {
    418 				//Zotero.debug("Closing file for item " + lk);
    419 				file.close();
    420 			}
    421 		}
    422 	}),
    423 	
    424 	/**
    425 	 *
    426 	 * @param {Zotero.Item} item
    427 	 * @param {Integer} fmtime - File modification time in milliseconds
    428 	 * @param {Integer} mtime - Remote modification time in milliseconds
    429 	 * @return {Boolean} - True if file modification time differs from remote mod time,
    430 	 *                     false otherwise
    431 	 */
    432 	checkFileModTime: function (item, fmtime, mtime) {
    433 		var libraryKey = item.libraryKey;
    434 		
    435 		if (fmtime == mtime) {
    436 			Zotero.debug(`Mod time for ${libraryKey} matches remote file -- skipping`);
    437 		}
    438 		// Compare floored timestamps for filesystems that don't support millisecond
    439 		// precision (e.g., HFS+)
    440 		else if (Math.floor(mtime / 1000) == Math.floor(fmtime / 1000)) {
    441 			Zotero.debug(`File mod times for ${libraryKey} are within one-second precision `
    442 				+ "(" + fmtime + " \u2248 " + mtime + ") -- skipping");
    443 		}
    444 		// Allow timestamp to be exactly one hour off to get around time zone issues
    445 		// -- there may be a proper way to fix this
    446 		else if (Math.abs(Math.floor(fmtime / 1000) - Math.floor(mtime / 1000)) == 3600) {
    447 			Zotero.debug(`File mod time (${fmtime}) for {$libraryKey} is exactly one hour off `
    448 				+ `remote file (${mtime}) -- assuming time zone issue and skipping`);
    449 		}
    450 		else {
    451 			return true;
    452 		}
    453 		
    454 		return false;
    455 	},
    456 	
    457 	checkForForcedDownloads: Zotero.Promise.coroutine(function* (libraryID) {
    458 		// Forced downloads happen even in on-demand mode
    459 		var sql = "SELECT COUNT(*) FROM items JOIN itemAttachments USING (itemID) "
    460 			+ "WHERE libraryID=? AND syncState=?";
    461 		return !!(yield Zotero.DB.valueQueryAsync(
    462 			sql, [libraryID, this.SYNC_STATE_FORCE_DOWNLOAD]
    463 		));
    464 	}),
    465 	
    466 	
    467 	/**
    468 	 * Get files marked as ready to download
    469 	 *
    470 	 * @param {Integer} libraryID
    471 	 * @return {Promise<Number[]>} - Promise for an array of attachment itemIDs
    472 	 */
    473 	getFilesToDownload: function (libraryID, forcedOnly) {
    474 		var sql = "SELECT itemID FROM itemAttachments JOIN items USING (itemID) "
    475 					+ "WHERE libraryID=? AND syncState IN (?";
    476 		var params = [libraryID, this.SYNC_STATE_FORCE_DOWNLOAD];
    477 		if (!forcedOnly) {
    478 			sql += ",?";
    479 			params.push(this.SYNC_STATE_TO_DOWNLOAD);
    480 		}
    481 		sql += ") "
    482 			// Skip attachments with empty path, which can't be saved, and files with .zotero*
    483 			// paths, which have somehow ended up in some users' libraries
    484 			+ "AND path!='' AND path NOT LIKE ?";
    485 		params.push('storage:.zotero%');
    486 		return Zotero.DB.columnQueryAsync(sql, params);
    487 	},
    488 	
    489 	
    490 	/**
    491 	 * Get files marked as ready to upload
    492 	 *
    493 	 * @param {Integer} libraryID
    494 	 * @return {Promise<Number[]>} - Promise for an array of attachment itemIDs
    495 	 */
    496 	getFilesToUpload: function (libraryID) {
    497 		var sql = "SELECT itemID FROM itemAttachments JOIN items USING (itemID) "
    498 			+ "WHERE libraryID=? AND syncState IN (?,?) AND linkMode IN (?,?)";
    499 		var params = [
    500 			libraryID,
    501 			this.SYNC_STATE_TO_UPLOAD,
    502 			this.SYNC_STATE_FORCE_UPLOAD,
    503 			Zotero.Attachments.LINK_MODE_IMPORTED_FILE,
    504 			Zotero.Attachments.LINK_MODE_IMPORTED_URL
    505 		];
    506 		return Zotero.DB.columnQueryAsync(sql, params);
    507 	},
    508 	
    509 	
    510 	/**
    511 	 * @param {Integer} libraryID
    512 	 * @return {Promise<String[]>} - Promise for an array of item keys
    513 	 */
    514 	getDeletedFiles: function (libraryID) {
    515 		var sql = "SELECT key FROM storageDeleteLog WHERE libraryID=?";
    516 		return Zotero.DB.columnQueryAsync(sql, libraryID);
    517 	},
    518 	
    519 	
    520 	/**
    521 	 * @param {Zotero.Item[]} items
    522 	 * @param {String|Integer} syncState
    523 	 * @return {Promise}
    524 	 */
    525 	updateSyncStates: function (items, syncState) {
    526 		if (syncState === undefined) {
    527 			throw new Error("Sync state not specified");
    528 		}
    529 		if (typeof syncState == 'string') {
    530 			syncState = this["SYNC_STATE_" + syncState.toUpperCase()];
    531 		}
    532 		return Zotero.Utilities.Internal.forEachChunkAsync(
    533 			items,
    534 			1000,
    535 			async function (chunk) {
    536 				chunk.forEach((item) => {
    537 					item._attachmentSyncState = syncState;
    538 				});
    539 				return Zotero.DB.queryAsync(
    540 					"UPDATE itemAttachments SET syncState=? WHERE itemID IN "
    541 						+ "(" + chunk.map(item => item.id).join(', ') + ")",
    542 					syncState
    543 				);
    544 			}
    545 		);
    546 	},
    547 	
    548 	
    549 	/**
    550 	 * Mark all stored files for upload checking
    551 	 *
    552 	 * This is used when switching between storage modes in the preferences so that all existing files
    553 	 * are uploaded via the new mode if necessary.
    554 	 */
    555 	resetAllSyncStates: async function (libraryID) {
    556 		if (!libraryID) {
    557 			throw new Error("libraryID not provided");
    558 		}
    559 		
    560 		return Zotero.DB.executeTransaction(async function () {
    561 			var sql = "SELECT itemID FROM items JOIN itemAttachments USING (itemID) "
    562 				+ "WHERE libraryID=? AND itemTypeID=? AND linkMode IN (?, ?)";
    563 			var params = [
    564 				libraryID,
    565 				Zotero.ItemTypes.getID('attachment'),
    566 				Zotero.Attachments.LINK_MODE_IMPORTED_FILE,
    567 				Zotero.Attachments.LINK_MODE_IMPORTED_URL,
    568 			];
    569 			var itemIDs = await Zotero.DB.columnQueryAsync(sql, params);
    570 			for (let itemID of itemIDs) {
    571 				let item = Zotero.Items.get(itemID);
    572 				item._attachmentSyncState = this.SYNC_STATE_TO_UPLOAD;
    573 			}
    574 			sql = "UPDATE itemAttachments SET syncState=? WHERE itemID IN (" + sql + ")";
    575 			await Zotero.DB.queryAsync(sql, [this.SYNC_STATE_TO_UPLOAD].concat(params));
    576 		}.bind(this));
    577 	},
    578 	
    579 	
    580 	/**
    581 	 * Extract a downloaded file and update the database metadata
    582 	 *
    583 	 * @param {Zotero.Item} data.item
    584 	 * @param {Integer}     data.mtime
    585 	 * @param {String}      data.md5
    586 	 * @param {Boolean}     data.compressed
    587 	 * @return {Promise}
    588 	 */
    589 	processDownload: Zotero.Promise.coroutine(function* (data) {
    590 		if (!data) {
    591 			throw new Error("'data' not set");
    592 		}
    593 		if (!data.item) {
    594 			throw new Error("'data.item' not set");
    595 		}
    596 		if (!data.mtime) {
    597 			throw new Error("'data.mtime' not set");
    598 		}
    599 		if (data.mtime != parseInt(data.mtime)) {
    600 			throw new Error("Invalid mod time '" + data.mtime + "'");
    601 		}
    602 		if (!data.compressed && !data.md5) {
    603 			throw new Error("'data.md5' is required if 'data.compressed'");
    604 		}
    605 		
    606 		var item = data.item;
    607 		var mtime = parseInt(data.mtime);
    608 		var md5 = data.md5;
    609 		
    610 		// TODO: Test file hash
    611 		
    612 		if (data.compressed) {
    613 			var newPath = yield this._processZipDownload(item);
    614 		}
    615 		else {
    616 			var newPath = yield this._processSingleFileDownload(item);
    617 		}
    618 		
    619 		// If newPath is set, the file was renamed, so set item filename to that
    620 		// and mark for updated
    621 		var path = yield item.getFilePathAsync();
    622 		if (newPath && path != newPath) {
    623 			// If library isn't editable but filename was changed, update
    624 			// database without updating the item's mod time, which would result
    625 			// in a library access error
    626 			try {
    627 				if (!Zotero.Items.isEditable(item)) {
    628 					Zotero.debug("File renamed without library access -- "
    629 						+ "updating itemAttachments path", 3);
    630 					yield item.relinkAttachmentFile(newPath, true);
    631 				}
    632 				else {
    633 					yield item.relinkAttachmentFile(newPath);
    634 				}
    635 			}
    636 			catch (e) {
    637 				Zotero.File.checkFileAccessError(e, path, 'update');
    638 			}
    639 			
    640 			path = newPath;
    641 		}
    642 		
    643 		if (!path) {
    644 			// This can happen if an HTML snapshot filename was changed and synced
    645 			// elsewhere but the renamed file wasn't synced, so the ZIP doesn't
    646 			// contain a file with the known name
    647 			Components.utils.reportError("File '" + item.attachmentFilename
    648 				+ "' not found after processing download " + item.libraryKey);
    649 			return new Zotero.Sync.Storage.Result({
    650 				localChanges: false
    651 			});
    652 		}
    653 		
    654 		try {
    655 			// If hash not provided (e.g., WebDAV), calculate it now
    656 			if (!md5) {
    657 				md5 = yield item.attachmentHash;
    658 			}
    659 			
    660 			// Set the file mtime to the time from the server
    661 			yield OS.File.setDates(path, null, new Date(parseInt(mtime)));
    662 		}
    663 		catch (e) {
    664 			Zotero.File.checkFileAccessError(e, path, 'update');
    665 		}
    666 		
    667 		item.attachmentSyncedModificationTime = mtime;
    668 		item.attachmentSyncedHash = md5;
    669 		item.attachmentSyncState = "in_sync";
    670 		yield item.saveTx({ skipAll: true });
    671 		
    672 		return new Zotero.Sync.Storage.Result({
    673 			localChanges: true
    674 		});
    675 	}),
    676 	
    677 	
    678 	_processSingleFileDownload: Zotero.Promise.coroutine(function* (item) {
    679 		var tempFilePath = OS.Path.join(Zotero.getTempDirectory().path, item.key + '.tmp');
    680 		
    681 		if (!(yield OS.File.exists(tempFilePath))) {
    682 			Zotero.debug(tempFilePath, 1);
    683 			throw new Error("Downloaded file not found");
    684 		}
    685 		
    686 		yield Zotero.Attachments.createDirectoryForItem(item);
    687 		
    688 		var filename = item.attachmentFilename;
    689 		if (!filename) {
    690 			Zotero.debug("Empty filename for item " + item.key, 2);
    691 		}
    692 		// Don't save Windows aliases
    693 		if (filename.endsWith('.lnk')) {
    694 			return false;
    695 		}
    696 		
    697 		var attachmentDir = Zotero.Attachments.getStorageDirectory(item).path;
    698 		var renamed = false;
    699 		
    700 		// Make sure the new filename is valid, in case an invalid character made it over
    701 		// (e.g., from before we checked for them)
    702 		var filteredFilename = Zotero.File.getValidFileName(filename);
    703 		if (filteredFilename != filename) {
    704 			Zotero.debug("Filtering filename '" + filename + "' to '" + filteredFilename + "'");
    705 			filename = filteredFilename;
    706 			renamed = true;
    707 		}
    708 		var path = OS.Path.join(attachmentDir, filename);
    709 		
    710 		Zotero.debug("Moving download file " + OS.Path.basename(tempFilePath)
    711 			+ ` into attachment directory as '${filename}'`);
    712 		try {
    713 			var finalFilename = Zotero.File.createShortened(
    714 				path, Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0o644
    715 			);
    716 		}
    717 		catch (e) {
    718 			Zotero.File.checkFileAccessError(e, path, 'create');
    719 		}
    720 		
    721 		if (finalFilename != filename) {
    722 			Zotero.debug("Changed filename '" + filename + "' to '" + finalFilename + "'");
    723 			
    724 			filename = finalFilename;
    725 			path = OS.Path.join(attachmentDir, filename);
    726 			
    727 			// Abort if Windows path limitation would cause filenames to be overly truncated
    728 			if (Zotero.isWin && filename.length < 40) {
    729 				try {
    730 					yield OS.File.remove(path);
    731 				}
    732 				catch (e) {}
    733 				// TODO: localize
    734 				var msg = "Due to a Windows path length limitation, your Zotero data directory "
    735 					+ "is too deep in the filesystem for syncing to work reliably. "
    736 					+ "Please relocate your Zotero data to a higher directory.";
    737 				Zotero.debug(msg, 1);
    738 				throw new Error(msg);
    739 			}
    740 			
    741 			renamed = true;
    742 		}
    743 		
    744 		try {
    745 			yield OS.File.move(tempFilePath, path);
    746 		}
    747 		catch (e) {
    748 			try {
    749 				yield OS.File.remove(tempFilePath);
    750 			}
    751 			catch (e) {}
    752 			
    753 			Zotero.File.checkFileAccessError(e, path, 'create');
    754 		}
    755 		
    756 		// processDownload() needs to know that we're renaming the file
    757 		return renamed ? path : null;
    758 	}),
    759 	
    760 	
    761 	_processZipDownload: Zotero.Promise.coroutine(function* (item) {
    762 		var zipFile = Zotero.getTempDirectory();
    763 		zipFile.append(item.key + '.tmp');
    764 		
    765 		if (!zipFile.exists()) {
    766 			Zotero.debug(zipFile.path);
    767 			throw new Error(`Downloaded ZIP file not found for item ${item.libraryKey}`);
    768 		}
    769 		
    770 		var zipReader = Components.classes["@mozilla.org/libjar/zip-reader;1"].
    771 				createInstance(Components.interfaces.nsIZipReader);
    772 		try {
    773 			zipReader.open(zipFile);
    774 			zipReader.test(null);
    775 			
    776 			Zotero.debug("ZIP file is OK");
    777 		}
    778 		catch (e) {
    779 			Zotero.debug(zipFile.leafName + " is not a valid ZIP file", 2);
    780 			zipReader.close();
    781 			
    782 			try {
    783 				zipFile.remove(false);
    784 			}
    785 			catch (e) {
    786 				Zotero.File.checkFileAccessError(e, zipFile, 'delete');
    787 			}
    788 			
    789 			// TODO: Remove prop file to trigger reuploading, in case it was an upload error?
    790 			
    791 			return false;
    792 		}
    793 		
    794 		var parentDir = Zotero.Attachments.getStorageDirectory(item).path;
    795 		try {
    796 			yield Zotero.Attachments.createDirectoryForItem(item);
    797 		}
    798 		catch (e) {
    799 			zipReader.close();
    800 			throw e;
    801 		}
    802 		
    803 		var returnFile = null;
    804 		var count = 0;
    805 		
    806 		var itemFileName = item.attachmentFilename;
    807 		
    808 		var entries = zipReader.findEntries(null);
    809 		while (entries.hasMore()) {
    810 			var entryName = entries.getNext();
    811 			var entry = zipReader.getEntry(entryName);
    812 			var b64re = /%ZB64$/;
    813 			if (entryName.match(b64re)) {
    814 				var filePath = Zotero.Utilities.Internal.Base64.decode(
    815 					entryName.replace(b64re, '')
    816 				);
    817 			}
    818 			else {
    819 				var filePath = entryName;
    820 			}
    821 			
    822 			if (filePath.startsWith('.zotero')) {
    823 				Zotero.debug("Skipping " + filePath);
    824 				continue;
    825 			}
    826 			
    827 			if (entry.isDirectory) {
    828 				Zotero.debug("Skipping directory " + filePath);
    829 				continue;
    830 			}
    831 			count++;
    832 			
    833 			Zotero.debug("Extracting " + filePath);
    834 			
    835 			var primaryFile = itemFileName == filePath;
    836 			var filtered = false;
    837 			var renamed = false;
    838 			
    839 			// Make sure all components of the path are valid, in case an invalid character somehow made
    840 			// it into the ZIP (e.g., from before we checked for them)
    841 			var filteredPath = filePath.split('/').map(part => Zotero.File.getValidFileName(part)).join('/');
    842 			if (filteredPath != filePath) {
    843 				Zotero.debug("Filtering filename '" + filePath + "' to '" + filteredPath + "'");
    844 				filePath = filteredPath;
    845 				filtered = true;
    846 			}
    847 			
    848 			var destPath = OS.Path.join(parentDir, ...filePath.split('/'));
    849 			
    850 			// If only one file in zip and it doesn't match the known filename,
    851 			// take our chances and use that name
    852 			if (count == 1 && !entries.hasMore() && itemFileName) {
    853 				// May not be necessary, but let's be safe
    854 				itemFileName = Zotero.File.getValidFileName(itemFileName);
    855 				if (itemFileName != filePath) {
    856 					let msg = "Renaming single file '" + filePath + "' in ZIP to known filename '" + itemFileName + "'";
    857 					Zotero.debug(msg, 2);
    858 					Components.utils.reportError(msg);
    859 					filePath = itemFileName;
    860 					destPath = OS.Path.join(OS.Path.dirname(destPath), itemFileName);
    861 					renamed = true;
    862 					primaryFile = true;
    863 				}
    864 			}
    865 			
    866 			if (primaryFile && filtered) {
    867 				renamed = true;
    868 			}
    869 			
    870 			if (yield OS.File.exists(destPath)) {
    871 				var msg = "ZIP entry '" + filePath + "' already exists";
    872 				Zotero.logError(msg);
    873 				Zotero.debug(destPath);
    874 				continue;
    875 			}
    876 			
    877 			let shortened;
    878 			try {
    879 				shortened = Zotero.File.createShortened(
    880 					destPath, Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0o644
    881 				);
    882 			}
    883 			catch (e) {
    884 				Zotero.logError(e);
    885 				
    886 				zipReader.close();
    887 				
    888 				Zotero.File.checkFileAccessError(e, destPath, 'create');
    889 			}
    890 			
    891 			if (OS.Path.basename(destPath) != shortened) {
    892 				Zotero.debug(`Changed filename '${OS.Path.basename(destPath)}' to '${shortened}'`);
    893 				
    894 				// Abort if Windows path limitation would cause filenames to be overly truncated
    895 				if (Zotero.isWin && shortened < 40) {
    896 					try {
    897 						yield OS.File.remove(destPath);
    898 					}
    899 					catch (e) {}
    900 					zipReader.close();
    901 					// TODO: localize
    902 					var msg = "Due to a Windows path length limitation, your Zotero data directory "
    903 						+ "is too deep in the filesystem for syncing to work reliably. "
    904 						+ "Please relocate your Zotero data to a higher directory.";
    905 					Zotero.debug(msg, 1);
    906 					throw new Error(msg);
    907 				}
    908 				
    909 				destPath = OS.Path.join(OS.Path.dirname(destPath), shortened);
    910 				
    911 				if (primaryFile) {
    912 					renamed = true;
    913 				}
    914 			}
    915 			
    916 			try {
    917 				zipReader.extract(entryName, Zotero.File.pathToFile(destPath));
    918 			}
    919 			catch (e) {
    920 				try {
    921 					yield OS.File.remove(destPath);
    922 				}
    923 				catch (e) {}
    924 				
    925 				// For advertising junk files, ignore a bug on Windows where
    926 				// destFile.create() works but zipReader.extract() doesn't
    927 				// when the path length is close to 255.
    928 				if (OS.Path.basename(destPath).match(/[a-zA-Z0-9+=]{130,}/)) {
    929 					var msg = "Ignoring error extracting '" + destPath + "'";
    930 					Zotero.debug(msg, 2);
    931 					Zotero.debug(e, 2);
    932 					Components.utils.reportError(msg + " in " + funcName);
    933 					continue;
    934 				}
    935 				
    936 				zipReader.close();
    937 				
    938 				Zotero.File.checkFileAccessError(e, destPath, 'create');
    939 			}
    940 			
    941 			yield Zotero.File.setNormalFilePermissions(destPath);
    942 			
    943 			// If we're renaming the main file, processDownload() needs to know
    944 			if (renamed) {
    945 				returnFile = destPath;
    946 			}
    947 		}
    948 		zipReader.close();
    949 		zipFile.remove(false);
    950 		
    951 		return returnFile;
    952 	}),
    953 	
    954 	
    955 	/**
    956 	 * @return {Promise<Object[]>} - A promise for an array of conflict objects
    957 	 */
    958 	getConflicts: Zotero.Promise.coroutine(function* (libraryID) {
    959 		var sql = "SELECT itemID, version FROM items JOIN itemAttachments USING (itemID) "
    960 			+ "WHERE libraryID=? AND syncState=?";
    961 		var rows = yield Zotero.DB.queryAsync(
    962 			sql,
    963 			[
    964 				{ int: libraryID },
    965 				this.SYNC_STATE_IN_CONFLICT
    966 			]
    967 		);
    968 		var keyVersionPairs = rows.map(function (row) {
    969 			var { libraryID, key } = Zotero.Items.getLibraryAndKeyFromID(row.itemID);
    970 			return [key, row.version];
    971 		});
    972 		var cacheObjects = yield Zotero.Sync.Data.Local.getCacheObjects(
    973 			'item', libraryID, keyVersionPairs
    974 		);
    975 		if (!cacheObjects.length) return [];
    976 		
    977 		var cacheObjectsByKey = {};
    978 		cacheObjects.forEach(obj => cacheObjectsByKey[obj.key] = obj);
    979 		
    980 		var items = [];
    981 		var localItems = yield Zotero.Items.getAsync(rows.map(row => row.itemID));
    982 		for (let localItem of localItems) {
    983 			// Use the mtime for the dateModified field, since that's all that's shown in the
    984 			// CR window at the moment
    985 			let localItemJSON = localItem.toJSON();
    986 			localItemJSON.dateModified = Zotero.Date.dateToISO(
    987 				new Date(yield localItem.attachmentModificationTime)
    988 			);
    989 			
    990 			let remoteItemJSON = cacheObjectsByKey[localItem.key];
    991 			if (!remoteItemJSON) {
    992 				Zotero.logError("Cached object not found for item " + localItem.libraryKey);
    993 				continue;
    994 			}
    995 			remoteItemJSON = remoteItemJSON.data;
    996 			if (remoteItemJSON.mtime) {
    997 				remoteItemJSON.dateModified = Zotero.Date.dateToISO(new Date(remoteItemJSON.mtime));
    998 			}
    999 			items.push({
   1000 				libraryID,
   1001 				left: localItemJSON,
   1002 				right: remoteItemJSON,
   1003 				changes: [],
   1004 				conflicts: []
   1005 			})
   1006 		}
   1007 		return items;
   1008 	}),
   1009 	
   1010 	
   1011 	resolveConflicts: Zotero.Promise.coroutine(function* (libraryID) {
   1012 		var conflicts = yield this.getConflicts(libraryID);
   1013 		if (!conflicts.length) return false;
   1014 		
   1015 		Zotero.debug("Reconciling conflicts for " + Zotero.Libraries.get(libraryID).name);
   1016 		
   1017 		var io = {
   1018 			dataIn: {
   1019 				type: 'file',
   1020 				captions: [
   1021 					Zotero.getString('sync.storage.localFile'),
   1022 					Zotero.getString('sync.storage.remoteFile'),
   1023 					Zotero.getString('sync.storage.savedFile')
   1024 				],
   1025 				conflicts
   1026 			}
   1027 		};
   1028 		
   1029 		var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
   1030 				   .getService(Components.interfaces.nsIWindowMediator);
   1031 		var lastWin = wm.getMostRecentWindow("navigator:browser");
   1032 		lastWin.openDialog('chrome://zotero/content/merge.xul', '', 'chrome,modal,centerscreen', io);
   1033 		
   1034 		if (!io.dataOut) {
   1035 			return false;
   1036 		}
   1037 		
   1038 		yield Zotero.DB.executeTransaction(function* () {
   1039 			for (let i = 0; i < conflicts.length; i++) {
   1040 				let conflict = conflicts[i];
   1041 				let item = Zotero.Items.getByLibraryAndKey(libraryID, conflict.left.key);
   1042 				let mtime = io.dataOut[i].data.dateModified;
   1043 				// Local
   1044 				if (mtime == conflict.left.dateModified) {
   1045 					syncState = this.SYNC_STATE_FORCE_UPLOAD;
   1046 					// When local version is chosen, update stored hash (and mtime) to remote values so
   1047 					// that upload goes through without 412
   1048 					item.attachmentSyncedModificationTime = conflict.right.mtime;
   1049 					item.attachmentSyncedHash = conflict.right.md5;
   1050 				}
   1051 				// Remote
   1052 				else {
   1053 					syncState = this.SYNC_STATE_FORCE_DOWNLOAD;
   1054 				}
   1055 				item.attachmentSyncState = syncState;
   1056 				yield item.save({ skipAll: true });
   1057 			}
   1058 		}.bind(this));
   1059 		return true;
   1060 	})
   1061 }