www

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

utilities_internal.js (51929B)


      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 	
     24 	Utilities based in part on code taken from Piggy Bank 2.1.1 (BSD-licensed)
     25 	
     26     ***** END LICENSE BLOCK *****
     27 */
     28 
     29 /**
     30  * @class Utility functions not made available to translators
     31  */
     32 Zotero.Utilities.Internal = {
     33 	SNAPSHOT_SAVE_TIMEOUT: 30000,
     34 	
     35 	/**
     36 	 * Run a function on chunks of a given size of an array's elements.
     37 	 *
     38 	 * @param {Array} arr
     39 	 * @param {Integer} chunkSize
     40 	 * @param {Function} func - A promise-returning function
     41 	 * @return {Array} The return values from the successive runs
     42 	 */
     43 	"forEachChunkAsync": Zotero.Promise.coroutine(function* (arr, chunkSize, func) {
     44 		var retValues = [];
     45 		var tmpArray = arr.concat();
     46 		var num = arr.length;
     47 		var done = 0;
     48 		
     49 		do {
     50 			var chunk = tmpArray.splice(0, chunkSize);
     51 			done += chunk.length;
     52 			retValues.push(yield func(chunk));
     53 		}
     54 		while (done < num);
     55 		
     56 		return retValues;
     57 	}),
     58 	
     59 	
     60 	/**
     61 	 * Copy a text string to the clipboard
     62 	 */
     63 	"copyTextToClipboard":function(str) {
     64 		Components.classes["@mozilla.org/widget/clipboardhelper;1"]
     65 			.getService(Components.interfaces.nsIClipboardHelper)
     66 			.copyString(str);
     67 	},
     68 	
     69 	
     70 	 /*
     71 	 * Adapted from http://developer.mozilla.org/en/docs/nsICryptoHash
     72 	 *
     73 	 * @param	{String|nsIFile}	strOrFile
     74 	 * @param	{Boolean}			[base64=false]	Return as base-64-encoded string rather than hex string
     75 	 * @return	{String}
     76 	 */
     77 	"md5":function(strOrFile, base64) {
     78 		if (typeof strOrFile == 'string') {
     79 			var converter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"].
     80 				createInstance(Components.interfaces.nsIScriptableUnicodeConverter);
     81 			converter.charset = "UTF-8";
     82 			var result = {};
     83 			var data = converter.convertToByteArray(strOrFile, result);
     84 			var ch = Components.classes["@mozilla.org/security/hash;1"]
     85 				.createInstance(Components.interfaces.nsICryptoHash);
     86 			ch.init(ch.MD5);
     87 			ch.update(data, data.length);
     88 		}
     89 		else if (strOrFile instanceof Components.interfaces.nsIFile) {
     90 			if (!strOrFile.exists()) {
     91 				return false;
     92 			}
     93 			
     94 			// Otherwise throws (NS_ERROR_NOT_AVAILABLE) [nsICryptoHash.updateFromStream]
     95 			if (!strOrFile.fileSize) {
     96 				// MD5 for empty string
     97 				return "d41d8cd98f00b204e9800998ecf8427e";
     98 			}
     99 			
    100 			var istream = Components.classes["@mozilla.org/network/file-input-stream;1"]
    101 							.createInstance(Components.interfaces.nsIFileInputStream);
    102 			// open for reading
    103 			istream.init(strOrFile, 0x01, 0o444, 0);
    104 			var ch = Components.classes["@mozilla.org/security/hash;1"]
    105 						   .createInstance(Components.interfaces.nsICryptoHash);
    106 			// we want to use the MD5 algorithm
    107 			ch.init(ch.MD5);
    108 			// this tells updateFromStream to read the entire file
    109 			const PR_UINT32_MAX = 0xffffffff;
    110 			ch.updateFromStream(istream, PR_UINT32_MAX);
    111 		}
    112 		
    113 		// pass false here to get binary data back
    114 		var hash = ch.finish(base64);
    115 		
    116 		if (istream) {
    117 			istream.close();
    118 		}
    119 		
    120 		if (base64) {
    121 			return hash;
    122 		}
    123 		
    124 		// return the two-digit hexadecimal code for a byte
    125 		function toHexString(charCode) {
    126 			return ("0" + charCode.toString(16)).slice(-2);
    127 		}
    128 		
    129 		// convert the binary hash data to a hex string.
    130 		var hexStr = "";
    131 		for (let i = 0; i < hash.length; i++) {
    132 			hexStr += toHexString(hash.charCodeAt(i));
    133 		}
    134 		return hexStr;
    135 	},
    136 	
    137 	
    138 	/**
    139 	 * @param {OS.File|nsIFile|String} file  File or file path
    140 	 * @param {Boolean} [base64=FALSE]  Return as base-64-encoded string
    141 	 *                                  rather than hex string
    142 	 */
    143 	md5Async: async function (file, base64) {
    144 		const CHUNK_SIZE = 16384;
    145 		
    146 		function toHexString(charCode) {
    147 			return ("0" + charCode.toString(16)).slice(-2);
    148 		}
    149 		
    150 		var ch = Components.classes["@mozilla.org/security/hash;1"]
    151 				   .createInstance(Components.interfaces.nsICryptoHash);
    152 		ch.init(ch.MD5);
    153 		
    154 		// Recursively read chunks of the file and return a promise for the hash
    155 		let readChunk = async function (file) {
    156 			try {
    157 				let data = await file.read(CHUNK_SIZE);
    158 				ch.update(data, data.length);
    159 				if (data.length == CHUNK_SIZE) {
    160 					return readChunk(file);
    161 				}
    162 				
    163 				let hash = ch.finish(base64);
    164 				// Base64
    165 				if (base64) {
    166 					return hash;
    167 				}
    168 				// Hex string
    169 				let hexStr = "";
    170 				for (let i = 0; i < hash.length; i++) {
    171 					hexStr += toHexString(hash.charCodeAt(i));
    172 				}
    173 				return hexStr;
    174 			}
    175 			catch (e) {
    176 				try {
    177 					ch.finish(false);
    178 				}
    179 				catch (e) {
    180 					Zotero.logError(e);
    181 				}
    182 				throw e;
    183 			}
    184 		};
    185 		
    186 		if (file instanceof OS.File) {
    187 			return readChunk(file);
    188 		}
    189 		
    190 		var path = (file instanceof Components.interfaces.nsIFile) ? file.path : file;
    191 		var hash;
    192 		try {
    193 			var osFile = await OS.File.open(path);
    194 			hash = await readChunk(osFile);
    195 		}
    196 		finally {
    197 			if (osFile) {
    198 				await osFile.close();
    199 			}
    200 		}
    201 		return hash;
    202 	},
    203 	
    204 	
    205 	gzip: Zotero.Promise.coroutine(function* (data) {
    206 		var deferred = Zotero.Promise.defer();
    207 		
    208 		// Get input stream from POST data
    209 		var unicodeConverter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"]
    210 			.createInstance(Components.interfaces.nsIScriptableUnicodeConverter);
    211 		unicodeConverter.charset = "UTF-8";
    212 		var is = unicodeConverter.convertToInputStream(data);
    213 		
    214 		// Initialize stream converter
    215 		var converter = Components.classes["@mozilla.org/streamconv;1?from=uncompressed&to=gzip"]
    216 			.createInstance(Components.interfaces.nsIStreamConverter);
    217 		converter.asyncConvertData(
    218 			"uncompressed",
    219 			"gzip",
    220 			{
    221 				binaryInputStream: null,
    222 				size: 0,
    223 				data: '',
    224 				
    225 				onStartRequest: function (request, context) {},
    226 				
    227 				onStopRequest: function (request, context, status) {
    228 					this.binaryInputStream.close();
    229 					delete this.binaryInputStream;
    230 					
    231 					deferred.resolve(this.data);
    232 				},
    233 				
    234 				onDataAvailable: function (request, context, inputStream, offset, count) {
    235 					this.size += count;
    236 					
    237 					this.binaryInputStream = Components.classes["@mozilla.org/binaryinputstream;1"]
    238 						.createInstance(Components.interfaces.nsIBinaryInputStream)
    239 					this.binaryInputStream.setInputStream(inputStream);
    240 					this.data += this.binaryInputStream.readBytes(this.binaryInputStream.available());
    241 				},
    242 				
    243 				QueryInterface: function (iid) {
    244 					if (iid.equals(Components.interfaces.nsISupports)
    245 						   || iid.equals(Components.interfaces.nsIStreamListener)) {
    246 						return this;
    247 					}
    248 					throw Components.results.NS_ERROR_NO_INTERFACE;
    249 				}
    250 			},
    251 			null
    252 		);
    253 		
    254 		// Send input stream to stream converter
    255 		var pump = Components.classes["@mozilla.org/network/input-stream-pump;1"]
    256 			.createInstance(Components.interfaces.nsIInputStreamPump);
    257 		pump.init(is, -1, -1, 0, 0, true);
    258 		pump.asyncRead(converter, null);
    259 		
    260 		return deferred.promise;
    261 	}),
    262 	
    263 	
    264 	gunzip: Zotero.Promise.coroutine(function* (data) {
    265 		var deferred = Zotero.Promise.defer();
    266 		
    267 		Components.utils.import("resource://gre/modules/NetUtil.jsm");
    268 		
    269 		var is = Components.classes["@mozilla.org/io/string-input-stream;1"]
    270 			.createInstance(Ci.nsIStringInputStream);
    271 		is.setData(data, data.length);
    272 		
    273 		var bis = Components.classes["@mozilla.org/binaryinputstream;1"]
    274 			.createInstance(Components.interfaces.nsIBinaryInputStream);
    275 		bis.setInputStream(is);
    276 		
    277 		// Initialize stream converter
    278 		var converter = Components.classes["@mozilla.org/streamconv;1?from=gzip&to=uncompressed"]
    279 			.createInstance(Components.interfaces.nsIStreamConverter);
    280 		converter.asyncConvertData(
    281 			"gzip",
    282 			"uncompressed",
    283 			{
    284 				data: '',
    285 				
    286 				onStartRequest: function (request, context) {},
    287 				
    288 				onStopRequest: function (request, context, status) {
    289 					deferred.resolve(this.data);
    290 				},
    291 				
    292 				onDataAvailable: function (request, context, inputStream, offset, count) {
    293 					this.data += NetUtil.readInputStreamToString(
    294 						inputStream,
    295 						inputStream.available(),
    296 						{
    297 							charset: 'UTF-8',
    298 							replacement: 65533
    299 						}
    300 					)
    301 				},
    302 				
    303 				QueryInterface: function (iid) {
    304 					if (iid.equals(Components.interfaces.nsISupports)
    305 						   || iid.equals(Components.interfaces.nsIStreamListener)) {
    306 						return this;
    307 					}
    308 					throw Components.results.NS_ERROR_NO_INTERFACE;
    309 				}
    310 			},
    311 			null
    312 		);
    313 		
    314 		// Send input stream to stream converter
    315 		var pump = Components.classes["@mozilla.org/network/input-stream-pump;1"]
    316 			.createInstance(Components.interfaces.nsIInputStreamPump);
    317 		pump.init(bis, -1, -1, 0, 0, true);
    318 		pump.asyncRead(converter, null);
    319 		
    320 		return deferred.promise;
    321 	}),
    322 	
    323 	
    324 	/**
    325 	 * Unicode normalization
    326 	 */
    327 	"normalize":function(str) {
    328 		var normalizer = Components.classes["@mozilla.org/intl/unicodenormalizer;1"]
    329 							.getService(Components.interfaces.nsIUnicodeNormalizer);
    330 		var obj = {};
    331 		str = normalizer.NormalizeUnicodeNFC(str, obj);
    332 		return obj.value;
    333 	},
    334 	
    335 	
    336 	/**
    337 	 * Return the byte length of a UTF-8 string
    338 	 *
    339 	 * http://stackoverflow.com/a/23329386
    340 	 */
    341 	byteLength: function (str) {
    342 		var s = str.length;
    343 		for (var i=str.length-1; i>=0; i--) {
    344 			var code = str.charCodeAt(i);
    345 			if (code > 0x7f && code <= 0x7ff) s++;
    346 			else if (code > 0x7ff && code <= 0xffff) s+=2;
    347 			if (code >= 0xDC00 && code <= 0xDFFF) i--; //trail surrogate
    348 		}
    349 		return s;
    350 	},
    351 	
    352 	/**
    353 	 * Display a prompt from an error with custom buttons and a callback
    354 	 */
    355 	"errorPrompt":function(title, e) {
    356 		var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"]
    357 					.getService(Components.interfaces.nsIPromptService);
    358 		var message, buttonText, buttonCallback;
    359 		
    360 		if (e.dialogButtonText !== undefined) {
    361 			buttonText = e.dialogButtonText;
    362 			buttonCallback = e.dialogButtonCallback;
    363 		}
    364 		if (e.message) {
    365 			message = e.message;
    366 		}
    367 		else {
    368 			message = e;
    369 		}
    370 		
    371 		if (typeof buttonText == 'undefined') {
    372 			buttonText = Zotero.getString('errorReport.reportError');
    373 			buttonCallback = function () {
    374 				win.ZoteroPane.reportErrors();
    375 			}
    376 		}
    377 		// If secondary button is explicitly null, just use an alert
    378 		else if (buttonText === null) {
    379 			ps.alert(null, title, message);
    380 			return;
    381 		}
    382 		
    383 		var buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_OK
    384 							+ ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING;
    385 		var index = ps.confirmEx(
    386 			null,
    387 			title,
    388 			message,
    389 			buttonFlags,
    390 			"",
    391 			buttonText,
    392 			"", null, {}
    393 		);
    394 		
    395 		if (index == 1) {
    396 			setTimeout(function () { buttonCallback(); }, 1);
    397 		}
    398 	},
    399 	
    400 	
    401 	/**
    402 	 * saveURI wrapper function
    403 	 * @param {nsIWebBrowserPersist} nsIWebBrowserPersist
    404 	 * @param {nsIURI} uri URL
    405 	 * @param {nsIFile|string path} target file
    406 	 * @param {Object} [headers]
    407 	 */
    408 	saveURI: function (wbp, uri, target, headers) {
    409 		// Handle gzip encoding
    410 		wbp.persistFlags |= wbp.PERSIST_FLAGS_AUTODETECT_APPLY_CONVERSION;
    411 		// If not explicitly using cache, skip it
    412 		if (!(wbp.persistFlags & wbp.PERSIST_FLAGS_FROM_CACHE)) {
    413 			wbp.persistFlags |= wbp.PERSIST_FLAGS_BYPASS_CACHE;
    414 		}
    415 		
    416 		if (typeof uri == 'string') {
    417 			uri = Services.io.newURI(uri, null, null);
    418 		}
    419 		
    420 		target = Zotero.File.pathToFile(target);
    421 		
    422 		if (headers) {
    423 			headers = Object.keys(headers).map(x => x + ": " + headers[x]).join("\r\n") + "\r\n";
    424 		}
    425 		
    426 		wbp.saveURI(uri, null, null, null, null, headers, target, null);
    427 	},
    428 	
    429 	
    430 	saveDocument: function (document, destFile) {
    431 		const nsIWBP = Components.interfaces.nsIWebBrowserPersist;
    432 		let wbp = Components.classes["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"]
    433 			.createInstance(nsIWBP);
    434 		wbp.persistFlags = nsIWBP.PERSIST_FLAGS_REPLACE_EXISTING_FILES
    435 			| nsIWBP.PERSIST_FLAGS_FORCE_ALLOW_COOKIES
    436 			| nsIWBP.PERSIST_FLAGS_AUTODETECT_APPLY_CONVERSION
    437 			| nsIWBP.PERSIST_FLAGS_FROM_CACHE
    438 			| nsIWBP.PERSIST_FLAGS_CLEANUP_ON_FAILURE
    439 			// Mostly ads
    440 			| nsIWBP.PERSIST_FLAGS_IGNORE_IFRAMES
    441 			| nsIWBP.PERSIST_FLAGS_IGNORE_REDIRECTED_DATA;
    442 		
    443 		let encodingFlags = 0;
    444 		let filesFolder = null;
    445 		if (document.contentType == "text/plain") {
    446 			encodingFlags |= nsIWBP.ENCODE_FLAGS_FORMATTED;
    447 			encodingFlags |= nsIWBP.ENCODE_FLAGS_ABSOLUTE_LINKS;
    448 			encodingFlags |= nsIWBP.ENCODE_FLAGS_NOFRAMES_CONTENT;
    449 		}
    450 		else {
    451 			encodingFlags |= nsIWBP.ENCODE_FLAGS_ENCODE_BASIC_ENTITIES;
    452 			
    453 			// Save auxiliary files to the same folder
    454 			filesFolder = OS.Path.dirname(destFile);
    455 		}
    456 		const wrapColumn = 80;
    457 		
    458 		var deferred = Zotero.Promise.defer();
    459 		var listener = new Zotero.WebProgressFinishListener(function () {
    460 			deferred.resolve();
    461 		});
    462 		wbp.progressListener = listener;
    463 		
    464 		wbp.saveDocument(
    465 			document,
    466 			Zotero.File.pathToFile(destFile),
    467 			Zotero.File.pathToFile(filesFolder),
    468 			null,
    469 			encodingFlags,
    470 			wrapColumn
    471 		);
    472 		
    473 		// Cancel save after timeout has passed, so we return an error to the connector and don't stay
    474 		// saving forever
    475 		var timeoutID = setTimeout(function () {
    476 			if (deferred.promise.isPending()) {
    477 				Zotero.debug("Stopping save for " + document.location.href, 2);
    478 				//Zotero.debug(listener.getRequest());
    479 				deferred.reject("Snapshot save timeout on " + document.location.href);
    480 				wbp.cancelSave();
    481 			}
    482 		}, this.SNAPSHOT_SAVE_TIMEOUT);
    483 		deferred.promise.then(() => clearTimeout(timeoutID));
    484 		
    485 		return deferred.promise;
    486 	},
    487 	
    488 	
    489 	/**
    490 	 * Launch a process
    491 	 * @param {nsIFile|String} cmd Path to command to launch
    492 	 * @param {String[]} args Arguments given
    493 	 * @return {Promise} Promise resolved to true if command succeeds, or an error otherwise
    494 	 */
    495 	"exec": Zotero.Promise.method(function (cmd, args) {
    496 		if (typeof cmd == 'string') {
    497 			Components.utils.import("resource://gre/modules/FileUtils.jsm");
    498 			cmd = new FileUtils.File(cmd);
    499 		}
    500 		
    501 		if(!cmd.isExecutable()) {
    502 			throw new Error(cmd.path + " is not an executable");
    503 		}
    504 		
    505 		var proc = Components.classes["@mozilla.org/process/util;1"].
    506 				createInstance(Components.interfaces.nsIProcess);
    507 		proc.init(cmd);
    508 		
    509 		Zotero.debug("Running " + cmd.path + " " + args.map(arg => "'" + arg + "'").join(" "));
    510 		
    511 		var deferred = Zotero.Promise.defer();
    512 		proc.runwAsync(args, args.length, {"observe":function(subject, topic) {
    513 			if(topic !== "process-finished") {
    514 				deferred.reject(new Error(cmd.path+" failed"));
    515 			} else if(proc.exitValue != 0) {
    516 				deferred.reject(new Error(cmd.path+" returned exit status "+proc.exitValue));
    517 			} else {
    518 				deferred.resolve(true);
    519 			}
    520 		}});
    521 		
    522 		return deferred.promise;
    523 	}),
    524 
    525 	/**
    526 	 * Get string data from the clipboard
    527 	 * @param {String[]} mimeType MIME type of data to get
    528 	 * @return {String|null} Clipboard data, or null if none was available
    529 	 */
    530 	"getClipboard":function(mimeType) {
    531 		var clip = Services.clipboard;
    532 		if (!clip.hasDataMatchingFlavors([mimeType], 1, clip.kGlobalClipboard)) {
    533 			return null;
    534 		}
    535 		var trans = Components.classes["@mozilla.org/widget/transferable;1"]
    536 						.createInstance(Components.interfaces.nsITransferable);
    537 		trans.addDataFlavor(mimeType);
    538 		clip.getData(trans, clip.kGlobalClipboard);
    539 		var str = {};
    540 		try {
    541 			trans.getTransferData(mimeType, str, {});
    542 			str = str.value.QueryInterface(Components.interfaces.nsISupportsString).data;
    543 		}
    544 		catch (e) {
    545 			return null;
    546 		}
    547 		return str;
    548 	},
    549 	
    550 	/**
    551 	 * Determine if one Window is a descendant of another Window
    552 	 * @param {DOMWindow} suspected child window
    553 	 * @param {DOMWindow} suspected parent window
    554 	 * @return {boolean}
    555 	 */
    556 	"isIframeOf":function isIframeOf(childWindow, parentWindow) {
    557 		while(childWindow.parent !== childWindow) {
    558 			childWindow = childWindow.parent;
    559 			if(childWindow === parentWindow) return true;
    560 		}
    561 	},
    562 	
    563 	
    564 	/**
    565 	 * Returns a DOMDocument object not attached to any window
    566 	 */
    567 	"getDOMDocument": function() {
    568 		return Components.classes["@mozilla.org/xmlextras/domparser;1"]
    569 			.createInstance(Components.interfaces.nsIDOMParser)
    570 			.parseFromString("<!DOCTYPE html><html></html>", "text/html");
    571 	},
    572 	
    573 	
    574 	/**
    575 	 * Update HTML links within XUL
    576 	 *
    577 	 * @param {HTMLElement} elem - HTML element to modify
    578 	 * @param {Object} [options] - Properties:
    579 	 *                                 .linkEvent - An object to pass to ZoteroPane.loadURI() to
    580 	 *                                 simulate modifier keys for link clicks. For example, to
    581 	 *                                 force links to open in new windows, pass with
    582 	 *                                 .shiftKey = true. If not provided, the actual event will
    583 	 *                                 be used instead.
    584 	 */
    585 	updateHTMLInXUL: function (elem, options) {
    586 		options = options || {};
    587 		var links = elem.getElementsByTagName('a');
    588 		for (let i = 0; i < links.length; i++) {
    589 			let a = links[i];
    590 			let href = a.getAttribute('href');
    591 			a.setAttribute('tooltiptext', href);
    592 			a.onclick = function (event) {
    593 				try {
    594 					let wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
    595 					   .getService(Components.interfaces.nsIWindowMediator);
    596 					let win = wm.getMostRecentWindow("navigator:browser");
    597 					win.ZoteroPane_Local.loadURI(href, options.linkEvent || event)
    598 				}
    599 				catch (e) {
    600 					Zotero.logError(e);
    601 				}
    602 				return false;
    603 			};
    604 		}
    605 	},
    606 	
    607 	
    608 	/**
    609 	 * A generator that yields promises that delay for the given intervals
    610 	 *
    611 	 * @param {Array<Integer>} intervals - An array of intervals in milliseconds
    612 	 * @param {Integer} [maxTime] - Total time to wait in milliseconds, after which the delaying
    613 	 *                              promise will return false. Before maxTime has elapsed, or if
    614 	 *                              maxTime isn't specified, the promises will yield true.
    615 	 */
    616 	"delayGenerator": function* (intervals, maxTime) {
    617 		var delay;
    618 		var totalTime = 0;
    619 		var last = false;
    620 		while (true) {
    621 			let interval = intervals.shift();
    622 			if (interval) {
    623 				delay = interval;
    624 			}
    625 			
    626 			if (maxTime && (totalTime + delay) > maxTime) {
    627 				yield Zotero.Promise.resolve(false);
    628 			}
    629 			
    630 			totalTime += delay;
    631 			
    632 			Zotero.debug("Delaying " + delay + " ms");
    633 			yield Zotero.Promise.delay(delay).return(true);
    634 		}
    635 	},
    636 	
    637 	
    638 	/**
    639 	 * Return an input stream that will be filled asynchronously with strings yielded from a
    640 	 * generator. If the generator yields a promise, the promise is waited for, but its value
    641 	 * is not added to the input stream.
    642 	 *
    643 	 * @param {GeneratorFunction|Generator} gen - Promise-returning generator function or
    644 	 *                                            generator
    645 	 * @return {nsIAsyncInputStream}
    646 	 */
    647 	getAsyncInputStream: function (gen, onError) {
    648 		// Initialize generator if necessary
    649 		var g = gen.next ? gen : gen();
    650 		var seq = 0;
    651 		
    652 		const PR_UINT32_MAX = Math.pow(2, 32) - 1;
    653 		var pipe = Cc["@mozilla.org/pipe;1"].createInstance(Ci.nsIPipe);
    654 		pipe.init(true, true, 0, PR_UINT32_MAX, null);
    655 		
    656 		var os = Components.classes["@mozilla.org/intl/converter-output-stream;1"]
    657 			.createInstance(Components.interfaces.nsIConverterOutputStream);
    658 		os.init(pipe.outputStream, 'utf-8', 0, 0x0000);
    659 		
    660 		
    661 		function onOutputStreamReady(aos) {
    662 			let currentSeq = seq++;
    663 			
    664 			var maybePromise = processNextValue();
    665 			// If generator returns a promise, wait for it
    666 			if (maybePromise.then) {
    667 				maybePromise.then(() => onOutputStreamReady(aos));
    668 			}
    669 			// If more data, tell stream we're ready
    670 			else if (maybePromise) {
    671 				aos.asyncWait({ onOutputStreamReady }, 0, 0, Zotero.mainThread);
    672 			}
    673 			// Otherwise close the stream
    674 			else {
    675 				aos.close();
    676 			}
    677 		};
    678 		
    679 		function processNextValue(lastVal) {
    680 			try {
    681 				var result = g.next(lastVal);
    682 				if (result.done) {
    683 					Zotero.debug("No more data to write");
    684 					return false;
    685 				}
    686 				if (result.value.then) {
    687 					return result.value.then(val => processNextValue(val));
    688 				}
    689 				if (typeof result.value != 'string') {
    690 					throw new Error("Data is not a string or promise (" + result.value + ")");
    691 				}
    692 				os.writeString(result.value);
    693 				return true;
    694 			}
    695 			catch (e) {
    696 				Zotero.logError(e);
    697 				if (onError) {
    698 					try {
    699 						os.writeString(onError(e));
    700 					}
    701 					catch (e) {
    702 						Zotero.logError(e);
    703 					}
    704 				}
    705 				os.close();
    706 				return false;
    707 			}
    708 		}
    709 		
    710 		pipe.outputStream.asyncWait({ onOutputStreamReady }, 0, 0, Zotero.mainThread);
    711 		return pipe.inputStream;
    712 	},
    713 	
    714 	
    715 	/**
    716 	 * Converts Zotero.Item to a format expected by translators
    717 	 * This is mostly the Zotero web API item JSON format, but with an attachments
    718 	 * and notes arrays and optional compatibility mappings for older translators.
    719 	 * 
    720 	 * @param {Zotero.Item} zoteroItem
    721 	 * @param {Boolean} legacy Add mappings for legacy (pre-4.0.27) translators
    722 	 * @return {Object}
    723 	 */
    724 	itemToExportFormat: function (zoteroItem, legacy, skipChildItems) {
    725 		function addCompatibilityMappings(item, zoteroItem) {
    726 			item.uniqueFields = {};
    727 			
    728 			// Meaningless local item ID, but some older export translators depend on it
    729 			item.itemID = zoteroItem.id;
    730 			item.key = zoteroItem.key; // CSV translator exports this
    731 			
    732 			// "version" is expected to be a field for "computerProgram", which is now
    733 			// called "versionNumber"
    734 			delete item.version;
    735 			if (item.versionNumber) {
    736 				item.version = item.uniqueFields.version = item.versionNumber;
    737 				delete item.versionNumber;
    738 			}
    739 			
    740 			// SQL instead of ISO-8601
    741 			item.dateAdded = zoteroItem.dateAdded;
    742 			item.dateModified = zoteroItem.dateModified;
    743 			if (item.accessDate) {
    744 				item.accessDate = zoteroItem.getField('accessDate');
    745 			}
    746 			
    747 			// Map base fields
    748 			for (let field in item) {
    749 				let id = Zotero.ItemFields.getID(field);
    750 				if (!id || !Zotero.ItemFields.isValidForType(id, zoteroItem.itemTypeID)) {
    751 					 continue;
    752 				}
    753 				
    754 				let baseField = Zotero.ItemFields.getName(
    755 					Zotero.ItemFields.getBaseIDFromTypeAndField(item.itemType, field)
    756 				);
    757 				
    758 				if (!baseField || baseField == field) {
    759 					item.uniqueFields[field] = item[field];
    760 				} else {
    761 					item[baseField] = item[field];
    762 					item.uniqueFields[baseField] = item[field];
    763 				}
    764 			}
    765 			
    766 			// Add various fields for compatibility with translators pre-4.0.27
    767 			item.itemID = zoteroItem.id;
    768 			item.libraryID = zoteroItem.libraryID == 1 ? null : zoteroItem.libraryID;
    769 			
    770 			// Creators
    771 			if (item.creators) {
    772 				for (let i=0; i<item.creators.length; i++) {
    773 					let creator = item.creators[i];
    774 					
    775 					if (creator.name) {
    776 						creator.fieldMode = 1;
    777 						creator.lastName = creator.name;
    778 						delete creator.name;
    779 					}
    780 					
    781 					// Old format used to supply creatorID (the database ID), but no
    782 					// translator ever used it
    783 				}
    784 			}
    785 			
    786 			if (!zoteroItem.isRegularItem()) {
    787 				item.sourceItemKey = item.parentItem;
    788 			}
    789 			
    790 			// Tags
    791 			for (let i=0; i<item.tags.length; i++) {
    792 				if (!item.tags[i].type) {
    793 					item.tags[i].type = 0;
    794 				}
    795 				// No translator ever used "primary", "fields", or "linkedItems" objects
    796 			}
    797 			
    798 			// "related" was never used (array of itemIDs)
    799 			
    800 			// seeAlso was always present, but it was always an empty array.
    801 			// Zotero RDF translator pretended to use it
    802 			item.seeAlso = [];
    803 			
    804 			if (zoteroItem.isAttachment()) {
    805 				item.linkMode = item.uniqueFields.linkMode = zoteroItem.attachmentLinkMode;
    806 				item.mimeType = item.uniqueFields.mimeType = item.contentType;
    807 			}
    808 			
    809 			if (item.note) {
    810 				item.uniqueFields.note = item.note;
    811 			}
    812 			
    813 			return item;
    814 		}
    815 		
    816 		var item = zoteroItem.toJSON();
    817 		
    818 		item.uri = Zotero.URI.getItemURI(zoteroItem);
    819 		delete item.key;
    820 		
    821 		if (!skipChildItems && !zoteroItem.isAttachment() && !zoteroItem.isNote()) {
    822 			// Include attachments
    823 			item.attachments = [];
    824 			let attachments = zoteroItem.getAttachments();
    825 			for (let i=0; i<attachments.length; i++) {
    826 				let zoteroAttachment = Zotero.Items.get(attachments[i]),
    827 					attachment = zoteroAttachment.toJSON();
    828 				if (legacy) addCompatibilityMappings(attachment, zoteroAttachment);
    829 				
    830 				item.attachments.push(attachment);
    831 			}
    832 			
    833 			// Include notes
    834 			item.notes = [];
    835 			let notes = zoteroItem.getNotes();
    836 			for (let i=0; i<notes.length; i++) {
    837 				let zoteroNote = Zotero.Items.get(notes[i]),
    838 					note = zoteroNote.toJSON();
    839 				if (legacy) addCompatibilityMappings(note, zoteroNote);
    840 				
    841 				item.notes.push(note);
    842 			}
    843 		}
    844 		
    845 		if (legacy) addCompatibilityMappings(item, zoteroItem);
    846 		
    847 		return item;
    848 	},
    849 	
    850 	
    851 	extractIdentifiers: function (text) {
    852 		var identifiers = [];
    853 		var foundIDs = new Set(); // keep track of identifiers to avoid duplicates
    854 		
    855 		// First look for DOIs
    856 		var ids = text.split(/[\s\u00A0]+/); // whitespace + non-breaking space
    857 		var doi;
    858 		for (let id of ids) {
    859 			if ((doi = Zotero.Utilities.cleanDOI(id)) && !foundIDs.has(doi)) {
    860 				identifiers.push({
    861 					DOI: doi
    862 				});
    863 				foundIDs.add(doi);
    864 			}
    865 		}
    866 		
    867 		// Then try ISBNs
    868 		if (!identifiers.length) {
    869 			// First try replacing dashes
    870 			let ids = text.replace(/[\u002D\u00AD\u2010-\u2015\u2212]+/g, "") // hyphens and dashes
    871 				.toUpperCase();
    872 			let ISBN_RE = /(?:\D|^)(97[89]\d{10}|\d{9}[\dX])(?!\d)/g;
    873 			let isbn;
    874 			while (isbn = ISBN_RE.exec(ids)) {
    875 				isbn = Zotero.Utilities.cleanISBN(isbn[1]);
    876 				if (isbn && !foundIDs.has(isbn)) {
    877 					identifiers.push({
    878 						ISBN: isbn
    879 					});
    880 					foundIDs.add(isbn);
    881 				}
    882 			}
    883 			
    884 			// Next try spaces
    885 			if (!identifiers.length) {
    886 				ids = ids.replace(/[ \u00A0]+/g, ""); // space + non-breaking space
    887 				while (isbn = ISBN_RE.exec(ids)) {
    888 					isbn = Zotero.Utilities.cleanISBN(isbn[1]);
    889 					if(isbn && !foundIDs.has(isbn)) {
    890 						identifiers.push({
    891 							ISBN: isbn
    892 						});
    893 						foundIDs.add(isbn);
    894 					}
    895 				}
    896 			}
    897 		}
    898 		
    899 		// Next try arXiv
    900 		if (!identifiers.length) {
    901 			// arXiv identifiers are extracted without version number
    902 			// i.e. 0706.0044v1 is extracted as 0706.0044,
    903 			// because arXiv OAI API doesn't allow to access individual versions
    904 			let arXiv_RE = /((?:[^A-Za-z]|^)([\-A-Za-z\.]+\/\d{7})(?:(v[0-9]+)|)(?!\d))|((?:\D|^)(\d{4}\.\d{4,5})(?:(v[0-9]+)|)(?!\d))/g;
    905 			let m;
    906 			while ((m = arXiv_RE.exec(text))) {
    907 				let arXiv = m[2] || m[5];
    908 				if (arXiv && !foundIDs.has(arXiv)) {
    909 					identifiers.push({arXiv: arXiv});
    910 					foundIDs.add(arXiv);
    911 				}
    912 			}
    913 		}
    914 		
    915 		// Finally try for PMID
    916 		if (!identifiers.length) {
    917 			// PMID; right now, the longest PMIDs are 8 digits, so it doesn't seem like we'll
    918 			// need to discriminate for a fairly long time
    919 			let PMID_RE = /(^|\s|,|:)(\d{1,9})(?=\s|,|$)/g;
    920 			let pmid;
    921 			while ((pmid = PMID_RE.exec(text)) && !foundIDs.has(pmid)) {
    922 				identifiers.push({
    923 					PMID: pmid[2]
    924 				});
    925 				foundIDs.add(pmid);
    926 			}
    927 		}
    928 		
    929 		return identifiers;
    930 	},
    931 	
    932 	
    933 	/**
    934 	 * Hyphenate an ISBN based on the registrant table available from
    935 	 * https://www.isbn-international.org/range_file_generation
    936 	 * See isbn.js
    937 	 *
    938 	 * @param {String} isbn ISBN-10 or ISBN-13
    939 	 * @param {Boolean} dontValidate Do not attempt to validate check digit
    940 	 * @return {String} Hyphenated ISBN or empty string if invalid ISBN is supplied
    941 	 */
    942 	"hyphenateISBN": function(isbn, dontValidate) {
    943 		isbn = Zotero.Utilities.cleanISBN(isbn, dontValidate);
    944 		if (!isbn) return '';
    945 		
    946 		var ranges = Zotero.ISBN.ranges,
    947 			parts = [],
    948 			uccPref,
    949 			i = 0;
    950 		if (isbn.length == 10) {
    951 			uccPref = '978';
    952 		} else {
    953 			uccPref = isbn.substr(0,3);
    954 			if (!ranges[uccPref]) return ''; // Probably invalid ISBN, but the checksum is OK
    955 			parts.push(uccPref);
    956 			i = 3; // Skip ahead
    957 		}
    958 		
    959 		var group = '',
    960 			found = false;
    961 		while (i < isbn.length-3 /* check digit, publication, registrant */) {
    962 			group += isbn.charAt(i);
    963 			if (ranges[uccPref][group]) {
    964 				parts.push(group);
    965 				found = true;
    966 				break;
    967 			}
    968 			i++;
    969 		}
    970 		
    971 		if (!found) return ''; // Did not find a valid group
    972 		
    973 		// Array of registrant ranges that are valid for a group
    974 		// Array always contains an even number of values (as string)
    975 		// From left to right, the values are paired so that the first indicates a
    976 		// lower bound of the range and the right indicates an upper bound
    977 		// The ranges are sorted by increasing number of characters
    978 		var regRanges = ranges[uccPref][group];
    979 		
    980 		var registrant = '';
    981 		found = false;
    982 		i++; // Previous loop 'break'ed early
    983 		while (!found && i < isbn.length-2 /* check digit, publication */) {
    984 			registrant += isbn.charAt(i);
    985 			
    986 			for(let j=0; j < regRanges.length && registrant.length >= regRanges[j].length; j+=2) {
    987 				if(registrant.length == regRanges[j].length
    988 					&& registrant >= regRanges[j] && registrant <= regRanges[j+1] // Falls within the range
    989 				) {
    990 					parts.push(registrant);
    991 					found = true;
    992 					break;
    993 				}
    994 			}
    995 			
    996 			i++;
    997 		}
    998 		
    999 		if (!found) return ''; // Outside of valid range, but maybe we need to update our data
   1000 		
   1001 		parts.push(isbn.substring(i,isbn.length-1)); // Publication is the remainder up to last digit
   1002 		parts.push(isbn.charAt(isbn.length-1)); // Check digit
   1003 		
   1004 		return parts.join('-');
   1005 	},
   1006 	
   1007 	
   1008 	buildLibraryMenu: function (menulist, libraries, selectedLibraryID) {
   1009 		var menupopup = menulist.firstChild;
   1010 		while (menupopup.hasChildNodes()) {
   1011 			menupopup.removeChild(menupopup.firstChild);
   1012 		}
   1013 		var selectedIndex = 0;
   1014 		var i = 0;
   1015 		for (let library of libraries) {
   1016 			let menuitem = menulist.ownerDocument.createElement('menuitem');
   1017 			menuitem.value = library.libraryID;
   1018 			menuitem.setAttribute('label', library.name);
   1019 			menupopup.appendChild(menuitem);
   1020 			if (library.libraryID == selectedLibraryID) {
   1021 				selectedIndex = i;
   1022 			}
   1023 			i++;
   1024 		}
   1025 		
   1026 		menulist.appendChild(menupopup);
   1027 		menulist.selectedIndex = selectedIndex;
   1028 	},
   1029 	
   1030 	
   1031 	buildLibraryMenuHTML: function (select, libraries, selectedLibraryID) {
   1032 		var namespaceURI = 'http://www.w3.org/1999/xhtml';
   1033 		while (select.hasChildNodes()) {
   1034 			select.removeChild(select.firstChild);
   1035 		}
   1036 		var selectedIndex = 0;
   1037 		var i = 0;
   1038 		for (let library of libraries) {
   1039 			let option = select.ownerDocument.createElementNS(namespaceURI, 'option');
   1040 			option.setAttribute('value', library.libraryID);
   1041 			option.setAttribute('data-editable', library.editable ? 'true' : 'false');
   1042 			option.setAttribute('data-filesEditable', library.filesEditable ? 'true' : 'false');
   1043 			option.textContent = library.name;
   1044 			select.appendChild(option);
   1045 			if (library.libraryID == selectedLibraryID) {
   1046 				option.setAttribute('selected', 'selected');
   1047 			}
   1048 			i++;
   1049 		}
   1050 	},
   1051 	
   1052 	
   1053 	/**
   1054 	 * Create a libraryOrCollection DOM tree to place in <menupopup> element.
   1055 	 * If has no children, returns a <menuitem> element, otherwise <menu>.
   1056 	 * 
   1057 	 * @param {Library/Collection} libraryOrCollection
   1058 	 * @param {Node<menupopup>} elem parent element
   1059 	 * @param {function} clickAction function to execute on clicking the menuitem.
   1060 	 * 		Receives the event and libraryOrCollection for given item.
   1061 	 * 
   1062 	 * @return {Node<menuitem>/Node<menu>} appended node
   1063 	 */
   1064 	createMenuForTarget: function(libraryOrCollection, elem, currentTarget, clickAction) {
   1065 		var doc = elem.ownerDocument;
   1066 		function _createMenuitem(label, value, icon, command) {
   1067 			let menuitem = doc.createElement('menuitem');
   1068 			menuitem.setAttribute("label", label);
   1069 			menuitem.setAttribute("type", "checkbox");
   1070 			if (value == currentTarget) {
   1071 				menuitem.setAttribute("checked", "true");
   1072 			}
   1073 			menuitem.setAttribute("value", value);
   1074 			menuitem.setAttribute("image", icon);
   1075 			menuitem.addEventListener('command', command);
   1076 			menuitem.classList.add('menuitem-iconic');
   1077 			return menuitem
   1078 		}	
   1079 		
   1080 		function _createMenu(label, value, icon, command) {
   1081 			let menu = doc.createElement('menu');
   1082 			menu.setAttribute("label", label);
   1083 			menu.setAttribute("value", value);
   1084 			menu.setAttribute("image", icon);
   1085 			// Allow click on menu itself to select a target
   1086 			menu.addEventListener('click', command);
   1087 			menu.classList.add('menu-iconic');
   1088 			let menupopup = doc.createElement('menupopup');
   1089 			menu.appendChild(menupopup);
   1090 			return menu;
   1091 		}
   1092 		
   1093 		var imageSrc = libraryOrCollection.treeViewImage;
   1094 		
   1095 		// Create menuitem for library or collection itself, to be placed either directly in the
   1096 		// containing menu or as the top item in a submenu
   1097 		var menuitem = _createMenuitem(
   1098 			libraryOrCollection.name, 
   1099 			libraryOrCollection.treeViewID,
   1100 			imageSrc,
   1101 			function (event) {
   1102 				clickAction(event, libraryOrCollection);
   1103 			}
   1104 		);
   1105 		
   1106 		var collections;
   1107 		if (libraryOrCollection.objectType == 'collection') {
   1108 			collections = Zotero.Collections.getByParent(libraryOrCollection.id);
   1109 		} else {
   1110 			collections = Zotero.Collections.getByLibrary(libraryOrCollection.id);
   1111 		}
   1112 		
   1113 		// If no subcollections, place menuitem for target directly in containing men
   1114 		if (collections.length == 0) {
   1115 			elem.appendChild(menuitem);
   1116 			return menuitem
   1117 		}
   1118 		
   1119 		// Otherwise create a submenu for the target's subcollections
   1120 		var menu = _createMenu(
   1121 			libraryOrCollection.name,
   1122 			libraryOrCollection.treeViewID,
   1123 			imageSrc,
   1124 			function (event) {
   1125 				clickAction(event, libraryOrCollection);
   1126 			}
   1127 		);
   1128 		var menupopup = menu.firstChild;
   1129 		menupopup.appendChild(menuitem);
   1130 		menupopup.appendChild(doc.createElement('menuseparator'));
   1131 		for (let collection of collections) {
   1132 			let collectionMenu = this.createMenuForTarget(
   1133 				collection, elem, currentTarget, clickAction
   1134 			);
   1135 			menupopup.appendChild(collectionMenu);
   1136 		}
   1137 		elem.appendChild(menu);
   1138 		return menu;
   1139 	},
   1140 	
   1141 	
   1142 	// TODO: Move somewhere better
   1143 	getVirtualCollectionState: function (type) {
   1144 		switch (type) {
   1145 			case 'duplicates':
   1146 				var prefKey = 'duplicateLibraries';
   1147 				break;
   1148 			
   1149 			case 'unfiled':
   1150 				var prefKey = 'unfiledLibraries';
   1151 				break;
   1152 			
   1153 			default:
   1154 				throw new Error("Invalid virtual collection type '" + type + "'");
   1155 		}
   1156 		var libraries;
   1157 		try {
   1158 			libraries = JSON.parse(Zotero.Prefs.get(prefKey) || '{}');
   1159 			if (typeof libraries != 'object') {
   1160 				throw true;
   1161 			}
   1162 		}
   1163 		// Ignore old/incorrect formats
   1164 		catch (e) {
   1165 			Zotero.Prefs.clear(prefKey);
   1166 			libraries = {};
   1167 		}
   1168 		
   1169 		return libraries;
   1170 	},
   1171 	
   1172 	
   1173 	getVirtualCollectionStateForLibrary: function (libraryID, type) {
   1174 		return this.getVirtualCollectionState(type)[libraryID] !== false;
   1175 	},
   1176 	
   1177 	
   1178 	setVirtualCollectionStateForLibrary: function (libraryID, type, show) {
   1179 		switch (type) {
   1180 			case 'duplicates':
   1181 				var prefKey = 'duplicateLibraries';
   1182 				break;
   1183 			
   1184 			case 'unfiled':
   1185 				var prefKey = 'unfiledLibraries';
   1186 				break;
   1187 			
   1188 			default:
   1189 				throw new Error("Invalid virtual collection type '" + type + "'");
   1190 		}
   1191 		
   1192 		var libraries = this.getVirtualCollectionState(type);
   1193 		
   1194 		// Update current library
   1195 		libraries[libraryID] = !!show;
   1196 		// Remove libraries that don't exist or that are set to true
   1197 		for (let id of Object.keys(libraries).filter(id => libraries[id] || !Zotero.Libraries.exists(id))) {
   1198 			delete libraries[id];
   1199 		}
   1200 		Zotero.Prefs.set(prefKey, JSON.stringify(libraries));
   1201 	},
   1202 	
   1203 	
   1204 	openPreferences: function (paneID, options = {}) {
   1205 		if (typeof options == 'string') {
   1206 			Zotero.debug("ZoteroPane.openPreferences() now takes an 'options' object -- update your code", 2);
   1207 			options = {
   1208 				action: options
   1209 			};
   1210 		}
   1211 		
   1212 		var io = {
   1213 			pane: paneID,
   1214 			tab: options.tab,
   1215 			tabIndex: options.tabIndex,
   1216 			action: options.action
   1217 		};
   1218 		
   1219 		var win = null;
   1220 		// If window is already open and no special action, just focus it
   1221 		if (!options.action) {
   1222 			var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
   1223 				.getService(Components.interfaces.nsIWindowMediator);
   1224 			var enumerator = wm.getEnumerator("zotero:pref");
   1225 			if (enumerator.hasMoreElements()) {
   1226 				var win = enumerator.getNext();
   1227 				win.focus();
   1228 				if (paneID) {
   1229 					var pane = win.document.getElementsByAttribute('id', paneID)[0];
   1230 					pane.parentElement.showPane(pane);
   1231 					
   1232 					// TODO: tab/action
   1233 				}
   1234 			}
   1235 		}
   1236 		if (!win) {
   1237 			let args = [
   1238 				'chrome://zotero/content/preferences/preferences.xul',
   1239 				'zotero-prefs',
   1240 				'chrome,titlebar,toolbar,centerscreen,'
   1241 					+ Zotero.Prefs.get('browser.preferences.instantApply', true) ? 'dialog=no' : 'modal',
   1242 				io
   1243 			];
   1244 			
   1245 			let win = Services.wm.getMostRecentWindow("navigator:browser");
   1246 			if (win) {
   1247 				win.openDialog(...args);
   1248 			}
   1249 			else {
   1250 				// nsIWindowWatcher needs a wrappedJSObject
   1251 				args[args.length - 1].wrappedJSObject = args[args.length - 1];
   1252 				Services.ww.openWindow(null, ...args);
   1253 			}
   1254 		}
   1255 		
   1256 		return win;
   1257 	},
   1258 	
   1259 	
   1260 	filterStack: function (stack) {
   1261 		return stack.split(/\n/)
   1262 			.filter(line => !line.includes('resource://zotero/bluebird'))
   1263 			.filter(line => !line.includes('XPCOMUtils.jsm'))
   1264 			.join('\n');
   1265 	},
   1266 	
   1267 	
   1268 	quitZotero: function(restart=false) {
   1269 		Zotero.debug("Zotero.Utilities.Internal.quitZotero() is deprecated -- use quit()");
   1270 		this.quit(restart);
   1271 	},
   1272 	
   1273 	
   1274 	/**
   1275 	 * Quits the program, optionally restarting.
   1276 	 * @param {Boolean} [restart=false]
   1277 	 */
   1278 	quit: function(restart=false) {
   1279 		var startup = Services.startup;
   1280 		if (restart) {
   1281 			Zotero.restarting = true;
   1282 		}
   1283 		startup.quit(startup.eAttemptQuit | (restart ? startup.eRestart : 0) );
   1284 	}
   1285 }
   1286 
   1287 /**
   1288  * Runs an AppleScript on OS X
   1289  *
   1290  * @param script {String}
   1291  * @param block {Boolean} Whether the script should block until the process is finished.
   1292  */
   1293 Zotero.Utilities.Internal.executeAppleScript = new function() {
   1294 	var _osascriptFile;
   1295 	
   1296 	return function(script, block) {
   1297 		if(_osascriptFile === undefined) {
   1298 			_osascriptFile = Components.classes["@mozilla.org/file/local;1"].
   1299 			createInstance(Components.interfaces.nsILocalFile);
   1300 			_osascriptFile.initWithPath("/usr/bin/osascript");
   1301 			if(!_osascriptFile.exists()) _osascriptFile = false;
   1302 		}
   1303 		if(_osascriptFile) {
   1304 			var proc = Components.classes["@mozilla.org/process/util;1"].
   1305 			createInstance(Components.interfaces.nsIProcess);
   1306 			proc.init(_osascriptFile);
   1307 			try {
   1308 				proc.run(!!block, ['-e', script], 2);
   1309 			} catch(e) {}
   1310 		}
   1311 	}
   1312 }
   1313 	
   1314 
   1315 /**
   1316  * Activates Firefox
   1317  */
   1318 Zotero.Utilities.Internal.activate = new function() {
   1319 	// For Carbon and X11
   1320 	var _carbon, ProcessSerialNumber, SetFrontProcessWithOptions;
   1321 	var _x11, _x11Display, _x11RootWindow, XClientMessageEvent, XFetchName, XFree, XQueryTree,
   1322 		XOpenDisplay, XCloseDisplay, XFlush, XDefaultRootWindow, XInternAtom, XSendEvent,
   1323 		XMapRaised, XGetWindowProperty, X11Atom, X11Bool, X11Display, X11Window, X11Status;
   1324 					
   1325 	/** 
   1326 	 * Bring a window to the foreground by interfacing directly with X11
   1327 	 */
   1328 	function _X11BringToForeground(win, intervalID) {
   1329 		var windowTitle = win.QueryInterface(Ci.nsIInterfaceRequestor)
   1330 			.getInterface(Ci.nsIWebNavigation).QueryInterface(Ci.nsIBaseWindow).title;
   1331 		
   1332 		var x11Window = _X11FindWindow(_x11RootWindow, windowTitle);
   1333 		if(!x11Window) return;
   1334 		win.clearInterval(intervalID);
   1335 			
   1336 		var event = new XClientMessageEvent();
   1337 		event.type = 33; /* ClientMessage*/
   1338 		event.serial = 0;
   1339 		event.send_event = 1;
   1340 		event.message_type = XInternAtom(_x11Display, "_NET_ACTIVE_WINDOW", 0);
   1341 		event.display = _x11Display;
   1342 		event.window = x11Window;
   1343 		event.format = 32;
   1344 		event.l0 = 2;
   1345 		var mask = 1<<20 /* SubstructureRedirectMask */ | 1<<19 /* SubstructureNotifyMask */;
   1346 		
   1347 		if(XSendEvent(_x11Display, _x11RootWindow, 0, mask, event.address())) {
   1348 			XMapRaised(_x11Display, x11Window);
   1349 			XFlush(_x11Display);
   1350 			Zotero.debug("Integration: Activated successfully");
   1351 		} else {
   1352 			Zotero.debug("Integration: An error occurred activating the window");
   1353 		}
   1354 	}
   1355 	
   1356 	/**
   1357 	 * Find an X11 window given a name
   1358 	 */
   1359 	function _X11FindWindow(w, searchName) {
   1360 		Components.utils.import("resource://gre/modules/ctypes.jsm");
   1361 		
   1362 		var res = _X11GetProperty(w, "_NET_CLIENT_LIST", 33 /** XA_WINDOW **/)
   1363 			|| _X11GetProperty(w, "_WIN_CLIENT_LIST", 6 /** XA_CARDINAL **/);
   1364 		if(!res) return false;
   1365 		
   1366 		var nClients = res[1],
   1367 			clientList = ctypes.cast(res[0], X11Window.array(nClients).ptr).contents,
   1368 			foundName = new ctypes.char.ptr();
   1369 		for(var i=0; i<nClients; i++) {			
   1370 			if(XFetchName(_x11Display, clientList.addressOfElement(i).contents,
   1371 					foundName.address())) {
   1372 				var foundNameString = undefined;
   1373 				try {
   1374 					foundNameString = foundName.readString();
   1375 				} catch(e) {}
   1376 				XFree(foundName);
   1377 				if(foundNameString === searchName) return clientList.addressOfElement(i).contents;
   1378 			}
   1379 		}
   1380 		XFree(res[0]);
   1381 		
   1382 		return false;
   1383 	}
   1384 	
   1385 	/**
   1386 	 * Get a property from an X11 window
   1387 	 */
   1388 	function _X11GetProperty(win, propertyName, propertyType) {
   1389 		Components.utils.import("resource://gre/modules/ctypes.jsm");
   1390 		
   1391 		var returnType = new X11Atom(),
   1392 			returnFormat = new ctypes.int(),
   1393 			nItemsReturned = new ctypes.unsigned_long(),
   1394 			nBytesAfterReturn = new ctypes.unsigned_long(),
   1395 			data = new ctypes.char.ptr();
   1396 		if(!XGetWindowProperty(_x11Display, win, XInternAtom(_x11Display, propertyName, 0), 0, 1024,
   1397 				0, propertyType, returnType.address(), returnFormat.address(),
   1398 				nItemsReturned.address(), nBytesAfterReturn.address(), data.address())) {
   1399 			var nElements = ctypes.cast(nItemsReturned, ctypes.unsigned_int).value;
   1400 			if(nElements) return [data, nElements];
   1401 		}
   1402 		return null;
   1403 	}
   1404 	
   1405 	return function(win) {
   1406 		if (Zotero.isMac) {
   1407 			const BUNDLE_IDS = {
   1408 				"Zotero":"org.zotero.zotero",
   1409 				"Firefox":"org.mozilla.firefox",
   1410 				"Aurora":"org.mozilla.aurora",
   1411 				"Nightly":"org.mozilla.nightly"
   1412 			};
   1413 			
   1414 			if (win) {
   1415 				Components.utils.import("resource://gre/modules/ctypes.jsm");
   1416 				win.focus();
   1417 				
   1418 				if(!_carbon) {
   1419 					_carbon = ctypes.open("/System/Library/Frameworks/Carbon.framework/Carbon");
   1420 					/*
   1421 					 * struct ProcessSerialNumber {
   1422 					 *    unsigned long highLongOfPSN;
   1423 					 *    unsigned long lowLongOfPSN;
   1424 					 * };
   1425 					 */
   1426 					ProcessSerialNumber = new ctypes.StructType("ProcessSerialNumber", 
   1427 						[{"highLongOfPSN":ctypes.uint32_t}, {"lowLongOfPSN":ctypes.uint32_t}]);
   1428 						
   1429 					/*
   1430 					 * OSStatus SetFrontProcessWithOptions (
   1431 					 *    const ProcessSerialNumber *inProcess,
   1432 					 *    OptionBits inOptions
   1433 					 * );
   1434 					 */
   1435 					SetFrontProcessWithOptions = _carbon.declare("SetFrontProcessWithOptions",
   1436 						ctypes.default_abi, ctypes.int32_t, ProcessSerialNumber.ptr,
   1437 						ctypes.uint32_t);
   1438 				}
   1439 				
   1440 				var psn = new ProcessSerialNumber();
   1441 				psn.highLongOfPSN = 0;
   1442 				psn.lowLongOfPSN = 2 // kCurrentProcess
   1443 				
   1444 				win.addEventListener("load", function() {
   1445 					var res = SetFrontProcessWithOptions(
   1446 						psn.address(),
   1447 						1 // kSetFrontProcessFrontWindowOnly = (1 << 0)
   1448 					);
   1449 				}, false);
   1450 			} else {
   1451 				Zotero.Utilities.Internal.executeAppleScript('tell application id "'+BUNDLE_IDS[Zotero.appName]+'" to activate');
   1452 			}
   1453 		} else if(!Zotero.isWin && win) {
   1454 			Components.utils.import("resource://gre/modules/ctypes.jsm");
   1455 
   1456 			if(_x11 === false) return;
   1457 			if(!_x11) {
   1458 				try {
   1459 					_x11 = ctypes.open("libX11.so.6");
   1460 				} catch(e) {
   1461 					try {
   1462 						var libName = ctypes.libraryName("X11");
   1463 					} catch(e) {
   1464 						_x11 = false;
   1465 						Zotero.debug("Integration: Could not get libX11 name; not activating");
   1466 						Zotero.logError(e);
   1467 						return;
   1468 					}
   1469 					
   1470 					try {
   1471 						_x11 = ctypes.open(libName);
   1472 					} catch(e) {
   1473 						_x11 = false;
   1474 						Zotero.debug("Integration: Could not open "+libName+"; not activating");
   1475 						Zotero.logError(e);
   1476 						return;
   1477 					}
   1478 				}
   1479 				
   1480 				X11Atom = ctypes.unsigned_long;
   1481 				X11Bool = ctypes.int;
   1482 				X11Display = new ctypes.StructType("Display");
   1483 				X11Window = ctypes.unsigned_long;
   1484 				X11Status = ctypes.int;
   1485 					
   1486 				/*
   1487 				 * typedef struct {
   1488 				 *     int type;
   1489 				 *     unsigned long serial;	/ * # of last request processed by server * /
   1490 				 *     Bool send_event;			/ * true if this came from a SendEvent request * /
   1491 				 *     Display *display;		/ * Display the event was read from * /
   1492 				 *     Window window;
   1493 				 *     Atom message_type;
   1494 				 *     int format;
   1495 				 *     union {
   1496 				 *         char b[20];
   1497 				 *         short s[10];
   1498 				 *         long l[5];
   1499 				 *     } data;
   1500 				 * } XClientMessageEvent;
   1501 				 */
   1502 				XClientMessageEvent = new ctypes.StructType("XClientMessageEvent",
   1503 					[
   1504 						{"type":ctypes.int},
   1505 						{"serial":ctypes.unsigned_long},
   1506 						{"send_event":X11Bool},
   1507 						{"display":X11Display.ptr},
   1508 						{"window":X11Window},
   1509 						{"message_type":X11Atom},
   1510 						{"format":ctypes.int},
   1511 						{"l0":ctypes.long},
   1512 						{"l1":ctypes.long},
   1513 						{"l2":ctypes.long},
   1514 						{"l3":ctypes.long},
   1515 						{"l4":ctypes.long}
   1516 					]
   1517 				);
   1518 				
   1519 				/*
   1520 				 * Status XFetchName(
   1521 				 *    Display*		display,
   1522 				 *    Window		w,
   1523 				 *    char**		window_name_return
   1524 				 * );
   1525 				 */
   1526 				XFetchName = _x11.declare("XFetchName", ctypes.default_abi, X11Status,
   1527 					X11Display.ptr, X11Window, ctypes.char.ptr.ptr);
   1528 					
   1529 				/*
   1530 				 * Status XQueryTree(
   1531 				 *    Display*		display,
   1532 				 *    Window		w,
   1533 				 *    Window*		root_return,
   1534 				 *    Window*		parent_return,
   1535 				 *    Window**		children_return,
   1536 				 *    unsigned int*	nchildren_return
   1537 				 * );
   1538 				 */
   1539 				XQueryTree = _x11.declare("XQueryTree", ctypes.default_abi, X11Status,
   1540 					X11Display.ptr, X11Window, X11Window.ptr, X11Window.ptr, X11Window.ptr.ptr,
   1541 					ctypes.unsigned_int.ptr);
   1542 				
   1543 				/*
   1544 				 * int XFree(
   1545 				 *    void*		data
   1546 				 * );
   1547 				 */
   1548 				XFree = _x11.declare("XFree", ctypes.default_abi, ctypes.int, ctypes.voidptr_t);
   1549 				
   1550 				/*
   1551 				 * Display *XOpenDisplay(
   1552 				 *     _Xconst char*	display_name
   1553 				 * );
   1554 				 */
   1555 				XOpenDisplay = _x11.declare("XOpenDisplay", ctypes.default_abi, X11Display.ptr,
   1556 					ctypes.char.ptr);
   1557 				 
   1558 				/*
   1559 				 * int XCloseDisplay(
   1560 				 *     Display*		display
   1561 				 * );
   1562 				 */
   1563 				XCloseDisplay = _x11.declare("XCloseDisplay", ctypes.default_abi, ctypes.int,
   1564 					X11Display.ptr);
   1565 				
   1566 				/*
   1567 				 * int XFlush(
   1568 				 *     Display*		display
   1569 				 * );
   1570 				 */
   1571 				XFlush = _x11.declare("XFlush", ctypes.default_abi, ctypes.int, X11Display.ptr);
   1572 				
   1573 				/*
   1574 				 * Window XDefaultRootWindow(
   1575 				 *     Display*		display
   1576 				 * );
   1577 				 */
   1578 				XDefaultRootWindow = _x11.declare("XDefaultRootWindow", ctypes.default_abi,
   1579 					X11Window, X11Display.ptr);
   1580 					
   1581 				/*
   1582 				 * Atom XInternAtom(
   1583 				 *     Display*			display,
   1584 				 *     _Xconst char*	atom_name,
   1585 				 *     Bool				only_if_exists
   1586 				 * );
   1587 				 */
   1588 				XInternAtom = _x11.declare("XInternAtom", ctypes.default_abi, X11Atom,
   1589 					X11Display.ptr, ctypes.char.ptr, X11Bool);
   1590 				 
   1591 				/*
   1592 				 * Status XSendEvent(
   1593 				 *     Display*		display,
   1594 				 *     Window		w,
   1595 				 *     Bool			propagate,
   1596 				 *     long			event_mask,
   1597 				 *     XEvent*		event_send
   1598 				 * );
   1599 				 */
   1600 				XSendEvent = _x11.declare("XSendEvent", ctypes.default_abi, X11Status,
   1601 					X11Display.ptr, X11Window, X11Bool, ctypes.long, XClientMessageEvent.ptr);
   1602 				
   1603 				/*
   1604 				 * int XMapRaised(
   1605 				 *     Display*		display,
   1606 				 *     Window		w
   1607 				 * );
   1608 				 */
   1609 				XMapRaised = _x11.declare("XMapRaised", ctypes.default_abi, ctypes.int,
   1610 					X11Display.ptr, X11Window);
   1611 				
   1612 				/*
   1613 				 * extern int XGetWindowProperty(
   1614 				 *     Display*		 display,
   1615 				 *     Window		 w,
   1616 				 *     Atom		 property,
   1617 				 *     long		 long_offset,
   1618 				 *     long		 long_length,
   1619 				 *     Bool		 delete,
   1620 				 *     Atom		 req_type,
   1621 				 *     Atom*		 actual_type_return,
   1622 				 *     int*		 actual_format_return,
   1623 				 *     unsigned long*	 nitems_return,
   1624 				 *     unsigned long*	 bytes_after_return,
   1625 				 *     unsigned char**	 prop_return 
   1626 				 * );
   1627 				 */
   1628 				XGetWindowProperty = _x11.declare("XGetWindowProperty", ctypes.default_abi,
   1629 					ctypes.int, X11Display.ptr, X11Window, X11Atom, ctypes.long, ctypes.long,
   1630 					X11Bool, X11Atom, X11Atom.ptr, ctypes.int.ptr, ctypes.unsigned_long.ptr,
   1631 					ctypes.unsigned_long.ptr, ctypes.char.ptr.ptr);
   1632 				
   1633 					
   1634 				_x11Display = XOpenDisplay(null);
   1635 				if(!_x11Display) {
   1636 					Zotero.debug("Integration: Could not open display; not activating");
   1637 					_x11 = false;
   1638 					return;
   1639 				}
   1640 				
   1641 				Zotero.addShutdownListener(function() {
   1642 					XCloseDisplay(_x11Display);
   1643 				});
   1644 				
   1645 				_x11RootWindow = XDefaultRootWindow(_x11Display);
   1646 				if(!_x11RootWindow) {
   1647 					Zotero.debug("Integration: Could not get root window; not activating");
   1648 					_x11 = false;
   1649 					return;
   1650 				}
   1651 			}
   1652 
   1653 			win.addEventListener("load", function() {
   1654 				var intervalID;
   1655 				intervalID = win.setInterval(function() {
   1656 					_X11BringToForeground(win, intervalID);
   1657 				}, 50);
   1658 			}, false);
   1659 		}
   1660 	}
   1661 };
   1662 
   1663 Zotero.Utilities.Internal.sendToBack = function() {
   1664 	if (Zotero.isMac) {
   1665 		Zotero.Utilities.Internal.executeAppleScript(`
   1666 			tell application "System Events"
   1667 				if frontmost of application id "org.zotero.zotero" then
   1668 					set visible of process "Zotero" to false
   1669 				end if
   1670 			end tell
   1671 		`);
   1672 	}
   1673 }
   1674 
   1675 /**
   1676  *  Base64 encode / decode
   1677  *  From http://www.webtoolkit.info/
   1678  */
   1679 Zotero.Utilities.Internal.Base64 = {
   1680 	 // private property
   1681 	 _keyStr : "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
   1682 	 
   1683 	 // public method for encoding
   1684 	 encode : function (input) {
   1685 		 var output = "";
   1686 		 var chr1, chr2, chr3, enc1, enc2, enc3, enc4;
   1687 		 var i = 0;
   1688 		 
   1689 		 input = this._utf8_encode(input);
   1690 		 
   1691 		 while (i < input.length) {
   1692 			 
   1693 			 chr1 = input.charCodeAt(i++);
   1694 			 chr2 = input.charCodeAt(i++);
   1695 			 chr3 = input.charCodeAt(i++);
   1696 			 
   1697 			 enc1 = chr1 >> 2;
   1698 			 enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
   1699 			 enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
   1700 			 enc4 = chr3 & 63;
   1701 			 
   1702 			 if (isNaN(chr2)) {
   1703 				 enc3 = enc4 = 64;
   1704 			 } else if (isNaN(chr3)) {
   1705 				 enc4 = 64;
   1706 			 }
   1707 			 
   1708 			 output = output +
   1709 			 this._keyStr.charAt(enc1) + this._keyStr.charAt(enc2) +
   1710 			 this._keyStr.charAt(enc3) + this._keyStr.charAt(enc4);
   1711 			 
   1712 		 }
   1713 		 
   1714 		 return output;
   1715 	 },
   1716 	 
   1717 	 // public method for decoding
   1718 	 decode : function (input) {
   1719 		 var output = "";
   1720 		 var chr1, chr2, chr3;
   1721 		 var enc1, enc2, enc3, enc4;
   1722 		 var i = 0;
   1723 		 
   1724 		 input = input.replace(/[^A-Za-z0-9\+\/\=]/g, "");
   1725 		 
   1726 		 while (i < input.length) {
   1727 			 
   1728 			 enc1 = this._keyStr.indexOf(input.charAt(i++));
   1729 			 enc2 = this._keyStr.indexOf(input.charAt(i++));
   1730 			 enc3 = this._keyStr.indexOf(input.charAt(i++));
   1731 			 enc4 = this._keyStr.indexOf(input.charAt(i++));
   1732 			 
   1733 			 chr1 = (enc1 << 2) | (enc2 >> 4);
   1734 			 chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
   1735 			 chr3 = ((enc3 & 3) << 6) | enc4;
   1736 			 
   1737 			 output = output + String.fromCharCode(chr1);
   1738 			 
   1739 			 if (enc3 != 64) {
   1740 				 output = output + String.fromCharCode(chr2);
   1741 			 }
   1742 			 if (enc4 != 64) {
   1743 				 output = output + String.fromCharCode(chr3);
   1744 			 }
   1745 			 
   1746 		 }
   1747 		 
   1748 		 output = this._utf8_decode(output);
   1749 		 
   1750 		 return output;
   1751 		 
   1752 	 },
   1753 	 
   1754 	 // private method for UTF-8 encoding
   1755 	 _utf8_encode : function (string) {
   1756 		 string = string.replace(/\r\n/g,"\n");
   1757 		 var utftext = "";
   1758 		 
   1759 		 for (var n = 0; n < string.length; n++) {
   1760 			 
   1761 			 var c = string.charCodeAt(n);
   1762 			 
   1763 			 if (c < 128) {
   1764 				 utftext += String.fromCharCode(c);
   1765 			 }
   1766 			 else if((c > 127) && (c < 2048)) {
   1767 				 utftext += String.fromCharCode((c >> 6) | 192);
   1768 				 utftext += String.fromCharCode((c & 63) | 128);
   1769 			 }
   1770 			 else {
   1771 				 utftext += String.fromCharCode((c >> 12) | 224);
   1772 				 utftext += String.fromCharCode(((c >> 6) & 63) | 128);
   1773 				 utftext += String.fromCharCode((c & 63) | 128);
   1774 			 }
   1775 			 
   1776 		 }
   1777 		 
   1778 		 return utftext;
   1779 	 },
   1780 	 
   1781 	 // private method for UTF-8 decoding
   1782 	 _utf8_decode : function (utftext) {
   1783 		 var string = "";
   1784 		 var i = 0;
   1785 		 var c = c1 = c2 = 0;
   1786 		 
   1787 		 while ( i < utftext.length ) {
   1788 			 
   1789 			 c = utftext.charCodeAt(i);
   1790 			 
   1791 			 if (c < 128) {
   1792 				 string += String.fromCharCode(c);
   1793 				 i++;
   1794 			 }
   1795 			 else if((c > 191) && (c < 224)) {
   1796 				 c2 = utftext.charCodeAt(i+1);
   1797 				 string += String.fromCharCode(((c & 31) << 6) | (c2 & 63));
   1798 				 i += 2;
   1799 			 }
   1800 			 else {
   1801 				 c2 = utftext.charCodeAt(i+1);
   1802 				 c3 = utftext.charCodeAt(i+2);
   1803 				 string += String.fromCharCode(((c & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63));
   1804 				 i += 3;
   1805 			 }
   1806 			 
   1807 		 }
   1808 		 
   1809 		 return string;
   1810 	 }
   1811  }