www

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

file.js (37234B)


      1 /*
      2     ***** BEGIN LICENSE BLOCK *****
      3     
      4     Copyright © 2009 Center for History and New Media
      5                      George Mason University, Fairfax, Virginia, USA
      6                      http://zotero.org
      7     
      8     This file is part of Zotero.
      9     
     10     Zotero is free software: you can redistribute it and/or modify
     11     it under the terms of the GNU Affero General Public License as published by
     12     the Free Software Foundation, either version 3 of the License, or
     13     (at your option) any later version.
     14     
     15     Zotero is distributed in the hope that it will be useful,
     16     but WITHOUT ANY WARRANTY; without even the implied warranty of
     17     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     18     GNU Affero General Public License for more details.
     19     
     20     You should have received a copy of the GNU Affero General Public License
     21     along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
     22     
     23     ***** END LICENSE BLOCK *****
     24 */
     25 
     26 /**
     27  * Functions for reading files
     28  * @namespace
     29  */
     30 Zotero.File = new function(){
     31 	Components.utils.import("resource://gre/modules/NetUtil.jsm");
     32 	Components.utils.import("resource://gre/modules/FileUtils.jsm");
     33 	
     34 	this.getExtension = getExtension;
     35 	this.getContentsFromURL = getContentsFromURL;
     36 	this.putContents = putContents;
     37 	this.getValidFileName = getValidFileName;
     38 	this.truncateFileName = truncateFileName;
     39 	this.getCharsetFromFile = getCharsetFromFile;
     40 	this.addCharsetListener = addCharsetListener;
     41 	
     42 	
     43 	this.pathToFile = function (pathOrFile) {
     44 		try {
     45 			if (typeof pathOrFile == 'string') {
     46 				return new FileUtils.File(pathOrFile);
     47 			}
     48 			else if (pathOrFile instanceof Ci.nsIFile) {
     49 				return pathOrFile;
     50 			}
     51 		}
     52 		catch (e) {
     53 			Zotero.logError(e);
     54 		}
     55 		throw new Error("Unexpected value '" + pathOrFile + "'");
     56 	}
     57 	
     58 	
     59 	this.pathToFileURI = function (path) {
     60 		var file = new FileUtils.File(path);
     61 		var ios = Components.classes["@mozilla.org/network/io-service;1"]
     62 			.getService(Components.interfaces.nsIIOService);
     63 		return ios.newFileURI(file).spec;
     64 	}
     65 	
     66 	
     67 	/**
     68 	 * Encode special characters in file paths that might cause problems,
     69 	 *  like # (but preserve slashes or colons)
     70 	 *
     71 	 * @param {String} path File path
     72 	 * @return {String} Encoded file path
     73 	 */
     74 	this.encodeFilePath = function(path) {
     75 		var parts = path.split(/([\\\/:]+)/);
     76 		// Every other item is the separator
     77 		for (var i=0, n=parts.length; i<n; i+=2) {
     78 			parts[i] = encodeURIComponent(parts[i]);
     79 		}
     80 		return parts.join('');
     81 	}
     82 	
     83 	function getExtension(file){
     84 		file = this.pathToFile(file);
     85 		var pos = file.leafName.lastIndexOf('.');
     86 		return pos==-1 ? '' : file.leafName.substr(pos+1);
     87 	}
     88 	
     89 	
     90 	/**
     91 	 * Traverses up the filesystem from a file until it finds an existing
     92 	 *  directory, or false if it hits the root
     93 	 */
     94 	this.getClosestDirectory = async function (file) {
     95 		try {
     96 			let stat = await OS.File.stat(file);
     97 			// If file is an existing directory, return it
     98 			if (stat.isDir) {
     99 				return file;
    100 			}
    101 		}
    102 		catch (e) {
    103 			if (e.becauseNoSuchFile) {}
    104 			else {
    105 				throw e;
    106 			}
    107 		}
    108 		
    109 		var dir = OS.Path.dirname(file);
    110 		while (dir && !await OS.File.exists(dir)) {
    111 			dir = OS.Path.dirname(dir);
    112 		}
    113 		
    114 		return dir || false;
    115 	}
    116 	
    117 	
    118 	/**
    119 	 * Get the first 200 bytes of a source as a string (multibyte-safe)
    120 	 *
    121 	 * @param {nsIURI|nsIFile|string spec|nsIChannel|nsIInputStream} source - The source to read
    122 	 * @return {Promise}
    123 	 */
    124 	this.getSample = function (file) {
    125 		var bytes = 200;
    126 		return this.getContentsAsync(file, null, bytes);
    127 	}
    128 	
    129 	
    130 	/**
    131 	 * Get contents of a binary file
    132 	 */
    133 	this.getBinaryContents = function(file) {
    134 		var iStream = Components.classes["@mozilla.org/network/file-input-stream;1"]
    135 					 .createInstance(Components.interfaces.nsIFileInputStream);
    136 		iStream.init(file, 0x01, 0o664, 0);
    137 		var bStream = Components.classes["@mozilla.org/binaryinputstream;1"]
    138 					 .createInstance(Components.interfaces.nsIBinaryInputStream);
    139 		bStream.setInputStream(iStream);
    140 		var string = bStream.readBytes(file.fileSize);
    141 		iStream.close();
    142 		return string;
    143 	}
    144 	
    145 	
    146 	/**
    147 	 * Get the contents of a file or input stream
    148 	 * @param {nsIFile|nsIInputStream|string path} file The file to read
    149 	 * @param {String} [charset] The character set; defaults to UTF-8
    150 	 * @param {Integer} [maxLength] The maximum number of bytes to read
    151 	 * @return {String} The contents of the file
    152 	 * @deprecated Use {@link Zotero.File.getContentsAsync} when possible
    153 	 */
    154 	this.getContents = function (file, charset, maxLength){
    155 		var fis;
    156 		
    157 		if (typeof file == 'string') {
    158 			file = new FileUtils.File(file);
    159 		}
    160 		
    161 		if(file instanceof Components.interfaces.nsIInputStream) {
    162 			fis = file;
    163 		} else if(file instanceof Components.interfaces.nsIFile) {
    164 			fis = Components.classes["@mozilla.org/network/file-input-stream;1"].
    165 				createInstance(Components.interfaces.nsIFileInputStream);
    166 			fis.init(file, 0x01, 0o664, 0);
    167 		} else {
    168 			throw new Error("File is not an nsIInputStream or nsIFile");
    169 		}
    170 		
    171 		if (charset) {
    172 			charset = Zotero.CharacterSets.toLabel(charset, true)
    173 		}
    174 		charset = charset || "UTF-8";
    175 		
    176 		var blockSize = maxLength ? Math.min(maxLength, 524288) : 524288;
    177 		
    178 		const replacementChar
    179 			= Components.interfaces.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER;
    180 		var is = Components.classes["@mozilla.org/intl/converter-input-stream;1"]
    181 			.createInstance(Components.interfaces.nsIConverterInputStream);
    182 		is.init(fis, charset, blockSize, replacementChar);
    183 		var chars = 0;
    184 		
    185 		var contents = "", str = {};
    186 		while (is.readString(blockSize, str) !== 0) {
    187 			if (maxLength) {
    188 				var strLen = str.value.length;
    189 				if ((chars + strLen) > maxLength) {
    190 					var remainder = maxLength - chars;
    191 					contents += str.value.slice(0, remainder);
    192 					break;
    193 				}
    194 				chars += strLen;
    195 			}
    196 			
    197 			contents += str.value;
    198 		}
    199 		
    200 		is.close();
    201 		
    202 		return contents;
    203 	};
    204 	
    205 	
    206 	/**
    207 	 * Get the contents of a text source asynchronously
    208 	 *
    209 	 * @param {string path|nsIFile|file URI|nsIChannel|nsIInputStream} source The source to read
    210 	 * @param {String} [charset] The character set; defaults to UTF-8
    211 	 * @param {Integer} [maxLength] Maximum length to fetch, in bytes
    212 	 * @return {Promise} A promise that is resolved with the contents of the file
    213 	 */
    214 	this.getContentsAsync = Zotero.Promise.coroutine(function* (source, charset, maxLength) {
    215 		Zotero.debug("Getting contents of "
    216 			+ (source instanceof Components.interfaces.nsIFile
    217 				? source.path
    218 				: (source instanceof Components.interfaces.nsIInputStream ? "input stream" : source)));
    219 		
    220 		// Send URIs to Zotero.HTTP.request()
    221 		if (source instanceof Components.interfaces.nsIURI
    222 				|| typeof source == 'string' && !source.startsWith('file:') && source.match(/^[a-z]{3,}:/)) {
    223 			Zotero.logError("Passing a URI to Zotero.File.getContentsAsync() is deprecated "
    224 				+ "-- use Zotero.HTTP.request() instead");
    225 			return Zotero.HTTP.request("GET", source);
    226 		}
    227 		
    228 		// Use NetUtil.asyncFetch() for input streams and channels
    229 		if (source instanceof Components.interfaces.nsIInputStream
    230 				|| source instanceof Components.interfaces.nsIChannel) {
    231 			var deferred = Zotero.Promise.defer();
    232 			try {
    233 				NetUtil.asyncFetch(source, function(inputStream, status) {
    234 					if (!Components.isSuccessCode(status)) {
    235 						deferred.reject(new Components.Exception("File read operation failed", status));
    236 						return;
    237 					}
    238 					
    239 					try {
    240 						try {
    241 							var bytesToFetch = inputStream.available();
    242 						}
    243 						catch (e) {
    244 							// The stream is closed automatically when end-of-file is reached,
    245 							// so this throws for empty files
    246 							if (e.name == "NS_BASE_STREAM_CLOSED") {
    247 								Zotero.debug("RESOLVING2");
    248 								deferred.resolve("");
    249 							}
    250 							deferred.reject(e);
    251 						}
    252 						
    253 						if (maxLength && maxLength < bytesToFetch) {
    254 							bytesToFetch = maxLength;
    255 						}
    256 						
    257 						if (bytesToFetch == 0) {
    258 							deferred.resolve("");
    259 							return;
    260 						}
    261 						
    262 						deferred.resolve(NetUtil.readInputStreamToString(
    263 							inputStream,
    264 							bytesToFetch,
    265 							options
    266 						));
    267 					}
    268 					catch (e) {
    269 						deferred.reject(e);
    270 					}
    271 				});
    272 			}
    273 			catch(e) {
    274 				// Make sure this get logged correctly
    275 				Zotero.logError(e);
    276 				throw e;
    277 			}
    278 			return deferred.promise;
    279 		}
    280 		
    281 		// Use OS.File for files
    282 		if (source instanceof Components.interfaces.nsIFile) {
    283 			source = source.path;
    284 		}
    285 		else if (typeof source == 'string') {
    286 			if (source.startsWith('file:')) {
    287 				source = OS.Path.fromFileURI(source);
    288 			}
    289 		}
    290 		else {
    291 			throw new Error(`Unsupported type '${typeof source}' for source`);
    292 		}
    293 		var options = {
    294 			encoding: charset ? charset : "utf-8"
    295 		};
    296 		if (maxLength) {
    297 			options.bytes = maxLength;
    298 		}
    299 		return OS.File.read(source, options);
    300 	});
    301 	
    302 	
    303 	/**
    304 	 * Get the contents of a binary source asynchronously
    305 	 *
    306 	 * This is quite slow and should only be used in tests.
    307 	 *
    308 	 * @param {string path|nsIFile|file URI} source The source to read
    309 	 * @param {Integer} [maxLength] Maximum length to fetch, in bytes
    310 	 * @return {Promise<String>} A promise for the contents of the source as a binary string
    311 	 */
    312 	this.getBinaryContentsAsync = Zotero.Promise.coroutine(function* (source, maxLength) {
    313 		// Use OS.File for files
    314 		if (source instanceof Components.interfaces.nsIFile) {
    315 			source = source.path;
    316 		}
    317 		else if (source.startsWith('^file:')) {
    318 			source = OS.Path.fromFileURI(source);
    319 		}
    320 		var options = {};
    321 		if (maxLength) {
    322 			options.bytes = maxLength;
    323 		}
    324 		var buf = yield OS.File.read(source, options);
    325 		return [...buf].map(x => String.fromCharCode(x)).join("");
    326 	});
    327 	
    328 	
    329 	/*
    330 	 * Return the contents of a URL as a string
    331 	 *
    332 	 * Runs synchronously, so should only be run on local (e.g. chrome) URLs
    333 	 */
    334 	function getContentsFromURL(url) {
    335 		var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"]
    336 						.createInstance();
    337 		xmlhttp.open('GET', url, false);
    338 		xmlhttp.overrideMimeType("text/plain");
    339 		xmlhttp.send(null);
    340 		return xmlhttp.responseText;
    341 	}
    342 	
    343 	
    344 	/*
    345 	 * Return a promise for the contents of a URL as a string
    346 	 */
    347 	this.getContentsFromURLAsync = function (url, options={}) {
    348 		return Zotero.HTTP.request("GET", url, Object.assign(options, { responseType: "text" }))
    349 		.then(function (xmlhttp) {
    350 			return xmlhttp.response;
    351 		});
    352 	}
    353 	
    354 	
    355 	/*
    356 	 * Write string to a file, overwriting existing file if necessary
    357 	 */
    358 	function putContents(file, str) {
    359 		if (file.exists()) {
    360 			file.remove(null);
    361 		}
    362 		var fos = Components.classes["@mozilla.org/network/file-output-stream;1"].
    363 				createInstance(Components.interfaces.nsIFileOutputStream);
    364 		fos.init(file, 0x02 | 0x08 | 0x20, 0o664, 0);  // write, create, truncate
    365 		
    366 		var os = Components.classes["@mozilla.org/intl/converter-output-stream;1"]
    367 						   .createInstance(Components.interfaces.nsIConverterOutputStream);
    368 		os.init(fos, "UTF-8", 4096, "?".charCodeAt(0));
    369 		os.writeString(str);
    370 		os.close();
    371 		
    372 		fos.close();
    373 	}
    374 	
    375 	/**
    376 	 * Write data to a file asynchronously
    377 	 *
    378 	 * @param {String|nsIFile} - String path or nsIFile to write to
    379 	 * @param {String|nsIInputStream} data - The string or nsIInputStream to write to the file
    380 	 * @param {String} [charset] - The character set; defaults to UTF-8
    381 	 * @return {Promise} - A promise that is resolved when the file has been written
    382 	 */
    383 	this.putContentsAsync = function (path, data, charset) {
    384 		if (path instanceof Ci.nsIFile) {
    385 			path = path.path;
    386 		}
    387 		
    388 		if (typeof data == 'string') {
    389 			return Zotero.Promise.resolve(OS.File.writeAtomic(
    390 				path,
    391 				data,
    392 				{
    393 					tmpPath: path + ".tmp",
    394 					encoding: charset ? charset.toLowerCase() : 'utf-8'
    395 				}
    396 			));
    397 		}
    398 		
    399 		var deferred = Zotero.Promise.defer();
    400 		var os = FileUtils.openSafeFileOutputStream(new FileUtils.File(path));
    401 		NetUtil.asyncCopy(data, os, function(inputStream, status) {
    402 			if (!Components.isSuccessCode(status)) {
    403 				deferred.reject(new Components.Exception("File write operation failed", status));
    404 				return;
    405 			}
    406 			deferred.resolve();
    407 		});
    408 		return deferred.promise;
    409 	};
    410 	
    411 	
    412 	this.download = Zotero.Promise.coroutine(function* (uri, path) {
    413 		Zotero.debug("Saving " + (uri.spec ? uri.spec : uri)
    414 			+ " to " + (path.path ? path.path : path));			
    415 		
    416 		var deferred = Zotero.Promise.defer();
    417 		NetUtil.asyncFetch(uri, function (is, status, request) {
    418 			if (!Components.isSuccessCode(status)) {
    419 				Zotero.logError(status);
    420 				deferred.reject(new Error("Download failed with status " + status));
    421 				return;
    422 			}
    423 			deferred.resolve(is);
    424 		});
    425 		var is = yield deferred.promise;
    426 		yield Zotero.File.putContentsAsync(path, is);
    427 	});
    428 	
    429 	
    430 	/**
    431 	 * Rename file within its parent directory
    432 	 *
    433 	 * @param {String} file - File path
    434 	 * @param {String} newName
    435 	 * @param {Object} [options]
    436 	 * @param {Boolean} [options.overwrite=false] - Overwrite file if one exists
    437 	 * @param {Boolean} [options.unique=false] - Add suffix to create unique filename if necessary
    438 	 * @return {String|false} - New filename, or false if destination file exists and `overwrite` not set
    439 	 */
    440 	this.rename = async function (file, newName, options = {}) {
    441 		var overwrite = options.overwrite || false;
    442 		var unique = options.unique || false;
    443 		
    444 		var origPath = file;
    445 		var origName = OS.Path.basename(origPath);
    446 		newName = Zotero.File.getValidFileName(newName);
    447 		
    448 		// Ignore if no change
    449 		if (origName === newName) {
    450 			Zotero.debug("Filename has not changed");
    451 			return origName;
    452 		}
    453 		
    454 		var parentDir = OS.Path.dirname(origPath);
    455 		var destPath = OS.Path.join(parentDir, newName);
    456 		var destName = OS.Path.basename(destPath);
    457 		// Get root + extension, if there is one
    458 		var pos = destName.lastIndexOf('.');
    459 		if (pos > 0) {
    460 			var root = destName.substr(0, pos);
    461 			var ext = destName.substr(pos + 1);
    462 		}
    463 		else {
    464 			var root = destName;
    465 		}
    466 		
    467 		var incr = 0;
    468 		while (true) {
    469 			// If filename already exists, add a numeric suffix to the end of the root, before
    470 			// the extension if there is one
    471 			if (incr) {
    472 				if (ext) {
    473 					destName = root + ' ' + (incr + 1) + '.' + ext;
    474 				}
    475 				else {
    476 					destName = root + ' ' + (incr + 1);
    477 				}
    478 				destPath = OS.Path.join(parentDir, destName);
    479 			}
    480 			
    481 			try {
    482 				Zotero.debug(`Renaming ${origPath} to ${OS.Path.basename(destPath)}`);
    483 				Zotero.debug(destPath);
    484 				await OS.File.move(origPath, destPath, { noOverwrite: !overwrite })
    485 			}
    486 			catch (e) {
    487 				if (e instanceof OS.File.Error) {
    488 					if (e.becauseExists) {
    489 						// Increment number to create unique suffix
    490 						if (unique) {
    491 							incr++;
    492 							continue;
    493 						}
    494 						// No overwriting or making unique and file exists
    495 						return false;
    496 					}
    497 				}
    498 				throw e;
    499 			}
    500 			break;
    501 		}
    502 		return destName;
    503 	};
    504 	
    505 	
    506 	/**
    507 	 * Delete a file if it exists, asynchronously
    508 	 *
    509 	 * @return {Promise<Boolean>} A promise for TRUE if file was deleted, FALSE if missing
    510 	 */
    511 	this.removeIfExists = function (path) {
    512 		return Zotero.Promise.resolve(OS.File.remove(path))
    513 		.return(true)
    514 		.catch(function (e) {
    515 			if (e instanceof OS.File.Error && e.becauseNoSuchFile) {
    516 				return false;
    517 			}
    518 			Zotero.debug(path, 1);
    519 			throw e;
    520 		});
    521 	}
    522 	
    523 	
    524 	/**
    525 	 * @return {Promise<Boolean>}
    526 	 */
    527 	this.directoryIsEmpty = Zotero.Promise.coroutine(function* (path) {
    528 		var it = new OS.File.DirectoryIterator(path);
    529 		try {
    530 			let entry = yield it.next();
    531 			return false;
    532 		}
    533 		catch (e) {
    534 			if (e != StopIteration) {
    535 				throw e;
    536 			}
    537 		}
    538 		finally {
    539 			it.close();
    540 		}
    541 		return true;
    542 	});
    543 	
    544 	
    545 	/**
    546 	 * Run a generator with an OS.File.DirectoryIterator, closing the
    547 	 * iterator when done
    548 	 *
    549 	 * The DirectoryIterator is passed as the first parameter to the generator.
    550 	 *
    551 	 * Zotero.File.iterateDirectory(path, function* (iterator) {
    552 	 *    while (true) {
    553 	 *        var entry = yield iterator.next();
    554 	 *        [...]
    555 	 *    }
    556 	 * })
    557 	 *
    558 	 * @return {Promise}
    559 	 */
    560 	this.iterateDirectory = function (path, generator) {
    561 		var iterator = new OS.File.DirectoryIterator(path);
    562 		return Zotero.Promise.coroutine(generator)(iterator)
    563 		.catch(function (e) {
    564 			if (e != StopIteration) {
    565 				throw e;
    566 			}
    567 		})
    568 		.finally(function () {
    569 			iterator.close();
    570 		});
    571 	}
    572 	
    573 	
    574 	/**
    575 	 * If directories can be moved at once, instead of recursively creating directories and moving files
    576 	 *
    577 	 * Currently this means using /bin/mv, which only works on macOS and Linux
    578 	 */
    579 	this.canMoveDirectoryWithCommand = Zotero.lazy(function () {
    580 		var cmd = "/bin/mv";
    581 		return !Zotero.isWin && this.pathToFile(cmd).exists();
    582 	});
    583 	
    584 	/**
    585 	 * For tests
    586 	 */
    587 	this.canMoveDirectoryWithFunction = Zotero.lazy(function () {
    588 		return true;
    589 	});
    590 	
    591 	/**
    592 	 * Move directory (using mv on macOS/Linux, recursively on Windows)
    593 	 *
    594 	 * @param {Boolean} [options.allowExistingTarget=false] - If true, merge files into an existing
    595 	 *     target directory if one exists rather than throwing an error
    596 	 * @param {Function} options.noOverwrite - Function that returns true if the file at the given
    597 	 *     path should throw an error rather than overwrite an existing file in the target
    598 	 */
    599 	this.moveDirectory = Zotero.Promise.coroutine(function* (oldDir, newDir, options = {}) {
    600 		var maxDepth = options.maxDepth || 10;
    601 		var cmd = "/bin/mv";
    602 		var useCmd = this.canMoveDirectoryWithCommand();
    603 		var useFunction = this.canMoveDirectoryWithFunction();
    604 		
    605 		if (!options.allowExistingTarget && (yield OS.File.exists(newDir))) {
    606 			throw new Error(newDir + " exists");
    607 		}
    608 		
    609 		var errors = [];
    610 		
    611 		// Throw certain known errors (no more disk space) to interrupt the operation
    612 		function checkError(e) {
    613 			if (!(e instanceof OS.File.Error)) {
    614 				return;
    615 			}
    616 			Components.classes["@mozilla.org/net/osfileconstantsservice;1"]
    617 				.getService(Components.interfaces.nsIOSFileConstantsService)
    618 				.init();
    619 			if ((e.unixErrno !== undefined && e.unixErrno == OS.Constants.libc.ENOSPC)
    620 					|| (e.winLastError !== undefined && e.winLastError == OS.Constants.libc.ENOSPC)) {
    621 				throw e;
    622 			}
    623 		}
    624 		
    625 		function addError(e) {
    626 			errors.push(e);
    627 			Zotero.logError(e);
    628 		}
    629 		
    630 		var rootDir = oldDir;
    631 		var moveSubdirs = Zotero.Promise.coroutine(function* (oldDir, depth) {
    632 			if (!depth) return;
    633 			
    634 			// Create target directory
    635 			try {
    636 				yield Zotero.File.createDirectoryIfMissingAsync(newDir + oldDir.substr(rootDir.length));
    637 			}
    638 			catch (e) {
    639 				addError(e);
    640 				return;
    641 			}
    642 			
    643 			Zotero.debug("Moving files in " + oldDir);
    644 			
    645 			yield Zotero.File.iterateDirectory(oldDir, function* (iterator) {
    646 				while (true) {
    647 					let entry = yield iterator.next();
    648 					let dest = newDir + entry.path.substr(rootDir.length);
    649 					
    650 					// entry.isDir can be false for some reason on Travis, causing spurious test failures
    651 					if (Zotero.automatedTest && !entry.isDir && (yield OS.File.stat(entry.path)).isDir) {
    652 						Zotero.debug("Overriding isDir for " + entry.path);
    653 						entry.isDir = true;
    654 					}
    655 					
    656 					// Move files in directory
    657 					if (!entry.isDir) {
    658 						try {
    659 							yield OS.File.move(
    660 								entry.path,
    661 								dest,
    662 								{
    663 									noOverwrite: options
    664 										&& options.noOverwrite
    665 										&& options.noOverwrite(entry.path)
    666 								}
    667 							);
    668 						}
    669 						catch (e) {
    670 							checkError(e);
    671 							Zotero.debug("Error moving " + entry.path);
    672 							addError(e);
    673 						}
    674 					}
    675 					else {
    676 						// Move directory with external command if possible and the directory doesn't
    677 						// already exist in target
    678 						let moved = false;
    679 						
    680 						if (useCmd && !(yield OS.File.exists(dest))) {
    681 							Zotero.debug(`Moving ${entry.path} with ${cmd}`);
    682 							let args = [entry.path, dest];
    683 							try {
    684 								yield Zotero.Utilities.Internal.exec(cmd, args);
    685 								moved = true;
    686 							}
    687 							catch (e) {
    688 								checkError(e);
    689 								Zotero.debug(e, 1);
    690 							}
    691 						}
    692 						
    693 						
    694 						// If can't use command, try moving with OS.File.move(). Technically this is
    695 						// unsupported for directories, but it works on all platforms as long as noCopy
    696 						// is set (and on some platforms regardless)
    697 						if (!moved && useFunction) {
    698 							Zotero.debug(`Moving ${entry.path} with OS.File`);
    699 							try {
    700 								yield OS.File.move(
    701 									entry.path,
    702 									dest,
    703 									{
    704 										noCopy: true
    705 									}
    706 								);
    707 								moved = true;
    708 							}
    709 							catch (e) {
    710 								checkError(e);
    711 								Zotero.debug(e, 1);
    712 							}
    713 						}
    714 						
    715 						// Otherwise, recurse into subdirectories to copy files individually
    716 						if (!moved) {
    717 							try {
    718 								yield moveSubdirs(entry.path, depth - 1);
    719 							}
    720 							catch (e) {
    721 								checkError(e);
    722 								addError(e);
    723 							}
    724 						}
    725 					}
    726 				}
    727 			});
    728 			
    729 			// Remove directory after moving everything within
    730 			//
    731 			// Don't try to remove root directory if there've been errors, since it won't work.
    732 			// (Deeper directories might fail too, but we don't worry about those.)
    733 			if (!errors.length || oldDir != rootDir) {
    734 				Zotero.debug("Removing " + oldDir);
    735 				try {
    736 					yield OS.File.removeEmptyDir(oldDir);
    737 				}
    738 				catch (e) {
    739 					addError(e);
    740 				}
    741 			}
    742 		});
    743 		
    744 		yield moveSubdirs(oldDir, maxDepth);
    745 		return errors;
    746 	});
    747 	
    748 	
    749 	/**
    750 	 * Generate a data: URI from an nsIFile
    751 	 *
    752 	 * From https://developer.mozilla.org/en-US/docs/data_URIs
    753 	 */
    754 	this.generateDataURI = function (file) {
    755 		var contentType = Components.classes["@mozilla.org/mime;1"]
    756 			.getService(Components.interfaces.nsIMIMEService)
    757 			.getTypeFromFile(file);
    758 		var inputStream = Components.classes["@mozilla.org/network/file-input-stream;1"]
    759 			.createInstance(Components.interfaces.nsIFileInputStream);
    760 		inputStream.init(file, 0x01, 0o600, 0);
    761 		var stream = Components.classes["@mozilla.org/binaryinputstream;1"]
    762 			.createInstance(Components.interfaces.nsIBinaryInputStream);
    763 		stream.setInputStream(inputStream);
    764 		var encoded = btoa(stream.readBytes(stream.available()));
    765 		return "data:" + contentType + ";base64," + encoded;
    766 	}
    767 	
    768 	
    769 	this.setNormalFilePermissions = function (file) {
    770 		return OS.File.setPermissions(
    771 			file,
    772 			{
    773 				unixMode: 0o644,
    774 				winAttributes: {
    775 					readOnly: false,
    776 					hidden: false,
    777 					system: false
    778 				}
    779 			}
    780 		);
    781 	}
    782 	
    783 	
    784 	this.createShortened = function (file, type, mode, maxBytes) {
    785 		file = this.pathToFile(file);
    786 		
    787 		if (!maxBytes) {
    788 			maxBytes = 255;
    789 		}
    790 		
    791 		// Limit should be 255, but leave room for unique numbering if necessary
    792 		var padding = 3;
    793 		
    794 		while (true) {
    795 			var newLength = maxBytes - padding;
    796 			
    797 			try {
    798 				file.create(type, mode);
    799 			}
    800 			catch (e) {
    801 				let pathError = false;
    802 				
    803 				let pathByteLength = Zotero.Utilities.Internal.byteLength(file.path);
    804 				let fileNameByteLength = Zotero.Utilities.Internal.byteLength(file.leafName);
    805 				
    806 				// Windows API only allows paths of 260 characters
    807 				//
    808 				// I think this should be >260 but we had a report of an error with exactly
    809 				// 260 chars: https://forums.zotero.org/discussion/41410
    810 				if (e.name == "NS_ERROR_FILE_NOT_FOUND" && pathByteLength >= 260) {
    811 					Zotero.debug("Path is " + file.path);
    812 					pathError = true;
    813 				}
    814 				// ext3/ext4/HFS+ have a filename length limit of ~254 bytes
    815 				else if ((e.name == "NS_ERROR_FAILURE" || e.name == "NS_ERROR_FILE_NAME_TOO_LONG")
    816 						&& (fileNameByteLength >= 254 || (Zotero.isLinux && fileNameByteLength > 143))) {
    817 					Zotero.debug("Filename is '" + file.leafName + "'");
    818 				}
    819 				else {
    820 					Zotero.debug("Path is " + file.path);
    821 					throw e;
    822 				}
    823 				
    824 				// Preserve extension
    825 				var matches = file.leafName.match(/.+(\.[a-z0-9]{0,20})$/i);
    826 				var ext = matches ? matches[1] : "";
    827 				
    828 				if (pathError) {
    829 					let pathLength = pathByteLength - fileNameByteLength;
    830 					newLength -= pathLength;
    831 					
    832 					// Make sure there's a least 1 character of the basename left over
    833 					if (newLength - ext.length < 1) {
    834 						throw new Error("Path is too long");
    835 					}
    836 				}
    837 				
    838 				// Shorten the filename
    839 				//
    840 				// Shortened file could already exist if there was another file with a
    841 				// similar name that was also longer than the limit, so we do this in a
    842 				// loop, adding numbers if necessary
    843 				var uniqueFile = file.clone();
    844 				var step = 0;
    845 				while (step < 100) {
    846 					let newBaseName = uniqueFile.leafName.substr(0, newLength - ext.length);
    847 					if (step == 0) {
    848 						var newName = newBaseName + ext;
    849 					}
    850 					else {
    851 						var newName = newBaseName + "-" + step + ext;
    852 					}
    853 					
    854 					// Check actual byte length, and shorten more if necessary
    855 					if (Zotero.Utilities.Internal.byteLength(newName) > maxBytes) {
    856 						step = 0;
    857 						newLength--;
    858 						continue;
    859 					}
    860 					
    861 					uniqueFile.leafName = newName;
    862 					if (!uniqueFile.exists()) {
    863 						break;
    864 					}
    865 					
    866 					step++;
    867 				}
    868 				
    869 				var msg = "Shortening filename to '" + newName + "'";
    870 				Zotero.debug(msg, 2);
    871 				Zotero.log(msg, 'warning');
    872 				
    873 				try {
    874 					uniqueFile.create(Components.interfaces.nsIFile.type, mode);
    875 				}
    876 				catch (e) {
    877 					// On Linux, try 143, which is the max filename length with eCryptfs
    878 					if (e.name == "NS_ERROR_FILE_NAME_TOO_LONG" && Zotero.isLinux && uniqueFile.leafName.length > 143) {
    879 						Zotero.debug("Trying shorter filename in case of filesystem encryption", 2);
    880 						maxBytes = 143;
    881 						continue;
    882 					}
    883 					else {
    884 						throw e;
    885 					}
    886 				}
    887 				
    888 				file.leafName = uniqueFile.leafName;
    889 			}
    890 			break;
    891 		}
    892 		
    893 		return file.leafName;
    894 	}
    895 	
    896 	
    897 	this.copyToUnique = function (file, newFile) {
    898 		file = this.pathToFile(file);
    899 		newFile = this.pathToFile(newFile);
    900 		
    901 		newFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0o644);
    902 		var newName = newFile.leafName;
    903 		newFile.remove(null);
    904 		
    905 		// Copy file to unique name
    906 		file.copyToFollowingLinks(newFile.parent, newName);
    907 		return newFile;
    908 	}
    909 	
    910 	
    911 	/**
    912 	 * Copies all files from dir into newDir
    913 	 *
    914 	 * @param {String|nsIFile} source - Source directory
    915 	 * @param {String|nsIFile} target - Target directory
    916 	 */
    917 	this.copyDirectory = Zotero.Promise.coroutine(function* (source, target) {
    918 		if (source instanceof Ci.nsIFile) source = source.path;
    919 		if (target instanceof Ci.nsIFile) target = target.path;
    920 		
    921 		yield OS.File.makeDir(target, {
    922 			ignoreExisting: true,
    923 			unixMode: 0o755
    924 		});
    925 		
    926 		return this.iterateDirectory(source, function* (iterator) {
    927 			while (true) {
    928 				let entry = yield iterator.next();
    929 				yield OS.File.copy(entry.path, OS.Path.join(target, entry.name));
    930 			}
    931 		})
    932 	});
    933 	
    934 	
    935 	this.createDirectoryIfMissing = function (dir) {
    936 		if (!dir.exists() || !dir.isDirectory()) {
    937 			if (dir.exists() && !dir.isDirectory()) {
    938 				dir.remove(null);
    939 			}
    940 			dir.create(Components.interfaces.nsIFile.DIRECTORY_TYPE, 0o755);
    941 		}
    942 	}
    943 	
    944 	
    945 	this.createDirectoryIfMissingAsync = function (path) {
    946 		return Zotero.Promise.resolve(
    947 			OS.File.makeDir(
    948 				path,
    949 				{
    950 					ignoreExisting: true,
    951 					unixMode: 0o755
    952 				}
    953 			)
    954 		);
    955 	}
    956 	
    957 	
    958 	/**
    959 	 * Check whether a directory is an ancestor directory of another directory/file
    960 	 */
    961 	this.directoryContains = function (dir, file) {
    962 		if (typeof dir != 'string') throw new Error("dir must be a string");
    963 		if (typeof file != 'string') throw new Error("file must be a string");
    964 		
    965 		dir = OS.Path.normalize(dir);
    966 		file = OS.Path.normalize(file);
    967 		
    968 		return file.startsWith(dir);
    969 	};
    970 	
    971 	
    972 	/**
    973 	 * @param {String} dirPath - Directory containing files to add to ZIP
    974 	 * @param {String} zipPath - ZIP file to create
    975 	 * @param {nsIRequestObserver} [observer]
    976 	 * @return {Promise}
    977 	 */
    978 	this.zipDirectory = Zotero.Promise.coroutine(function* (dirPath, zipPath, observer) {
    979 		var zw = Components.classes["@mozilla.org/zipwriter;1"]
    980 			.createInstance(Components.interfaces.nsIZipWriter);
    981 		zw.open(this.pathToFile(zipPath), 0x04 | 0x08 | 0x20); // open rw, create, truncate
    982 		var entries = yield _addZipEntries(dirPath, dirPath, zw);
    983 		if (entries.length == 0) {
    984 			Zotero.debug('No files to add -- removing ZIP file');
    985 			zw.close();
    986 			yield OS.File.remove(zipPath);
    987 			return false;
    988 		}
    989 		
    990 		Zotero.debug(`Creating ${OS.Path.basename(zipPath)} with ${entries.length} file(s)`);
    991 		
    992 		var context = {
    993 			zipWriter: zw,
    994 			entries
    995 		};
    996 		
    997 		var deferred = Zotero.Promise.defer();
    998 		zw.processQueue(
    999 			{
   1000 				onStartRequest: function (request, ctx) {
   1001 					try {
   1002 						if (observer && observer.onStartRequest) {
   1003 							observer.onStartRequest(request, context);
   1004 						}
   1005 					}
   1006 					catch (e) {
   1007 						deferred.reject(e);
   1008 					}
   1009 				},
   1010 				onStopRequest: function (request, ctx, status) {
   1011 					try {
   1012 						if (observer && observer.onStopRequest) {
   1013 							observer.onStopRequest(request, context, status);
   1014 						}
   1015 					}
   1016 					catch (e) {
   1017 						deferred.reject(e);
   1018 						return;
   1019 					}
   1020 					finally {
   1021 						zw.close();
   1022 					}
   1023 					deferred.resolve(true);
   1024 				}
   1025 			},
   1026 			{}
   1027 		);
   1028 		return deferred.promise;
   1029 	});
   1030 	
   1031 	
   1032 	var _addZipEntries = Zotero.Promise.coroutine(function* (rootPath, path, zipWriter) {
   1033 		var entries = [];
   1034 		let iterator;
   1035 		try {
   1036 			iterator = new OS.File.DirectoryIterator(path);
   1037 			yield iterator.forEach(Zotero.Promise.coroutine(function* (entry) {
   1038 				// entry.isDir can be false for some reason on Travis, causing spurious test failures
   1039 				if (Zotero.automatedTest && !entry.isDir && (yield OS.File.stat(entry.path)).isDir) {
   1040 					Zotero.debug("Overriding isDir for " + entry.path);
   1041 					entry.isDir = true;
   1042 				}
   1043 				
   1044 				if (entry.isSymLink) {
   1045 					Zotero.debug("Skipping symlink " + entry.name);
   1046 					return;
   1047 				}
   1048 				if (entry.isDir) {
   1049 					entries.concat(yield _addZipEntries(rootPath, entry.path, zipWriter));
   1050 					return;
   1051 				}
   1052 				if (entry.name.startsWith('.')) {
   1053 					Zotero.debug('Skipping file ' + entry.name);
   1054 					return;
   1055 				}
   1056 				
   1057 				Zotero.debug("Adding ZIP entry " + entry.path);
   1058 				zipWriter.addEntryFile(
   1059 					// Add relative path
   1060 					entry.path.substr(rootPath.length + 1),
   1061 					Components.interfaces.nsIZipWriter.COMPRESSION_DEFAULT,
   1062 					Zotero.File.pathToFile(entry.path),
   1063 					true
   1064 				);
   1065 				entries.push({
   1066 					name: entry.name,
   1067 					path: entry.path
   1068 				});
   1069 			}));
   1070 		}
   1071 		finally {
   1072 			iterator.close();
   1073 		}
   1074 		return entries;
   1075 	});
   1076 	
   1077 	
   1078 	/**
   1079 	 * Strip potentially invalid characters
   1080 	 *
   1081 	 * See http://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words
   1082 	 *
   1083 	 * @param	{String}	fileName
   1084 	 * @param	{Boolean}	[skipXML=false]		Don't strip characters invalid in XML
   1085 	 */
   1086 	function getValidFileName(fileName, skipXML) {
   1087 		// TODO: use space instead, and figure out what's doing extra
   1088 		// URL encode when saving attachments that trigger this
   1089 		fileName = fileName.replace(/[\/\\\?\*:|"<>]/g, '');
   1090 		// Replace newlines and tabs (which shouldn't be in the string in the first place) with spaces
   1091 		fileName = fileName.replace(/[\r\n\t]+/g, ' ');
   1092 		// Replace various thin spaces
   1093 		fileName = fileName.replace(/[\u2000-\u200A]/g, ' ');
   1094 		// Replace zero-width spaces
   1095 		fileName = fileName.replace(/[\u200B-\u200E]/g, '');
   1096 		if (!skipXML) {
   1097 			// Strip characters not valid in XML, since they won't sync and they're probably unwanted
   1098 			fileName = fileName.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ud800-\udfff\ufffe\uffff]/g, '');
   1099 			
   1100 			// Normalize to NFC
   1101 			fileName = fileName.normalize();
   1102 		}
   1103 		// Don't allow hidden files
   1104 		fileName = fileName.replace(/^\./, '');
   1105 		// Don't allow blank or illegal filenames
   1106 		if (!fileName || fileName == '.' || fileName == '..') {
   1107 			fileName = '_';
   1108 		}
   1109 		return fileName;
   1110 	}
   1111 	
   1112 	/**
   1113 	 * Truncate a filename (excluding the extension) to the given total length
   1114 	 * If the "extension" is longer than 20 characters,
   1115 	 * it is treated as part of the file name
   1116 	 */
   1117 	function truncateFileName(fileName, maxLength) {
   1118 		if(!fileName || (fileName + '').length <= maxLength) return fileName;
   1119 
   1120 		var parts = (fileName + '').split(/\.(?=[^\.]+$)/);
   1121 		var fn = parts[0];
   1122 		var ext = parts[1];
   1123 		//if the file starts with a period , use the whole file
   1124 		//the whole file name might also just be a period
   1125 		if(!fn) {
   1126 			fn = '.' + (ext || '');
   1127 		}
   1128 
   1129 		//treat long extensions as part of the file name
   1130 		if(ext && ext.length > 20) {
   1131 			fn += '.' + ext;
   1132 			ext = undefined;
   1133 		}
   1134 
   1135 		if(ext === undefined) {	//there was no period in the whole file name
   1136 			ext = '';
   1137 		} else {
   1138 			ext = '.' + ext;
   1139 		}
   1140 
   1141 		return fn.substr(0,maxLength-ext.length) + ext;
   1142 	}
   1143 	
   1144 	/*
   1145 	 * Not implemented, but it'd sure be great if it were
   1146 	 */
   1147 	function getCharsetFromByteArray(arr) {
   1148 		
   1149 	}
   1150 	
   1151 	
   1152 	/*
   1153 	 * An extraordinarily inelegant way of getting the character set of a
   1154 	 * text file using a hidden browser
   1155 	 *
   1156 	 * I'm quite sure there's a better way
   1157 	 *
   1158 	 * Note: This is for text files -- don't run on other files
   1159 	 *
   1160 	 * 'callback' is the function to pass the charset (and, if provided, 'args')
   1161 	 * to after detection is complete
   1162 	 */
   1163 	function getCharsetFromFile(file, mimeType, callback, args){
   1164 		if (!file || !file.exists()){
   1165 			callback(false, args);
   1166 			return;
   1167 		}
   1168 		
   1169 		if (mimeType.substr(0, 5) != 'text/' ||
   1170 				!Zotero.MIME.hasInternalHandler(mimeType, this.getExtension(file))) {
   1171 			callback(false, args);
   1172 			return;
   1173 		}
   1174 		
   1175 		var browser = Zotero.Browser.createHiddenBrowser();
   1176 		
   1177 		var url = Components.classes["@mozilla.org/network/protocol;1?name=file"]
   1178 				.getService(Components.interfaces.nsIFileProtocolHandler)
   1179 				.getURLSpecFromFile(file);
   1180 		
   1181 		this.addCharsetListener(browser, function (charset, args) {
   1182 			callback(charset, args);
   1183 			Zotero.Browser.deleteHiddenBrowser(browser);
   1184 		}, args);
   1185 		
   1186 		browser.loadURI(url);
   1187 	}
   1188 	
   1189 	
   1190 	/*
   1191 	 * Attach a load listener to a browser object to perform charset detection
   1192 	 *
   1193 	 * We make sure the universal character set detector is set to the
   1194 	 * universal_charset_detector (temporarily changing it if not--shhhh)
   1195 	 *
   1196 	 * 'callback' is the function to pass the charset (and, if provided, 'args')
   1197 	 * to after detection is complete
   1198 	 */
   1199 	function addCharsetListener(browser, callback, args){
   1200 		var prefService = Components.classes["@mozilla.org/preferences-service;1"]
   1201 							.getService(Components.interfaces.nsIPrefBranch);
   1202 		var oldPref = prefService.getCharPref('intl.charset.detector');
   1203 		var newPref = 'universal_charset_detector';
   1204 		//Zotero.debug("Default character detector is " + (oldPref ? oldPref : '(none)'));
   1205 		
   1206 		if (oldPref != newPref){
   1207 			//Zotero.debug('Setting character detector to universal_charset_detector');
   1208 			prefService.setCharPref('intl.charset.detector', 'universal_charset_detector');
   1209 		}
   1210 		
   1211 		var onpageshow = function(){
   1212 			// ignore spurious about:blank loads
   1213 			if(browser.contentDocument.location.href == "about:blank") return;
   1214 
   1215 			browser.removeEventListener("pageshow", onpageshow, false);
   1216 			
   1217 			var charset = browser.contentDocument.characterSet;
   1218 			Zotero.debug("Detected character set '" + charset + "'");
   1219 			
   1220 			//Zotero.debug('Resetting character detector to ' + (oldPref ? oldPref : '(none)'));
   1221 			prefService.setCharPref('intl.charset.detector', oldPref);
   1222 			
   1223 			callback(charset, args);
   1224 		};
   1225 		
   1226 		browser.addEventListener("pageshow", onpageshow, false);
   1227 	}
   1228 	
   1229 	
   1230 	this.checkFileAccessError = function (e, file, operation) {
   1231 		file = this.pathToFile(file);
   1232 		
   1233 		var str = 'file.accessError.';
   1234 		if (file) {
   1235 			str += 'theFile'
   1236 		}
   1237 		else {
   1238 			str += 'aFile'
   1239 		}
   1240 		str += 'CannotBe';
   1241 		
   1242 		switch (operation) {
   1243 			case 'create':
   1244 				str += 'Created';
   1245 				break;
   1246 				
   1247 			case 'delete':
   1248 				str += 'Deleted';
   1249 				break;
   1250 				
   1251 			default:
   1252 				str += 'Updated';
   1253 		}
   1254 		str = Zotero.getString(str, file.path ? file.path : undefined);
   1255 		
   1256 		Zotero.debug(file.path);
   1257 		Zotero.debug(e, 1);
   1258 		Components.utils.reportError(e);
   1259 		
   1260 		if (e.name == 'NS_ERROR_FILE_ACCESS_DENIED' || e.name == 'NS_ERROR_FILE_IS_LOCKED'
   1261 				// These show up on some Windows systems
   1262 				|| e.name == 'NS_ERROR_FAILURE' || e.name == 'NS_ERROR_FILE_NOT_FOUND'
   1263 				// OS.File.Error
   1264 				|| e.becauseAccessDenied || e.becauseNoSuchFile) {
   1265 			let checkFileWindows = Zotero.getString('file.accessError.message.windows');
   1266 			let checkFileOther = Zotero.getString('file.accessError.message.other');
   1267 			let msg = str + "\n\n"
   1268 					+ (Zotero.isWin ? checkFileWindows : checkFileOther)
   1269 					+ "\n\n"
   1270 					+ Zotero.getString('file.accessError.restart');
   1271 			
   1272 			e = new Zotero.Error(
   1273 				msg,
   1274 				0,
   1275 				{
   1276 					dialogButtonText: Zotero.getString('file.accessError.showParentDir'),
   1277 					dialogButtonCallback: function () {
   1278 						try {
   1279 							file.parent.QueryInterface(Components.interfaces.nsILocalFile);
   1280 							file.parent.reveal();
   1281 						}
   1282 						// Unsupported on some platforms
   1283 						catch (e) {
   1284 							Zotero.launchFile(file.parent);
   1285 						}
   1286 					}
   1287 				}
   1288 			);
   1289 		}
   1290 		
   1291 		throw e;
   1292 	}
   1293 	
   1294 	
   1295 	this.isDropboxDirectory = function(path) {
   1296 		return path.toLowerCase().indexOf('dropbox') != -1;
   1297 	}
   1298 	
   1299 	
   1300 	this.reveal = Zotero.Promise.coroutine(function* (file) {
   1301 		if (!(yield OS.File.exists(file))) {
   1302 			throw new Error(file + " does not exist");
   1303 		}
   1304 		
   1305 		Zotero.debug("Revealing " + file);
   1306 		
   1307 		var nsIFile = this.pathToFile(file);
   1308 		nsIFile.QueryInterface(Components.interfaces.nsILocalFile);
   1309 		try {
   1310 			nsIFile.reveal();
   1311 		}
   1312 		catch (e) {
   1313 			Zotero.logError(e);
   1314 			// On platforms that don't support nsILocalFile.reveal() (e.g. Linux),
   1315 			// launch the directory
   1316 			let zp = Zotero.getActiveZoteroPane();
   1317 			if (zp) {
   1318 				try {
   1319 					let info = yield OS.File.stat(file);
   1320 					// Launch parent directory for files
   1321 					if (!info.isDir) {
   1322 						file = OS.Path.dirname(file);
   1323 					}
   1324 					Zotero.launchFile(file);
   1325 				}
   1326 				catch (e) {
   1327 					Zotero.logError(e);
   1328 					return;
   1329 				}
   1330 			}
   1331 			else {
   1332 				Zotero.logError(e);
   1333 			}
   1334 		}
   1335 	});
   1336 }