/*
    ***** BEGIN LICENSE BLOCK *****
    
    Copyright © 2009 Center for History and New Media
                     George Mason University, Fairfax, Virginia, USA
                     http://zotero.org
    
    This file is part of Zotero.
    
    Zotero is free software: you can redistribute it and/or modify
    it under the terms of the GNU Affero General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.
    
    Zotero is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU Affero General Public License for more details.
    
    You should have received a copy of the GNU Affero General Public License
    along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
    
    ***** END LICENSE BLOCK *****
*/

// Commonly used imports accessible anywhere
Components.utils.import("resource://zotero/config.js");
Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
Components.utils.import("resource://gre/modules/Services.jsm");
Components.utils.import("resource://gre/modules/osfile.jsm");
Components.utils.import("resource://gre/modules/PluralForm.jsm");

Services.scriptloader.loadSubScript("resource://zotero/polyfill.js");

/*
 * Core functions
 */
 (function(){
	// Privileged (public) methods
	this.getStorageDirectory = getStorageDirectory;
	this.debug = debug;
	this.log = log;
	this.logError = logError;
	this.localeJoin = localeJoin;
	this.setFontSize = setFontSize;
	this.flattenArguments = flattenArguments;
	this.getAncestorByTagName = getAncestorByTagName;
	this.randomString = randomString;
	this.moveToUnique = moveToUnique;
	this.reinit = reinit; // defined in zotero-service.js
	
	// Public properties
	this.initialized = false;
	this.skipLoading = false;
	this.startupError;
	this.__defineGetter__("startupErrorHandler", function() { return _startupErrorHandler; });
	this.version;
	this.platform;
	this.locale;
	this.dir; // locale direction: 'ltr' or 'rtl'
	this.isMac;
	this.isWin;
	this.initialURL; // used by Schema to show the changelog on upgrades
	this.Promise = require('resource://zotero/bluebird.js');
	
	this.getActiveZoteroPane = function() {
		var win = Services.wm.getMostRecentWindow("navigator:browser");
		return win ? win.ZoteroPane : null;
	};
	
	/**
	 * @property	{Boolean}	locked		Whether all Zotero panes are locked
	 *										with an overlay
	 */
	this.__defineGetter__('locked', function () { return _locked; });
	this.__defineSetter__('locked', function (lock) {
		var wasLocked = _locked;
		_locked = lock;
		
		if (!wasLocked && lock) {
			this.unlockDeferred = Zotero.Promise.defer();
			this.unlockPromise = this.unlockDeferred.promise;
		}
		else if (wasLocked && !lock) {
			Zotero.debug("Running unlock callbacks");
			this.unlockDeferred.resolve();
		}
	});
	
	/**
	 * @property	{Boolean}	closing		True if the application is closing.
	 */
	this.closing = false;
	
	
	this.unlockDeferred;
	this.unlockPromise;
	this.initializationDeferred;
	this.initializationPromise;
	
	this.hiDPISuffix = "";
	
	var _startupErrorHandler;
	var _localizedStringBundle;
	
	var _locked = false;
	var _shutdownListeners = [];
	var _progressMessage;
	var _progressMeters;
	var _progressPopup;
	var _lastPercentage;
	
	// whether we are waiting for another Zotero process to release its DB lock
	var _waitingForDBLock = false;
	
	/**
	 * Maintains nsITimers to be used when Zotero.wait() completes (to reduce performance penalty
	 * of initializing new objects)
	 */
	var _waitTimers = [];
	
	/**
	 * Maintains nsITimerCallbacks to be used when Zotero.wait() completes
	 */
	var _waitTimerCallbacks = [];
	
	/**
	 * Maintains running nsITimers in global scope, so that they don't disappear randomly
	 */
	var _runningTimers = new Map();
	
	var _startupTime = new Date();
	// Errors that were in the console at startup
	var _startupErrors = [];
	// Number of errors to maintain in the recent errors buffer
	const ERROR_BUFFER_SIZE = 25;
	// A rolling buffer of the last ERROR_BUFFER_SIZE errors
	var _recentErrors = [];
	
	/**
	 * Initialize the extension
	 *
	 * @return {Promise<Boolean>}
	 */
	this.init = Zotero.Promise.coroutine(function* (options) {
		if (this.initialized || this.skipLoading) {
			return false;
		}
		
		this.locked = true;
		this.initializationDeferred = Zotero.Promise.defer();
		this.initializationPromise = this.initializationDeferred.promise;
		this.uiReadyDeferred = Zotero.Promise.defer();
		this.uiReadyPromise = this.uiReadyDeferred.promise;
		
		// Add a function to Zotero.Promise to check whether a value is still defined, and if not
		// to throw a specific error that's ignored by the unhandled rejection handler in
		// bluebird.js. This allows for easily cancelling promises when they're no longer
		// needed, for example after a binding is destroyed.
		//
		// Example usage:
		//
		// getAsync.tap(() => Zotero.Promise.check(this.mode))
		//
		// If the binding is destroyed while getAsync() is being resolved and this.mode no longer
		// exists, subsequent lines won't be run, and nothing will be logged to the console.
		this.Promise.check = function (val) {
			if (!val && val !== 0) {
				let e = new Error;
				e.name = "ZoteroPromiseInterrupt";
				throw e;
			}
		};
		
		if (options) {
			let opts = [
				'openPane',
				'test',
				'automatedTest',
				'skipBundledFiles'
			];
			opts.filter(opt => options[opt]).forEach(opt => this[opt] = true);
		}
		
		this.mainThread = Services.tm.mainThread;
		
		this.clientName = ZOTERO_CONFIG.CLIENT_NAME;
		
		var appInfo = Components.classes["@mozilla.org/xre/app-info;1"]
			.getService(Components.interfaces.nsIXULAppInfo);
		this.platformVersion = appInfo.platformVersion;
		this.platformMajorVersion = parseInt(appInfo.platformVersion.match(/^[0-9]+/)[0]);
		this.isFx = true;
		this.isClient = true;
		this.isStandalone = Services.appinfo.ID == ZOTERO_CONFIG['GUID'];
		
		if (Zotero.isStandalone) {
			var version = Services.appinfo.version;
		}
		else {
			let deferred = Zotero.Promise.defer();
			Components.utils.import("resource://gre/modules/AddonManager.jsm");
			AddonManager.getAddonByID(
				ZOTERO_CONFIG.GUID,
				function (addon) {
					deferred.resolve(addon.version);
				}
			);
			var version = yield deferred.promise;
		}
		Zotero.version = version;
		
		// OS platform
		var win = Components.classes["@mozilla.org/appshell/appShellService;1"]
			   .getService(Components.interfaces.nsIAppShellService)
			   .hiddenDOMWindow;
		this.platform = win.navigator.platform;
		this.isMac = (this.platform.substr(0, 3) == "Mac");
		this.isWin = (this.platform.substr(0, 3) == "Win");
		this.isLinux = (this.platform.substr(0, 5) == "Linux");
		this.oscpu = win.navigator.oscpu;
		
		// Browser
		Zotero.browser = "g";
		
		//
		// Get settings from language pack (extracted by zotero-build/locale/merge_mozilla_files)
		//
		function getIntlProp(name, fallback = null) {
			try {
				return intlProps.GetStringFromName(name);
			}
			catch (e) {
				Zotero.logError(`Couldn't load ${name} from intl.properties`);
				return fallback;
			}
		}
		function setOrClearIntlPref(name, type) {
			var val = getIntlProp(name);
			if (val !== null) {
				if (type == 'boolean') {
					val = val == 'true';
				}
				Zotero.Prefs.set(name, val, 1);
			}
			else {
				Zotero.Prefs.clear(name, 1);
			}
		}
		var intlProps = Services.strings.createBundle("chrome://zotero/locale/mozilla/intl.properties");
		this.locale = getIntlProp('general.useragent.locale', 'en-US');
		let [get, numForms] = PluralForm.makeGetter(parseInt(getIntlProp('pluralRule', 1)));
		this.pluralFormGet = get;
		this.pluralFormNumForms = numForms;
		setOrClearIntlPref('intl.accept_languages', 'string');
		
		// Also load the brand as appName
		var brandBundle = Services.strings.createBundle("chrome://branding/locale/brand.properties");
		this.appName = brandBundle.GetStringFromName("brandShortName");
		
		_localizedStringBundle = Services.strings.createBundle("chrome://zotero/locale/zotero.properties");
		
		// Set the locale direction to Zotero.dir
		// DEBUG: is there a better way to get the entity from JS?
		var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"]
						.createInstance();
		xmlhttp.open('GET', 'chrome://global/locale/global.dtd', false);
		xmlhttp.overrideMimeType('text/plain');
		xmlhttp.send(null);
		var matches = xmlhttp.responseText.match(/(ltr|rtl)/);
		if (matches && matches[0] == 'rtl') {
			Zotero.dir = 'rtl';
		}
		else {
			Zotero.dir = 'ltr';
		}
		Zotero.rtl = Zotero.dir == 'rtl';
		
		Zotero.Prefs.init();
		Zotero.Debug.init(options && options.forceDebugLog);
		
		// Make sure that Zotero Standalone is not running as root
		if(Zotero.isStandalone && !Zotero.isWin) _checkRoot();
		
		_addToolbarIcon();
		
		try {
			yield Zotero.DataDirectory.init();
			var dataDir = Zotero.DataDirectory.dir;
		}
		catch (e) {
			// Zotero dir not found
			if ((e instanceof OS.File.Error && e.becauseNoSuchFile) || e.name == 'NS_ERROR_FILE_NOT_FOUND') {
				let foundInDefault = false;
				try {
					foundInDefault = (yield OS.File.exists(Zotero.DataDirectory.defaultDir))
						&& (yield OS.File.exists(
							OS.Path.join(
								Zotero.DataDirectory.defaultDir,
								Zotero.DataDirectory.getDatabaseFilename()
							)
						));
				}
				catch (e) {
					Zotero.logError(e);
				}
				
				let previousDir = Zotero.Prefs.get('lastDataDir') || Zotero.Prefs.get('dataDir');
				Zotero.startupError = foundInDefault
					? Zotero.getString(
						'dataDir.notFound.defaultFound',
						[
							Zotero.clientName,
							previousDir,
							Zotero.DataDirectory.defaultDir
						]
					)
					: Zotero.getString('dataDir.notFound', Zotero.clientName);
				_startupErrorHandler = function() {
					var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"].
							createInstance(Components.interfaces.nsIPromptService);
					var buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING
						+ ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING
						+ ps.BUTTON_POS_2 * ps.BUTTON_TITLE_IS_STRING;
					// TEMP: lastDataDir can be removed once old persistent descriptors have been
					// converted, which they are in getZoteroDirectory() in 5.0
					if (foundInDefault) {
						let index = ps.confirmEx(null,
							Zotero.getString('general.error'),
							Zotero.startupError,
							buttonFlags,
							Zotero.getString('dataDir.useNewLocation'),
							Zotero.getString('general.quit'),
							Zotero.getString('general.locate'),
							null, {}
						);
						// Revert to home directory
						if (index == 0) {
							Zotero.DataDirectory.set(Zotero.DataDirectory.defaultDir);
							Zotero.Utilities.Internal.quit(true);
							return;
						}
						// Locate data directory
						else if (index == 2) {
							Zotero.DataDirectory.choose(true);
						}

					}
					else {
						let index = ps.confirmEx(null,
							Zotero.getString('general.error'),
							Zotero.startupError + '\n\n' +
							Zotero.getString('dataDir.previousDir') + ' ' + previousDir,
							buttonFlags,
							Zotero.getString('general.quit'),
							Zotero.getString('dataDir.useDefaultLocation'),
							Zotero.getString('general.locate'),
							null, {}
						);
						// Revert to home directory
						if (index == 1) {
							Zotero.DataDirectory.set(Zotero.DataDirectory.defaultDir);
							Zotero.Utilities.Internal.quit(true);
							return;
						}
						// Locate data directory
						else if (index == 2) {
							Zotero.DataDirectory.choose(true);
						}
					}
				}
				return;
			}
			// DEBUG: handle more startup errors
			else {
				throw e;
			}
		}
		
		if (!Zotero.isConnector) {
			yield Zotero.DataDirectory.checkForMigration(
				dataDir, Zotero.DataDirectory.defaultDir
			);
			if (this.skipLoading) {
				return;
			}
			
			// Make sure data directory isn't in Dropbox, etc.
			if (Zotero.isStandalone) {
				yield Zotero.DataDirectory.checkForUnsafeLocation(dataDir);
			}
		}
		
		// Register shutdown handler to call Zotero.shutdown()
		var _shutdownObserver = {observe:function() { Zotero.shutdown().done() }};
		Services.obs.addObserver(_shutdownObserver, "quit-application", false);
		
		try {
			Zotero.IPC.init();
		}
		catch (e) {
			if (_checkDataDirAccessError(e)) {
				return false;
			}
			throw (e);
		}
		
		// Get startup errors
		try {
			var messages = {};
			Services.console.getMessageArray(messages, {});
			_startupErrors = Object.keys(messages.value).map(i => messages[i])
				.filter(msg => _shouldKeepError(msg));
		} catch(e) {
			Zotero.logError(e);
		}
		// Register error observer
		Services.console.registerListener(ConsoleListener);
		
		// Add shutdown listener to remove quit-application observer and console listener
		this.addShutdownListener(function() {
			Services.obs.removeObserver(_shutdownObserver, "quit-application", false);
			Services.console.unregisterListener(ConsoleListener);
		});
		
		// Load additional info for connector or not
		if(Zotero.isConnector) {
			Zotero.debug("Loading in connector mode");
			Zotero.Connector_Types.init();
			
			// Store a startupError until we get information from Zotero Standalone
			Zotero.startupError = Zotero.getString("connector.loadInProgress")
			
			if(!Zotero.isFirstLoadThisSession) {
				// We want to get a checkInitComplete message before initializing if we switched to
				// connector mode because Standalone was launched
				Zotero.IPC.broadcast("checkInitComplete");
			} else {
				Zotero.initComplete();
			}
		} else {
			Zotero.debug("Loading in full mode");
			return _initFull()
			.then(function (success) {
				if (!success) {
					return false;
				}
				
				if(Zotero.isStandalone) Zotero.Standalone.init();
				Zotero.initComplete();
			})
		}
		
		return true;
	});
	
	/**
	 * Triggers events when initialization finishes
	 */
	this.initComplete = function() {
		if(Zotero.initialized) return;
		
		Zotero.debug("Running initialization callbacks");
		delete this.startupError;
		this.initialized = true;
		this.initializationDeferred.resolve();
		
		if(Zotero.isConnector) {
			Zotero.Repo.init();
			Zotero.locked = false;
		}
		
		if(!Zotero.isFirstLoadThisSession) {
			// trigger zotero-reloaded event
			Zotero.debug('Triggering "zotero-reloaded" event');
			Services.obs.notifyObservers(Zotero, "zotero-reloaded", null);
		}
		
		Zotero.debug('Triggering "zotero-loaded" event');
		Services.obs.notifyObservers(Zotero, "zotero-loaded", null);
	}
	
	
	this.uiIsReady = function () {
		if (this.uiReadyPromise.isPending()) {
			Zotero.debug("User interface ready in " + (new Date() - _startupTime) + " ms");
			this.uiReadyDeferred.resolve();
		}
	};
	
	
	var _addToolbarIcon = function () {
		if (Zotero.isStandalone) return;
		
		// Add toolbar icon
		try {
			Services.scriptloader.loadSubScript("chrome://zotero/content/icon.js", {}, "UTF-8");
		}
		catch (e) {
			if (Zotero) {
				Zotero.debug(e, 1);
			}
			Components.utils.reportError(e);
		}
	};
	
	
	/**
	 * Initialization function to be called only if Zotero is in full mode
	 *
	 * @return {Promise:Boolean}
	 */
	var _initFull = Zotero.Promise.coroutine(function* () {
		if (!(yield _initDB())) return false;
		
		Zotero.VersionHeader.init();
		
		// Check for data reset/restore
		var dataDir = Zotero.DataDirectory.dir;
		var restoreFile = OS.Path.join(dataDir, 'restore-from-server');
		var resetDataDirFile = OS.Path.join(dataDir, 'reset-data-directory');
		
		var result = yield Zotero.Promise.all([OS.File.exists(restoreFile), OS.File.exists(resetDataDirFile)]);
		if (result.some(r => r)) {
			[Zotero.restoreFromServer, Zotero.resetDataDir] = result;
			try {
				yield Zotero.DB.closeDatabase();
				
				// TODO: better error handling
				
				// TODO: prompt for location
				// TODO: Back up database
				// TODO: Reset translators and styles
				
				
				
				if (Zotero.restoreFromServer) {
					let dbfile = Zotero.DataDirectory.getDatabase();
					Zotero.debug("Deleting " + dbfile);
					yield OS.File.remove(dbfile, { ignoreAbsent: true });
					let storageDir = OS.Path.join(dataDir, 'storage');
					Zotero.debug("Deleting " + storageDir.path);
					OS.File.removeDir(storageDir, { ignoreAbsent: true }),
					yield OS.File.remove(restoreFile);
					Zotero.restoreFromServer = true;
				}
				else if (Zotero.resetDataDir) {
					Zotero.initAutoSync = true;
					
					// Clear some user prefs
					[
						'sync.server.username',
						'sync.storage.username'
					].forEach(p => Zotero.Prefs.clear(p));
					
					// Clear data directory
					Zotero.debug("Deleting data directory files");
					let lastError;
					// Delete all files in directory rather than removing directory, in case it's
					// a symlink
					yield Zotero.File.iterateDirectory(dataDir, function* (iterator) {
						while (true) {
							let entry = yield iterator.next();
							// Don't delete some files
							if (entry.name == 'pipes'
									|| entry.name.startsWith(Zotero.Fulltext.pdfInfoName)
									|| entry.name.startsWith(Zotero.Fulltext.pdfConverterName)) {
								continue;
							}
							Zotero.debug("Deleting " + entry.path);
							try {
								if (entry.isDir) {
									yield OS.File.removeDir(entry.path);
								}
								else {
									yield OS.File.remove(entry.path);
								}
							}
							// Keep trying to delete as much as we can
							catch (e) {
								lastError = e;
								Zotero.logError(e);
							}
						}
					});
					if (lastError) {
						throw lastError;
					}
				}
				Zotero.debug("Done with reset");
				
				if (!(yield _initDB())) return false;
			}
			catch (e) {
				// Restore from backup?
				alert(e);
				return false;
			}
		}
		
		Zotero.HTTP.triggerProxyAuth();
		
		// Add notifier queue callbacks to the DB layer
		Zotero.DB.addCallback('begin', function () { return Zotero.Notifier.begin(); });
		Zotero.DB.addCallback('commit', function () { return Zotero.Notifier.commit(); });
		Zotero.DB.addCallback('rollback', function () { return Zotero.Notifier.reset(); });
		
		try {
			// Require >=2.1b3 database to ensure proper locking
			if (Zotero.isStandalone) {
				let dbSystemVersion = yield Zotero.Schema.getDBVersion('system');
				if (dbSystemVersion > 0 && dbSystemVersion < 31) {
					var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"]
								.createInstance(Components.interfaces.nsIPromptService);
					var buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING)
						+ (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_IS_STRING)
						+ (ps.BUTTON_POS_2) * (ps.BUTTON_TITLE_IS_STRING)
						+ ps.BUTTON_POS_2_DEFAULT;
					var index = ps.confirmEx(
						null,
						Zotero.getString('dataDir.incompatibleDbVersion.title'),
						Zotero.getString('dataDir.incompatibleDbVersion.text'),
						buttonFlags,
						Zotero.getString('general.useDefault'),
						Zotero.getString('dataDir.chooseNewDataDirectory'),
						Zotero.getString('general.quit'),
						null,
						{}
					);
					
					var quit = false;
					
					// Default location
					if (index == 0) {
						Zotero.Prefs.set("useDataDir", false)
						
						Services.startup.quit(
							Components.interfaces.nsIAppStartup.eAttemptQuit
								| Components.interfaces.nsIAppStartup.eRestart
						);
					}
					// Select new data directory
					else if (index == 1) {
						let dir = yield Zotero.DataDirectory.choose(true);
						if (!dir) {
							quit = true;
						}
					}
					else {
						quit = true;
					}
					
					if (quit) {
						Services.startup.quit(Components.interfaces.nsIAppStartup.eAttemptQuit);
					}
					
					throw true;
				}
			}
			
			try {
				var updated = yield Zotero.Schema.updateSchema({
					onBeforeUpdate: (options = {}) => {
						if (options.minor) return;
						try {
							Zotero.showZoteroPaneProgressMeter(
								Zotero.getString('upgrade.status')
							)
						}
						catch (e) {
							Zotero.logError(e);
						}
					}
				});
			}
			catch (e) {
				Zotero.logError(e);
				
				if (e instanceof Zotero.DB.IncompatibleVersionException) {
					let kbURL = "https://www.zotero.org/support/kb/newer_db_version";
					let msg = (e.dbClientVersion
						? Zotero.getString('startupError.incompatibleDBVersion',
							[Zotero.clientName, e.dbClientVersion])
						: Zotero.getString('startupError.zoteroVersionIsOlder')) + "\n\n"
						+ Zotero.getString('startupError.zoteroVersionIsOlder.current', Zotero.version)
							+ "\n\n"
						+ Zotero.getString('startupError.zoteroVersionIsOlder.upgrade',
							ZOTERO_CONFIG.DOMAIN_NAME);
					Zotero.startupError = msg;
					_startupErrorHandler = function() {
						var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"]
							.getService(Components.interfaces.nsIPromptService);
						var buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING)
							+ (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_CANCEL)
							+ (ps.BUTTON_POS_2) * (ps.BUTTON_TITLE_IS_STRING)
							+ ps.BUTTON_POS_0_DEFAULT;
						
						var index = ps.confirmEx(
							null,
							Zotero.getString('general.error'),
							Zotero.startupError,
							buttonFlags,
							Zotero.getString('general.checkForUpdate'),
							null,
							Zotero.getString('general.moreInformation'),
							null,
							{}
						);
						
						// "Check for Update" button
						if(index === 0) {
							if(Zotero.isStandalone) {
								Components.classes["@mozilla.org/embedcomp/window-watcher;1"]
									.getService(Components.interfaces.nsIWindowWatcher)
									.openWindow(null, 'chrome://mozapps/content/update/updates.xul',
										'updateChecker', 'chrome,centerscreen,modal', null);
							} else {
								// In Firefox, show the add-on manager
								Components.utils.import("resource://gre/modules/AddonManager.jsm");
								AddonManager.getAddonByID(ZOTERO_CONFIG['GUID'],
									function (addon) {
										// Disable auto-update so that the user is presented with the option
										var initUpdateState = addon.applyBackgroundUpdates;
										addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DISABLE;
										addon.findUpdates({
												onNoUpdateAvailable: function() {
													ps.alert(
														null,
														Zotero.getString('general.noUpdatesFound'),
														Zotero.getString('general.isUpToDate', 'Zotero')
													);
												},
												onUpdateAvailable: function() {
													// Show available update
													Components.classes["@mozilla.org/appshell/window-mediator;1"]
														.getService(Components.interfaces.nsIWindowMediator)
														.getMostRecentWindow('navigator:browser')
														.BrowserOpenAddonsMgr('addons://updates/available');
												},
												onUpdateFinished: function() {
													// Restore add-on auto-update state, but don't fire
													//  too quickly or the update will not show in the
													//  add-on manager
													setTimeout(function() {
															addon.applyBackgroundUpdates = initUpdateState;
													}, 1000);
												}
											},
											AddonManager.UPDATE_WHEN_USER_REQUESTED
										);
									}
								);
							}
						}
						// Load More Info page
						else if (index == 2) {
							let io = Components.classes['@mozilla.org/network/io-service;1']
								.getService(Components.interfaces.nsIIOService);
							let uri = io.newURI(kbURL, null, null);
							let handler = Components.classes['@mozilla.org/uriloader/external-protocol-service;1']
								.getService(Components.interfaces.nsIExternalProtocolService)
								.getProtocolHandlerInfo('http');
							handler.preferredAction = Components.interfaces.nsIHandlerInfo.useSystemDefault;
							handler.launchWithURI(uri, null);
						}
					};
					throw e;
				}
				
				let stack = e.stack ? Zotero.Utilities.Internal.filterStack(e.stack) : null;
				Zotero.startupError = Zotero.getString('startupError.databaseUpgradeError')
					+ "\n\n"
					+ (stack || e);
				throw e;
			}
			
			yield Zotero.Users.init();
			yield Zotero.Libraries.init();
			
			yield Zotero.ItemTypes.init();
			yield Zotero.ItemFields.init();
			yield Zotero.CreatorTypes.init();
			yield Zotero.FileTypes.init();
			yield Zotero.CharacterSets.init();
			yield Zotero.RelationPredicates.init();
			
			Zotero.locked = false;
			
			// Initialize various services
			Zotero.Integration.init();
			
			if(Zotero.Prefs.get("httpServer.enabled")) {
				Zotero.Server.init();
			}
			
			yield Zotero.Fulltext.init();
			
			Zotero.Notifier.registerObserver(Zotero.Tags, 'setting', 'tags');
			
			yield Zotero.Sync.Data.Local.init();
			yield Zotero.Sync.Data.Utilities.init();
			Zotero.Sync.Runner = new Zotero.Sync.Runner_Module;
			Zotero.Sync.EventListeners.init();
			Zotero.Streamer = new Zotero.Streamer_Module;
			Zotero.Streamer.init();
			
			Zotero.MIMETypeHandler.init();
			yield Zotero.Proxies.init();
			
			// Initialize keyboard shortcuts
			Zotero.Keys.init();
			
			yield Zotero.Date.init();
			Zotero.LocateManager.init();
			yield Zotero.ID.init();
			yield Zotero.Collections.init();
			yield Zotero.Items.init();
			yield Zotero.Searches.init();
			yield Zotero.Tags.init();
			yield Zotero.Creators.init();
			yield Zotero.Groups.init();
			yield Zotero.Relations.init();
			
			// Load all library data except for items, which are loaded when libraries are first
			// clicked on or if otherwise necessary
			yield Zotero.Promise.each(
				Zotero.Libraries.getAll(),
				library => Zotero.Promise.coroutine(function* () {
					yield Zotero.SyncedSettings.loadAll(library.libraryID);
					if (library.libraryType != 'feed') {
						yield Zotero.Collections.loadAll(library.libraryID);
						yield Zotero.Searches.loadAll(library.libraryID);
					}
				})()
			);

			
			Zotero.Items.startEmptyTrashTimer();
			
			yield Zotero.QuickCopy.init();
			Zotero.addShutdownListener(() => Zotero.QuickCopy.uninit());
			
			Zotero.Feeds.init();
			Zotero.addShutdownListener(() => Zotero.Feeds.uninit());
			
			return true;
		}
		catch (e) {
			Zotero.logError(e);
			if (!Zotero.startupError) {
				Zotero.startupError = Zotero.getString('startupError') + "\n\n" + (e.stack || e);
			}
			return false;
		}
	});
	
	/**
	 * Initializes the DB connection
	 */
	var _initDB = Zotero.Promise.coroutine(function* (haveReleasedLock) {
		try {
			// Test read access
			yield Zotero.DB.test();
			
			let dbfile = Zotero.DataDirectory.getDatabase();

			// Tell any other Zotero instances to release their lock,
			// in case we lost the lock on the database (how?) and it's
			// now open in two places at once
			Zotero.IPC.broadcast("releaseLock " + dbfile);
			
			// Test write access on Zotero data directory
			if (!Zotero.File.pathToFile(OS.Path.dirname(dbfile)).isWritable()) {
				var msg = 'Cannot write to ' + OS.Path.dirname(dbfile) + '/';
			}
			// Test write access on Zotero database
			else if (!Zotero.File.pathToFile(dbfile).isWritable()) {
				var msg = 'Cannot write to ' + dbfile;
			}
			else {
				var msg = false;
			}
			
			if (msg) {
				var e = {
					name: 'NS_ERROR_FILE_ACCESS_DENIED',
					message: msg,
					toString: function () { return this.message; }
				};
				throw (e);
			}
		}
		catch (e) {
			if (_checkDataDirAccessError(e)) {}
			// Storage busy
			else if (e.message.includes('2153971713')) {
				Zotero.startupError = Zotero.getString('startupError.databaseInUse') + "\n\n"
					+ Zotero.getString(
						"startupError.close" + (Zotero.isStandalone ? 'Firefox' : 'Standalone')
					);
			}
			else {
				let stack = e.stack ? Zotero.Utilities.Internal.filterStack(e.stack) : null;
				Zotero.startupError = Zotero.getString('startupError') + "\n\n" + (stack || e);
			}
			
			Zotero.debug(e.toString(), 1);
			Components.utils.reportError(e); // DEBUG: doesn't always work
			Zotero.skipLoading = true;
			return false;
		}
		
		return true;
	});
	
	
	function _checkDataDirAccessError(e) {
		if (e.name != 'NS_ERROR_FILE_ACCESS_DENIED' && !e.message.includes('2152857621')) {
			return false;
		}
		
		var msg = Zotero.getString('dataDir.databaseCannotBeOpened', Zotero.clientName)
			+ "\n\n"
			+ Zotero.getString('dataDir.checkPermissions', Zotero.clientName);
		// If already using default directory, just show it
		if (Zotero.DataDirectory.dir == Zotero.DataDirectory.defaultDir) {
			msg += "\n\n" + Zotero.getString('dataDir.location', Zotero.DataDirectory.dir);
		}
		// Otherwise suggest moving to default, since there's a good chance this is due to security
		// software preventing Zotero from accessing the selected directory (particularly if it's
		// a Firefox profile)
		else {
			msg += "\n\n"
				+ Zotero.getString('dataDir.moveToDefaultLocation', Zotero.clientName)
				+ "\n\n"
				+ Zotero.getString(
					'dataDir.migration.failure.full.current', Zotero.DataDirectory.dir
				)
				+ "\n"
				+ Zotero.getString(
					'dataDir.migration.failure.full.recommended', Zotero.DataDirectory.defaultDir
				);
		}
		Zotero.startupError = msg;
		return true;
	}
	
	
	/**
	 * Called when the DB has been released by another Zotero process to perform necessary 
	 * initialization steps
	 */
	this.onDBLockReleased = function() {
		if(Zotero.isConnector) {
			// if DB lock is released, switch out of connector mode
			switchConnectorMode(false);
		} else if(_waitingForDBLock) {
			// if waiting for DB lock and we get it, continue init
			_waitingForDBLock = false;
		}
	}
	
	this.shutdown = Zotero.Promise.coroutine(function* () {
		Zotero.debug("Shutting down Zotero");
		
		try {
			// set closing to true
			Zotero.closing = true;
			
			// run shutdown listener
			for (let listener of _shutdownListeners) {
				try {
					listener();
				} catch(e) {
					Zotero.logError(e);
				}
			}
			
			// remove temp directory
			Zotero.removeTempDirectory();
			
			if (Zotero.DB) {
				// close DB
				yield Zotero.DB.closeDatabase(true)
				
				if (!Zotero.restarting) {
					// broadcast that DB lock has been released
					Zotero.IPC.broadcast("lockReleased");
				}
			}
		} catch(e) {
			Zotero.logError(e);
			throw e;
		}
	});
	
	
	this.getProfileDirectory = function () {
		Zotero.warn("Zotero.getProfileDirectory() is deprecated -- use Zotero.Profile.dir");
		return Zotero.File.pathToFile(Zotero.Profile.dir);
	}
	
	
	this.getZoteroDirectory = function () {
		Zotero.warn("Zotero.getZoteroDirectory() is deprecated -- use Zotero.DataDirectory.dir");
		return Zotero.File.pathToFile(Zotero.DataDirectory.dir);
	}
	
	
	function getStorageDirectory(){
		var file = OS.Path.join(Zotero.DataDirectory.dir, 'storage');
		file = Zotero.File.pathToFile(file);
		Zotero.File.createDirectoryIfMissing(file);
		return file;
	}
	
	
	this.getZoteroDatabase = function (name, ext) {
		Zotero.warn("Zotero.getZoteroDatabase() is deprecated -- use Zotero.DataDirectory.getDatabase()");
		return Zotero.File.pathToFile(Zotero.DataDirectory.getDatabase(name, ext));
	}
	
	
	/**
	 * @return	{nsIFile}
	 */
	this.getTempDirectory = function () {
		var tmp = Zotero.File.pathToFile(Zotero.DataDirectory.dir);
		tmp.append('tmp');
		Zotero.File.createDirectoryIfMissing(tmp);
		return tmp;
	}
	
	
	this.removeTempDirectory = function () {
		var tmp = Zotero.File.pathToFile(Zotero.DataDirectory.dir);
		tmp.append('tmp');
		if (tmp.exists()) {
			try {
				tmp.remove(true);
			}
			catch (e) {}
		}
	}
	
	
	this.getStylesDirectory = function () {
		var dir = Zotero.File.pathToFile(Zotero.DataDirectory.dir);
		dir.append('styles');
		Zotero.File.createDirectoryIfMissing(dir);
		return dir;
	}
	
	
	this.getTranslatorsDirectory = function () {
		var dir = Zotero.File.pathToFile(Zotero.DataDirectory.dir);
		dir.append('translators');
		Zotero.File.createDirectoryIfMissing(dir);
		return dir;
	}
	
	
	this.openMainWindow = function () {
		var prefService = Components.classes["@mozilla.org/preferences-service;1"]
			.getService(Components.interfaces.nsIPrefBranch);
		var chromeURI = prefService.getCharPref('toolkit.defaultChromeURI');
		var flags = prefService.getCharPref("toolkit.defaultChromeFeatures", "chrome,dialog=no,all");
		var ww = Components.classes['@mozilla.org/embedcomp/window-watcher;1']
			.getService(Components.interfaces.nsIWindowWatcher);
		return ww.openWindow(null, chromeURI, '_blank', flags, null);
	}
	
	
	/**
	 * Launch a file, the best way we can
	 */
	this.launchFile = function (file) {
		file = Zotero.File.pathToFile(file);
		try {
			Zotero.debug("Launching " + file.path);
			file.launch();
		}
		catch (e) {
			Zotero.debug(e, 2);
			Zotero.debug("launch() not supported -- trying fallback executable", 2);
			
			try {
				if (Zotero.isWin) {
					var pref = "fallbackLauncher.windows";
				}
				else {
					var pref = "fallbackLauncher.unix";
				}
				var path = Zotero.Prefs.get(pref);
				
				var exec = Components.classes["@mozilla.org/file/local;1"]
							.createInstance(Components.interfaces.nsILocalFile);
				exec.initWithPath(path);
				if (!exec.exists()) {
					throw new Error(path + " does not exist");
				}
				
				var proc = Components.classes["@mozilla.org/process/util;1"]
								.createInstance(Components.interfaces.nsIProcess);
				proc.init(exec);
				
				var args = [file.path];
				proc.runw(true, args, args.length);
			}
			catch (e) {
				Zotero.debug(e);
				Zotero.debug("Launching via executable failed -- passing to loadUrl()");
				
				// If nsILocalFile.launch() isn't available and the fallback
				// executable doesn't exist, we just let the Firefox external
				// helper app window handle it
				var nsIFPH = Components.classes["@mozilla.org/network/protocol;1?name=file"]
								.getService(Components.interfaces.nsIFileProtocolHandler);
				var uri = nsIFPH.newFileURI(file);
				
				var nsIEPS = Components.classes["@mozilla.org/uriloader/external-protocol-service;1"].
								getService(Components.interfaces.nsIExternalProtocolService);
				nsIEPS.loadUrl(uri);
			}
		}
	}
	
	
	/**
	 * Launch an HTTP URL externally, the best way we can
	 *
	 * Used only by Standalone
	 */
	this.launchURL = function (url) {
		if (!url.match(/^https?/)) {
			throw new Error("launchURL() requires an HTTP(S) URL");
		}
		
		try {
			var io = Components.classes['@mozilla.org/network/io-service;1']
						.getService(Components.interfaces.nsIIOService);
			var uri = io.newURI(url, null, null);
			var handler = Components.classes['@mozilla.org/uriloader/external-protocol-service;1']
							.getService(Components.interfaces.nsIExternalProtocolService)
							.getProtocolHandlerInfo('http');
			handler.preferredAction = Components.interfaces.nsIHandlerInfo.useSystemDefault;
			handler.launchWithURI(uri, null);
		}
		catch (e) {
			Zotero.debug("launchWithURI() not supported -- trying fallback executable");
			
			if (Zotero.isWin) {
				var pref = "fallbackLauncher.windows";
			}
			else {
				var pref = "fallbackLauncher.unix";
			}
			var path = Zotero.Prefs.get(pref);
			
			var exec = Components.classes["@mozilla.org/file/local;1"]
						.createInstance(Components.interfaces.nsILocalFile);
			exec.initWithPath(path);
			if (!exec.exists()) {
				throw ("Fallback executable not found -- check extensions.zotero." + pref + " in about:config");
			}
			
			var proc = Components.classes["@mozilla.org/process/util;1"]
							.createInstance(Components.interfaces.nsIProcess);
			proc.init(exec);
			
			var args = [url];
			proc.runw(false, args, args.length);
		}
	}
	
	
	/**
	 * Opens a URL in the basic viewer, and optionally run a callback on load
	 *
	 * @param {String} uri
	 * @param {Function} [onLoad] - Function to run once URI is loaded; passed the loaded document
	 */
	this.openInViewer = function (uri, onLoad) {
		var wm = Services.wm;
		var win = wm.getMostRecentWindow("zotero:basicViewer");
		if (win) {
			win.loadURI(uri);
		} else {
			let ww = Components.classes['@mozilla.org/embedcomp/window-watcher;1']
				.getService(Components.interfaces.nsIWindowWatcher);
			let arg = Components.classes["@mozilla.org/supports-string;1"]
				.createInstance(Components.interfaces.nsISupportsString);
			arg.data = uri;
			win = ww.openWindow(null, "chrome://zotero/content/standalone/basicViewer.xul",
				"basicViewer", "chrome,dialog=yes,resizable,centerscreen,menubar,scrollbars", arg);
		}
		if (onLoad) {
			let browser
			let func = function () {
				win.removeEventListener("load", func);
				browser = win.document.documentElement.getElementsByTagName('browser')[0];
				browser.addEventListener("pageshow", innerFunc);
			};
			let innerFunc = function () {
				browser.removeEventListener("pageshow", innerFunc);
				onLoad(browser.contentDocument);
			};
			win.addEventListener("load", func);
		}
	};
	
	
	/*
	 * Debug logging function
	 *
	 * Uses prefs e.z.debug.log and e.z.debug.level (restart required)
	 *
	 * @param {} message
	 * @param {Integer} [level=3]
	 * @param {Integer} [maxDepth]
	 * @param {Boolean|Integer} [stack] Whether to display the calling stack.
	 *   If true, stack is displayed starting from the caller. If an integer,
	 *   that many stack levels will be omitted starting from the caller.
	 */
	function debug(message, level, maxDepth, stack) {
		// Account for this alias
		if (stack === true) {
			stack = 1;
		} else if (stack >= 0) {
			stack++;
		}
		
		Zotero.Debug.log(message, level, maxDepth, stack);
	}
	
	
	/*
	 * Log a message to the Mozilla JS error console
	 *
	 * |type| is a string with one of the flag types in nsIScriptError:
	 *    'error', 'warning', 'exception', 'strict'
	 */
	function log(message, type, sourceName, sourceLine, lineNumber, columnNumber) {
		var scriptError = Components.classes["@mozilla.org/scripterror;1"]
			.createInstance(Components.interfaces.nsIScriptError);
		
		if (!type) {
			type = 'warning';
		}
		var flags = scriptError[type + 'Flag'];
		
		scriptError.init(
			message,
			sourceName ? sourceName : null,
			sourceLine != undefined ? sourceLine : null,
			lineNumber != undefined ? lineNumber : null, 
			columnNumber != undefined ? columnNumber : null,
			flags,
			'component javascript'
		);
		Services.console.logMessage(scriptError);
	}
	
	/**
	 * Log a JS error to the Mozilla error console and debug output
	 * @param {Exception} err
	 */
	function logError(err) {
		Zotero.debug(err, 1);
		log(err.message ? err.message : err.toString(), "error",
			err.fileName ? err.fileName : (err.filename ? err.filename : null), null,
			err.lineNumber ? err.lineNumber : null, null);
	}
	
	
	this.warn = function (err) {
		Zotero.debug(err, 2);
		log(err.message ? err.message : err.toString(), "warning",
			err.fileName ? err.fileName : (err.filename ? err.filename : null), null,
			err.lineNumber ? err.lineNumber : null, null);
	}
	
	
	/**
	 * Display an alert in a given window
	 *
	 * @param {Window}
	 * @param {String} title
	 * @param {String} msg
	 */
	this.alert = function (window, title, msg) {
		this.debug(`Alert:\n\n${msg}`);
		var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"]
			.getService(Components.interfaces.nsIPromptService);
		ps.alert(window, title, msg);
	}
	
	
	this.getErrors = function (asStrings) {
		var errors = [];
		
		for (let msg of _startupErrors.concat(_recentErrors)) {
			let altMessage;
			// Remove password in malformed XML errors
			if (msg.category == 'malformed-xml') {
				try {
					// msg.message is read-only, so store separately
					altMessage = msg.message.replace(/(https?:\/\/[^:]+:)([^@]+)(@[^"]+)/, "$1****$3");
				}
				catch (e) {}
			}
			
			if (asStrings) {
				errors.push(altMessage || msg.message)
			}
			else {
				errors.push(msg);
			}
		}
		return errors;
	}
	
	
	/**
	 * Get versions, platform, etc.
	 */
	this.getSystemInfo = Zotero.Promise.coroutine(function* () {
		var info = {
			version: Zotero.version,
			platform: Zotero.platform,
			oscpu: Zotero.oscpu,
			locale: Zotero.locale,
			appName: Services.appinfo.name,
			appVersion: Services.appinfo.version
		};
		
		var extensions = yield Zotero.getInstalledExtensions();
		info.extensions = extensions.join(', ');
		
		var str = '';
		for (var key in info) {
			str += key + ' => ' + info[key] + ', ';
		}
		str = str.substr(0, str.length - 2);
		return str;
	});
	
	
	/**
	 * @return {Promise<String[]>} - Promise for an array of extension names and versions
	 */
	this.getInstalledExtensions = Zotero.Promise.method(function () {
		var deferred = Zotero.Promise.defer();
		function onHaveInstalledAddons(installed) {
			installed.sort(function(a, b) {
				return ((a.appDisabled || a.userDisabled) ? 1 : 0) -
					((b.appDisabled || b.userDisabled) ? 1 : 0);
			});
			var addons = [];
			for (let addon of installed) {
				switch (addon.id) {
					case "zotero@chnm.gmu.edu":
					case "{972ce4c6-7e08-4474-a285-3208198ce6fd}": // Default theme
						continue;
				}
				
				addons.push(addon.name + " (" + addon.version
					+ (addon.type != 2 ? ", " + addon.type : "")
					+ ((addon.appDisabled || addon.userDisabled) ? ", disabled" : "")
					+ ")");
			}
			deferred.resolve(addons);
		}
		
		Components.utils.import("resource://gre/modules/AddonManager.jsm");
		AddonManager.getAllAddons(onHaveInstalledAddons);
		return deferred.promise;
	});
	
	/**
	 * @param {String} name
	 * @param {String[]} [params=[]] - Strings to substitute for placeholders
	 * @param {Number} [num] - Number (also appearing in `params`) to use when determining which plural
	 *     form of the string to use; localized strings should include all forms in the order specified
	 *     in https://developer.mozilla.org/en-US/docs/Mozilla/Localization/Localization_and_Plurals,
	 *     separated by semicolons
	 */
	this.getString = function (name, params, num) {
		try {
			if (params != undefined) {
				if (typeof params != 'object'){
					params = [params];
				}
				var l10n = _localizedStringBundle.formatStringFromName(name, params, params.length);
			}
			else {
				var l10n = _localizedStringBundle.GetStringFromName(name);
			}
			if (num !== undefined) {
				let availableForms = l10n.split(/;/);
				// If not enough available forms, use last one -- PluralForm.get() uses first by
				// default, but it's more likely that a localizer will translate the two English
				// strings with some plural form as the second one, so we might as well use that
				if (availableForms.length < this.pluralFormNumForms()) {
					l10n = availableForms[availableForms.length - 1];
				}
				else {
					l10n = this.pluralFormGet(num, l10n);
				}
			}
		}
		catch (e){
			if (e.name == 'NS_ERROR_ILLEGAL_VALUE') {
				Zotero.debug(params, 1);
			}
			else if (e.name != 'NS_ERROR_FAILURE') {
				Components.utils.reportError(e);
				Zotero.debug(e, 1);
			}
			throw ('Localized string not available for ' + name);
		}
		return l10n;
	}
	
	
	/**
	 * Defines property on the object
	 * More compact way to do Object.defineProperty
	 *
	 * @param {Object} obj Target object
	 * @param {String} prop Property to be defined
	 * @param {Object} desc Propery descriptor. If not overriden, "enumerable" is true
	 * @param {Object} opts Options:
	 *   lazy {Boolean} If true, the _getter_ is intended for late
	 *     initialization of the property. The getter is replaced with a simple
	 *     property once initialized.
	 */
	this.defineProperty = function(obj, prop, desc, opts) {
		if (typeof prop != 'string') throw new Error("Property must be a string");
		var d = { __proto__: null, enumerable: true, configurable: true }; // Enumerable by default
		for (let p in desc) {
			if (!desc.hasOwnProperty(p)) continue;
			d[p] = desc[p];
		}
		
		if (opts) {
			if (opts.lazy && d.get) {
				let getter = d.get;
				d.configurable = true; // Make sure we can change the property later
				d.get = function() {
					let val = getter.call(this);
					
					// Redefine getter on this object as non-writable value
					delete d.set;
					delete d.get;
					d.writable = false;
					d.value = val;
					Object.defineProperty(this, prop, d);
					
					return val;
				}
			}
		}
		
		Object.defineProperty(obj, prop, d);
	}
	
	this.extendClass = function(superClass, newClass) {
		newClass._super = superClass;
		newClass.prototype = Object.create(superClass.prototype);
		newClass.prototype.constructor = newClass;
	}
	
	
	/*
	 * This function should be removed
	 *
	 * |separator| defaults to a space (not a comma like Array.join()) if
	 *   not specified
	 *
	 * TODO: Substitute localized characters (e.g. Arabic comma and semicolon)
	 */
	function localeJoin(arr, separator) {
		if (typeof separator == 'undefined') {
			separator = ' ';
		}
		return arr.join(separator);
	}
	
	
	this.getLocaleCollation = function () {
		if (this.collation) {
			return this.collation;
		}
		
		try {
			// DEBUG: Is this necessary, or will Intl.Collator just default to the same locales we're
			// passing manually?
			
			let locales;
			// Fx55+
			if (Services.locale.getAppLocalesAsBCP47) {
				locales = Services.locale.getAppLocalesAsBCP47();
			}
			else {
				let locale;
				// Fx54
				if (Services.locale.getAppLocale) {
					locale = Services.locale.getAppLocale();
				}
				// Fx <=53
				else {
					locale = Services.locale.getApplicationLocale();
					locale = locale.getCategory('NSILOCALE_COLLATE');
				}
				
				// Extract a valid language tag
				locale = locale.match(/^[a-z]{2}(\-[A-Z]{2})?/)[0];
				locales = [locale];
			}
			
			var collator = new Intl.Collator(locales, {
				ignorePunctuation: true,
				numeric: true,
				sensitivity: 'base'
			});
		}
		catch (e) {
			Zotero.logError(e);
			
			// If there's an error, just skip sorting
			collator = {
				compare: function (a, b) {
					return 0;
				}
			};
		}
		
		// Grab all ASCII punctuation and space at the begining of string
		var initPunctuationRE = /^[\x20-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]+/;
		// Punctuation that should be ignored when sorting
		var ignoreInitRE = /["'[{(]+$/;
		
		// Until old code is updated, pretend we're returning an nsICollation
		return this.collation = {
			compareString: function (_, a, b) {
				if (!a && !b) return 0;
				if (!a || !b) return b ? -1 : 1;
				
				// Compare initial punctuation
				var aInitP = initPunctuationRE.exec(a) || '';
				var bInitP = initPunctuationRE.exec(b) || '';
				
				var aWordStart = 0, bWordStart = 0;
				if (aInitP) {
					aWordStart = aInitP[0].length;
					aInitP = aInitP[0].replace(ignoreInitRE, '');
				}
				if (bInitP) {
					bWordStart = bInitP.length;
					bInitP = bInitP[0].replace(ignoreInitRE, '');
				}
				
				// If initial punctuation is equivalent, use collator comparison
				// that ignores all punctuation
				if (aInitP == bInitP || !aInitP && !bInitP) return collator.compare(a, b);
				
				// Otherwise consider "attached" words as well, e.g. the order should be
				// "__ n", "__z", "_a"
				// We don't actually care what the attached word is, just whether it's
				// there, since at this point we're guaranteed to have non-equivalent
				// initial punctuation
				if (aWordStart < a.length) aInitP += 'a';
				if (bWordStart < b.length) bInitP += 'a';
				
				return aInitP.localeCompare(bInitP);
			}
		};
	}
	
	this.defineProperty(this, "localeCompare", {
		get: function() {
			var collation = this.getLocaleCollation();
			return collation.compareString.bind(collation, 1);
		}
	}, {lazy: true});
	
	/*
	 * Sets font size based on prefs -- intended for use on root element
	 *  (zotero-pane, note window, etc.)
	 */
	function setFontSize(rootElement) {
		var size = Zotero.Prefs.get('fontSize');
		rootElement.style.fontSize = size + 'em';
		if (size <= 1) {
			size = 'small';
		}
		else if (size <= 1.25) {
			size = 'medium';
		}
		else {
			size = 'large';
		}
		// Custom attribute -- allows for additional customizations in zotero.css
		rootElement.setAttribute('zoteroFontSize', size);
	}
	
	
	/*
	 * Flattens mixed arrays/values in a passed _arguments_ object and returns
	 * an array of values -- allows for functions to accept both arrays of
	 * values and/or an arbitrary number of individual values
	 */
	function flattenArguments(args){
		// Put passed scalar values into an array
		if (args === null || typeof args == 'string' || typeof args.length == 'undefined') {
			args = [args];
		}
		
		var returns = [];
		for (var i=0; i<args.length; i++){
			var arg = args[i];
			if (!arg && arg !== 0) {
				continue;
			}
			if (Array.isArray(arg)) {
				returns.push(...arg);
			}
			else {
				returns.push(arg);
			}
		}
		return returns;
	}
	
	
	function getAncestorByTagName(elem, tagName){
		while (elem.parentNode){
			elem = elem.parentNode;
			if (elem.localName == tagName) {
				return elem;
			}
		}
		return false;
	}
	
	
	/**
	* Generate a random string of length 'len' (defaults to 8)
	**/
	function randomString(len, chars) {
		return Zotero.Utilities.randomString(len, chars);
	}
	
	
	function moveToUnique(file, newFile){
		newFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0o644);
		var newName = newFile.leafName;
		newFile.remove(null);
		
		// Move file to unique name
		file.moveTo(newFile.parent, newName);
		return file;
	}
	
	
	/**
	 * Generate a function that produces a static output
	 *
	 * Zotero.lazy(fn) returns a function. The first time this function
	 * is called, it calls fn() and returns its output. Subsequent
	 * calls return the same output as the first without calling fn()
	 * again.
	 */
	this.lazy = function(fn) {
		var x, called = false;
		return function() {
			if(!called) {
				x = fn.apply(this);
				called = true;
			}
			return x;
		};
	};
	
	
	this.serial = function (fn) {
		Components.utils.import("resource://zotero/concurrentCaller.js");
		var caller = new ConcurrentCaller({
			numConcurrent: 1,
			onError: e => Zotero.logError(e)
		});
		return function () {
			var args = arguments;
			return caller.start(function () {
				return fn.apply(this, args);
			}.bind(this));
		};
	}
	
	
	this.spawn = function (generator, thisObject) {
		if (thisObject) {
			return Zotero.Promise.coroutine(generator.bind(thisObject))();
		}
		return Zotero.Promise.coroutine(generator)();
	}
	
	
	/**
	 * Emulates the behavior of window.setTimeout
	 *
	 * @param {Function} func			The function to be called
	 * @param {Integer} ms				The number of milliseconds to wait before calling func
	 * @return {Integer} - ID of timer to be passed to clearTimeout()
	 */
	var _lastTimeoutID = 0;
	this.setTimeout = function (func, ms) {
		var id = ++_lastTimeoutID;
		
		var timer = Components.classes["@mozilla.org/timer;1"]
			.createInstance(Components.interfaces.nsITimer);
		var timerCallback = {
			"notify": function () {
				func();
				_runningTimers.delete(id);
			}
		};
		timer.initWithCallback(timerCallback, ms, Components.interfaces.nsITimer.TYPE_ONE_SHOT);
		_runningTimers.set(id, timer);
		return id;
	};
	
	
	this.clearTimeout = function (id) {
		var timer = _runningTimers.get(id);
		if (timer) {
			timer.cancel();
			_runningTimers.delete(id);
		}
	};
	
	
	/**
	 * Show Zotero pane overlay and progress bar in all windows
	 *
	 * @param {String} msg
	 * @param {Boolean} [determinate=false]
	 * @param {Boolean} [modalOnly=false] - Don't use popup if Zotero pane isn't showing
	 * @return	void
	 */
	this.showZoteroPaneProgressMeter = function (msg, determinate, icon, modalOnly) {
		// If msg is undefined, keep any existing message. If false/null/"", clear.
		// The message is also cleared when the meters are hidden.
		_progressMessage = msg = (msg === undefined ? _progressMessage : msg) || "";
		var currentWindow = Services.wm.getMostRecentWindow("navigator:browser");
		var enumerator = Services.wm.getEnumerator("navigator:browser");
		var progressMeters = [];
		while (enumerator.hasMoreElements()) {
			var win = enumerator.getNext();
			if(!win.ZoteroPane) continue;
			if (!win.ZoteroPane.isShowing() && !modalOnly) {
				if (win != currentWindow) {
					continue;
				}
				
				// If Zotero is closed in the top-most window, show a popup instead
				_progressPopup = new Zotero.ProgressWindow();
				_progressPopup.changeHeadline("Zotero");
				if (icon) {
					_progressPopup.addLines([msg], [icon]);
				}
				else {
					_progressPopup.addDescription(msg);
				}
				_progressPopup.show();
				continue;
			}
			
			var label = win.ZoteroPane.document.getElementById('zotero-pane-progress-label');
			if (!label) {
				Components.utils.reportError("label not found in " + win.document.location.href);
			}
			if (msg) {
				label.hidden = false;
				label.value = msg;
			}
			else {
				label.hidden = true;
			}
			var progressMeter = win.ZoteroPane.document.getElementById('zotero-pane-progressmeter')
			if (determinate) {
				progressMeter.mode = 'determined';
				progressMeter.value = 0;
				progressMeter.max = 1000;
			}
			else {
				progressMeter.mode = 'undetermined';
			}
			
			_showWindowZoteroPaneOverlay(win.ZoteroPane.document);
			win.ZoteroPane.document.getElementById('zotero-pane-overlay-deck').selectedIndex = 0;
			
			progressMeters.push(progressMeter);
		}
		this.locked = true;
		_progressMeters = progressMeters;
	}
	
	
	/**
	 * @param	{Number}	percentage		Percentage complete as integer or float
	 */
	this.updateZoteroPaneProgressMeter = function (percentage) {
		if(percentage !== null) {
			if (percentage < 0 || percentage > 100) {
				Zotero.debug("Invalid percentage value '" + percentage + "' in Zotero.updateZoteroPaneProgressMeter()");
				return;
			}
			percentage = Math.round(percentage * 10);
		}
		if (percentage === _lastPercentage) {
			return;
		}
		for (let pm of _progressMeters) {
			if (percentage !== null) {
				if (pm.mode == 'undetermined') {
					pm.max = 1000;
					pm.mode = 'determined';
				}
				pm.value = percentage;
			} else if(pm.mode === 'determined') {
				pm.mode = 'undetermined';
			}
		}
		_lastPercentage = percentage;
	}
	
	
	/**
	 * Hide Zotero pane overlay in all windows
	 */
	this.hideZoteroPaneOverlays = function () {
		this.locked = false;
		
		var enumerator = Services.wm.getEnumerator("navigator:browser");
		while (enumerator.hasMoreElements()) {
			var win = enumerator.getNext();
			if(win.ZoteroPane && win.ZoteroPane.document) {
				_hideWindowZoteroPaneOverlay(win.ZoteroPane.document);
			}
		}
		
		if (_progressPopup) {
			_progressPopup.close();
		}
		
		_progressMessage = null;
		_progressMeters = [];
		_progressPopup = null;
		_lastPercentage = null;
	}
	
	
	/**
	 * Adds a listener to be called when Zotero shuts down (even if Firefox is not shut down)
	 */
	this.addShutdownListener = function(listener) {
		_shutdownListeners.push(listener);
	}
	
	function _showWindowZoteroPaneOverlay(doc) {
		doc.getElementById('zotero-collections-tree').disabled = true;
		doc.getElementById('zotero-items-tree').disabled = true;
		doc.getElementById('zotero-pane-tab-catcher-top').hidden = false;
		doc.getElementById('zotero-pane-tab-catcher-bottom').hidden = false;
		doc.getElementById('zotero-pane-overlay').hidden = false;
	}
	
	
	function _hideWindowZoteroPaneOverlay(doc) {
		doc.getElementById('zotero-collections-tree').disabled = false;
		doc.getElementById('zotero-items-tree').disabled = false;
		doc.getElementById('zotero-pane-tab-catcher-top').hidden = true;
		doc.getElementById('zotero-pane-tab-catcher-bottom').hidden = true;
		doc.getElementById('zotero-pane-overlay').hidden = true;
	}
	
	
	this.updateQuickSearchBox = function (document) {
		var searchBox = document.getElementById('zotero-tb-search');
		if(!searchBox) return;
		
		var mode = Zotero.Prefs.get("search.quicksearch-mode");
		var prefix = 'zotero-tb-search-mode-';
		var prefixLen = prefix.length;
		
		var modes = {
			titleCreatorYear: {
				label: Zotero.getString('quickSearch.mode.titleCreatorYear')
			},
			
			fields: {
				label: Zotero.getString('quickSearch.mode.fieldsAndTags')
			},
			
			everything: {
				label: Zotero.getString('quickSearch.mode.everything')
			}
		};
		
		if (!modes[mode]) {
			Zotero.Prefs.set("search.quicksearch-mode", "fields");
			mode = 'fields';
		}
		
		var hbox = document.getAnonymousNodes(searchBox)[0];
		var input = hbox.getElementsByAttribute('class', 'textbox-input')[0];
		
		// Already initialized, so just update selection
		var button = hbox.getElementsByAttribute('id', 'zotero-tb-search-menu-button');
		if (button.length) {
			button = button[0];
			var menupopup = button.firstChild;
			for (let menuitem of menupopup.childNodes) {
				if (menuitem.id.substr(prefixLen) == mode) {
					menuitem.setAttribute('checked', true);
					searchBox.placeholder = modes[mode].label;
					return;
				}
			}
			return;
		}
		
		// Otherwise, build menu
		button = document.createElement('button');
		button.id = 'zotero-tb-search-menu-button';
		button.setAttribute('type', 'menu');
		
		var menupopup = document.createElement('menupopup');
		
		for (var i in modes) {
			var menuitem = document.createElement('menuitem');
			menuitem.setAttribute('id', prefix + i);
			menuitem.setAttribute('label', modes[i].label);
			menuitem.setAttribute('name', 'searchMode');
			menuitem.setAttribute('type', 'radio');
			//menuitem.setAttribute("tooltiptext", "");
			
			menupopup.appendChild(menuitem);
			
			if (mode == i) {
				menuitem.setAttribute('checked', true);
				menupopup.selectedItem = menuitem;
			}
		}
		
		menupopup.addEventListener("command", function(event) {
			var mode = event.target.id.substr(22);
			Zotero.Prefs.set("search.quicksearch-mode", mode);
			if (document.getElementById("zotero-tb-search").value == "") {
				event.stopPropagation();
			}
		}, false);
		
		button.appendChild(menupopup);
		hbox.insertBefore(button, input);
		
		searchBox.placeholder = modes[mode].label;
		
		// If Alt-Up/Down, show popup
		searchBox.addEventListener("keypress", function(event) {
			if (event.altKey && (event.keyCode == event.DOM_VK_UP || event.keyCode == event.DOM_VK_DOWN)) {
				document.getElementById('zotero-tb-search-menu-button').open = true;
				event.stopPropagation();
			}
		}, false);
	}
	
	
	/*
	 * Clear entries that no longer exist from various tables
	 */
	this.purgeDataObjects = Zotero.Promise.coroutine(function* () {
		yield Zotero.DB.executeTransaction(function* () {
			return Zotero.Creators.purge();
		});
		yield Zotero.DB.executeTransaction(function* () {
			return Zotero.Tags.purge();
		});
		yield Zotero.Fulltext.purgeUnusedWords();
		yield Zotero.DB.executeTransaction(function* () {
			return Zotero.Items.purge();
		});
		// DEBUG: this might not need to be permanent
		//yield Zotero.DB.executeTransaction(function* () {
		//	return Zotero.Relations.purge();
		//});
	});
	
	
	this.reloadDataObjects = function () {
		return Zotero.Promise.all([
			Zotero.Collections.reloadAll(),
			Zotero.Creators.reloadAll(),
			Zotero.Items.reloadAll()
		]);
	}
	
	
	/**
	 * Brings Zotero Standalone to the foreground
	 */
	this.activateStandalone = function() {
		var uri = Services.io.newURI('zotero://select', null, null);
		var handler = Components.classes['@mozilla.org/uriloader/external-protocol-service;1']
					.getService(Components.interfaces.nsIExternalProtocolService)
					.getProtocolHandlerInfo('zotero');
		handler.preferredAction = Components.interfaces.nsIHandlerInfo.useSystemDefault;
		handler.launchWithURI(uri, null);
	}
	
	/**
	 * Determines whether to keep an error message so that it can (potentially) be reported later
	 */
	function _shouldKeepError(msg) {
		const skip = ['CSS Parser', 'content javascript'];
		
		//Zotero.debug(msg);
		try {
			msg.QueryInterface(Components.interfaces.nsIScriptError);
			//Zotero.debug(msg);
			if (skip.indexOf(msg.category) != -1 || msg.flags & msg.warningFlag) {
				return false;
			}
		}
		catch (e) { }
		
		const blacklist = [
			"No chrome package registered for chrome://communicator",
			'[JavaScript Error: "Components is not defined" {file: "chrome://nightly/content/talkback/talkback.js',
			'[JavaScript Error: "document.getElementById("sanitizeItem")',
			'No chrome package registered for chrome://piggy-bank',
			'[JavaScript Error: "[Exception... "\'Component is not available\' when calling method: [nsIHandlerService::getTypeFromExtension',
			'[JavaScript Error: "this._uiElement is null',
			'Error: a._updateVisibleText is not a function',
			'[JavaScript Error: "Warning: unrecognized command line flag ',
			'LibX:',
			'function skype_',
			'[JavaScript Error: "uncaught exception: Permission denied to call method Location.toString"]',
			'CVE-2009-3555',
			'OpenGL',
			'trying to re-register CID',
			'Services.HealthReport',
			'[JavaScript Error: "this.docShell is null"',
			'[JavaScript Error: "downloadable font:',
			'[JavaScript Error: "Image corrupt or truncated:',
			'[JavaScript Error: "The character encoding of the',
			'nsLivemarkService.js',
			'Sync.Engine.Tabs',
			'content-sessionStore.js',
			'org.mozilla.appSessions',
			'bad script XDR magic number'
		];
		
		for (var i=0; i<blacklist.length; i++) {
			if (msg.message.indexOf(blacklist[i]) != -1) {
				//Zotero.debug("Skipping blacklisted error: " + msg.message);
				return false;
			}
		}
		
		return true;
	}

	/**
	 * Warn if Zotero Standalone is running as root and clobber the cache directory if it is
	 */
	function _checkRoot() {
		var env = Components.classes["@mozilla.org/process/environment;1"].
			getService(Components.interfaces.nsIEnvironment);
		var user = env.get("USER") || env.get("USERNAME");
		if(user === "root") {
			// Show warning
			if(Services.prompt.confirmEx(null, "", Zotero.getString("standalone.rootWarning"),
					Services.prompt.BUTTON_POS_0*Services.prompt.BUTTON_TITLE_IS_STRING |
					Services.prompt.BUTTON_POS_1*Services.prompt.BUTTON_TITLE_IS_STRING,
					Zotero.getString("standalone.rootWarning.exit"),
					Zotero.getString("standalone.rootWarning.continue"),
					null, null, {}) == 0) {
				Components.utils.import("resource://gre/modules/ctypes.jsm");
				var exit = Zotero.IPC.getLibc().declare("exit", ctypes.default_abi,
					                                    ctypes.void_t, ctypes.int);
				// Zap cache files
				try {
					Services.dirsvc.get("ProfLD", Components.interfaces.nsIFile).remove(true);
				} catch(e) {}
				// Exit Zotero without giving XULRunner the opportunity to figure out the
				// cache is missing. Otherwise XULRunner will zap the prefs
				exit(0);
			}
		}
	}
	
	/**
	 * Observer for console messages
	 * @namespace
	 */
	var ConsoleListener = {
		"QueryInterface":XPCOMUtils.generateQI([Components.interfaces.nsIConsoleMessage,
			Components.interfaces.nsISupports]),
		"observe":function(msg) {
			if(!_shouldKeepError(msg)) return;
			if(_recentErrors.length === ERROR_BUFFER_SIZE) _recentErrors.shift();
			_recentErrors.push(msg);
		}
	};
}).call(Zotero);

Zotero.Prefs = new function(){
	// Privileged methods
	this.init = init;
	this.get = get;
	this.set = set;
	
	this.register = register;
	this.unregister = unregister;
	this.observe = observe;
	
	// Public properties
	this.prefBranch;
	
	function init(){
		this.prefBranch = Services.prefs.getBranch(ZOTERO_CONFIG.PREF_BRANCH);
		
		// Register observer to handle pref changes
		this.register();

		// Unregister observer handling pref changes
		if (Zotero.addShutdownListener) {
			Zotero.addShutdownListener(this.unregister.bind(this));
		}

		// Process pref version updates
		var fromVersion = this.get('prefVersion');
		if (!fromVersion) {
			fromVersion = 0;
		}
		var toVersion = 2;
		if (fromVersion < toVersion) {
			for (var i = fromVersion + 1; i <= toVersion; i++) {
				switch (i) {
					case 1:
						// If a sync username is entered and ZFS is enabled, turn
						// on-demand downloading off to maintain current behavior
						if (this.get('sync.server.username')) {
							if (this.get('sync.storage.enabled')
									&& this.get('sync.storage.protocol') == 'zotero') {
								this.set('sync.storage.downloadMode.personal', 'on-sync');
							}
							if (this.get('sync.storage.groups.enabled')) {
								this.set('sync.storage.downloadMode.groups', 'on-sync');
							}
						}
						break;
					
					case 2:
						// Re-show saveButton guidance panel (and clear old saveIcon pref).
						// The saveButton guidance panel initially could auto-hide too easily.
						this.clear('firstRunGuidanceShown.saveIcon');
						this.clear('firstRunGuidanceShown.saveButton');
						break;
				}
			}
			this.set('prefVersion', toVersion);
		}
	}
	
	
	/**
	* Retrieve a preference
	**/
	function get(pref, global){
		try {
			if (global) {
				var branch = Services.prefs.getBranch("");
			}
			else {
				var branch = this.prefBranch;
			}
			
			switch (branch.getPrefType(pref)){
				case branch.PREF_BOOL:
					return branch.getBoolPref(pref);
				case branch.PREF_STRING:
					return '' + branch.getComplexValue(pref, Components.interfaces.nsISupportsString);
				case branch.PREF_INT:
					return branch.getIntPref(pref);
			}
		}
		catch (e){
			throw ("Invalid preference '" + pref + "'");
		}
	}
	
	
	/**
	* Set a preference
	**/
	function set(pref, value, global) {
		try {
			if (global) {
				var branch = Services.prefs.getBranch("");
			}
			else {
				var branch = this.prefBranch;
			}
			
			switch (branch.getPrefType(pref)) {
				case branch.PREF_BOOL:
					return branch.setBoolPref(pref, value);
				case branch.PREF_STRING:
					let str = Cc["@mozilla.org/supports-string;1"]
						.createInstance(Ci.nsISupportsString);
					str.data = value;
					return branch.setComplexValue(pref, Ci.nsISupportsString, str);
				case branch.PREF_INT:
					return branch.setIntPref(pref, value);
				
				// If not an existing pref, create appropriate type automatically
				case 0:
					if (typeof value == 'boolean') {
						Zotero.debug("Creating boolean pref '" + pref + "'");
						return branch.setBoolPref(pref, value);
					}
					if (typeof value == 'string') {
						Zotero.debug("Creating string pref '" + pref + "'");
						return branch.setCharPref(pref, value);
					}
					if (parseInt(value) == value) {
						Zotero.debug("Creating integer pref '" + pref + "'");
						return branch.setIntPref(pref, value);
					}
					throw new Error("Invalid preference value '" + value + "' for pref '" + pref + "'");
			}
		}
		catch (e) {
			Zotero.logError(e);
			throw new Error("Invalid preference '" + pref + "'");
		}
	}
	
	
	this.clear = function (pref, global) {
		if (global) {
			var branch = Services.prefs.getBranch("");
		}
		else {
			var branch = this.prefBranch;
		}
		branch.clearUserPref(pref);
	}
	
	
	this.resetBranch = function (exclude = []) {
		var keys = this.prefBranch.getChildList("", {});
		for (let key of keys) {
			if (this.prefBranch.prefHasUserValue(key)) {
				if (exclude.includes(key)) {
					continue;
				}
				Zotero.debug("Clearing " + key);
				this.prefBranch.clearUserPref(key);
			}
		}
	};
	
	
	// Import settings bundles
	this.importSettings = function (str, uri) {
		var ps = Services.prompt;
		
		if (!uri.match(/https:\/\/([^\.]+\.)?zotero.org\//)) {
			Zotero.debug("Ignoring settings file not from https://zotero.org");
			return;
		}
		
		str = Zotero.Utilities.trim(str.replace(/<\?xml.*\?>\s*/, ''));
		Zotero.debug(str);
		
		var confirm = ps.confirm(
			null,
			"",
			"Apply settings from zotero.org?"
		);
		
		if (!confirm) {
			return;
		}
		
		// TODO: parse settings XML
	}
	
	// Handlers for some Zotero preferences
	var _handlers = [
		[ "automaticScraperUpdates", function(val) {
			if (val){
				Zotero.Schema.updateFromRepository(1);
			}
			else {
				Zotero.Schema.stopRepositoryTimer();
			}
		}],
		["fontSize", function (val) {
			Zotero.setFontSize(
				Zotero.getActiveZoteroPane().document.getElementById('zotero-pane')
			);
		}],
		[ "layout", function(val) {
			Zotero.getActiveZoteroPane().updateLayout();
		}],
		[ "note.fontSize", function(val) {
			if (val < 6) {
				Zotero.Prefs.set('note.fontSize', 11);
			}
		}],
		[ "zoteroDotOrgVersionHeader", function(val) {
			if (val) {
				Zotero.VersionHeader.register();
			}
			else {
				Zotero.VersionHeader.unregister();
			}
		}],
		[ "sync.autoSync", function(val) {
			if (val) {
				Zotero.Sync.EventListeners.AutoSyncListener.register();
				Zotero.Sync.EventListeners.IdleListener.register();
			}
			else {
				Zotero.Sync.EventListeners.AutoSyncListener.unregister();
				Zotero.Sync.EventListeners.IdleListener.unregister();
			}
		}],
		[ "search.quicksearch-mode", function(val) {
			var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
						.getService(Components.interfaces.nsIWindowMediator);
			var enumerator = wm.getEnumerator("navigator:browser");
			while (enumerator.hasMoreElements()) {
				var win = enumerator.getNext();
				if (!win.ZoteroPane) continue;
				Zotero.updateQuickSearchBox(win.ZoteroPane.document);
			}
			
			var enumerator = wm.getEnumerator("zotero:item-selector");
			while (enumerator.hasMoreElements()) {
				var win = enumerator.getNext();
				if (!win.Zotero) continue;
				Zotero.updateQuickSearchBox(win.document);
			}
		}]
	];
	
	//
	// Methods to register a preferences observer
	//
	function register(){
		this.prefBranch.QueryInterface(Components.interfaces.nsIPrefBranch2);
		this.prefBranch.addObserver("", this, false);
		
		// Register pre-set handlers
		for (var i=0; i<_handlers.length; i++) {
			this.registerObserver(_handlers[i][0], _handlers[i][1]);
		}
	}
	
	function unregister(){
		if (!this.prefBranch){
			return;
		}
		this.prefBranch.removeObserver("", this);
	}
	
	/**
	 * @param {nsIPrefBranch} subject The nsIPrefBranch we're observing (after appropriate QI)
	 * @param {String} topic The string defined by NS_PREFBRANCH_PREFCHANGE_TOPIC_ID
	 * @param {String} data The name of the pref that's been changed (relative to subject)
	 */
	function observe(subject, topic, data){
		if (topic != "nsPref:changed" || !_observers[data] || !_observers[data].length) {
			return;
		}
		
		var obs = _observers[data];
		for (var i=0; i<obs.length; i++) {
			try {
				obs[i](this.get(data));
			}
			catch (e) {
				Zotero.debug("Error while executing preference observer handler for " + data);
				Zotero.debug(e);
			}
		}
	}
	
	var _observers = {};
	this.registerObserver = function(name, handler) {
		_observers[name] = _observers[name] || [];
		_observers[name].push(handler);
	}
	
	this.unregisterObserver = function(name, handler) {
		var obs = _observers[name];
		if (!obs) {
			Zotero.debug("No preferences observer registered for " + name);
			return;
		}
		
		var i = obs.indexOf(handler);
		if (i == -1) {
			Zotero.debug("Handler was not registered for preference " + name);
			return;
		}
		
		obs.splice(i, 1);
	}
}


/*
 * Handles keyboard shortcut initialization from preferences, optionally
 * overriding existing global shortcuts
 *
 * Actions are configured in ZoteroPane.handleKeyPress()
 */
Zotero.Keys = new function() {
	this.init = init;
	this.windowInit = windowInit;
	this.getCommand = getCommand;
	
	var _keys = {};
	
	
	/*
	 * Called by Zotero.init()
	 */
	function init() {
		var cmds = Zotero.Prefs.prefBranch.getChildList('keys', {}, {});
		
		// Get the key=>command mappings from the prefs
		for (let cmd of cmds) {
			cmd = cmd.substr(5); // strips 'keys.'
			// Remove old pref
			if (cmd == 'overrideGlobal') {
				Zotero.Prefs.clear('keys.overrideGlobal');
				continue;
			}
			_keys[this.getKeyForCommand(cmd)] = cmd;
		}
	}
	
	
	/*
	 * Called by ZoteroPane.onLoad()
	 */
	function windowInit(document) {
		var globalKeys = [
			{
				name: 'openZotero',
				defaultKey: 'Z'
			},
			{
				name: 'saveToZotero',
				defaultKey: 'S'
			}
		];
		
		globalKeys.forEach(function (x) {
			let keyElem = document.getElementById('key_' + x.name);
			if (keyElem) {
				let prefKey = this.getKeyForCommand(x.name);
				// Only override the default with the pref if the <key> hasn't
				// been manually changed and the pref has been
				if (keyElem.getAttribute('key') == x.defaultKey
						&& keyElem.getAttribute('modifiers') == 'accel shift'
						&& prefKey != x.defaultKey) {
					keyElem.setAttribute('key', prefKey);
				}
			}
		}.bind(this));
	}
	
	
	function getCommand(key) {
		key = key.toUpperCase();
		return _keys[key] ? _keys[key] : false;
	}
	
	
	this.getKeyForCommand = function (cmd) {
		try {
			var key = Zotero.Prefs.get('keys.' + cmd);
		}
		catch (e) {}
		return key !== undefined ? key.toUpperCase() : false;
	}
}


/**
 * Add X-Zotero-Version header to HTTP requests to zotero.org
 *
 * @namespace
 */
Zotero.VersionHeader = {
	init: function () {
		if (Zotero.Prefs.get("zoteroDotOrgVersionHeader")) {
			this.register();
		}
		Zotero.addShutdownListener(this.unregister);
	},
	
	// Called from this.init() and Zotero.Prefs.observe()
	register: function () {
		Services.obs.addObserver(this, "http-on-modify-request", false);
	},
	
	observe: function (subject, topic, data) {
		try {
			var channel = subject.QueryInterface(Components.interfaces.nsIHttpChannel);
			if (channel.URI.host.match(/zotero\.org$/)) {
				channel.setRequestHeader("X-Zotero-Version", Zotero.version, false);
			}
		}
		catch (e) {
			Zotero.debug(e);
		}
	},
	
	unregister: function () {
		Services.obs.removeObserver(Zotero.VersionHeader, "http-on-modify-request");
	}
}

Zotero.DragDrop = {
	currentEvent: null,
	currentOrientation: 0,
	currentSourceNode: null,
	
	getDataFromDataTransfer: function (dataTransfer, firstOnly) {
		var dt = dataTransfer;
		
		var dragData = {
			dataType: '',
			data: [],
			dropEffect: dt.dropEffect
		};
		
		var len = firstOnly ? 1 : dt.mozItemCount;
		
		if (dt.types.contains('zotero/collection')) {
			dragData.dataType = 'zotero/collection';
			let ids = dt.getData('zotero/collection').split(",").map(id => parseInt(id));
			dragData.data = ids;
		}
		else if (dt.types.contains('zotero/item')) {
			dragData.dataType = 'zotero/item';
			let ids = dt.getData('zotero/item').split(",").map(id => parseInt(id));
			dragData.data = ids;
		}
		else {
			if (dt.types.contains('application/x-moz-file')) {
				dragData.dataType = 'application/x-moz-file';
				var files = [];
				for (var i=0; i<len; i++) {
					var file = dt.mozGetDataAt("application/x-moz-file", i);
					if (!file) {
						continue;
					}
					file.QueryInterface(Components.interfaces.nsIFile);
					// Don't allow folder drag
					if (file.isDirectory()) {
						continue;
					}
					files.push(file);
				}
				dragData.data = files;
			}
			// This isn't an else because on Linux a link drag contains an empty application/x-moz-file too
			if (!dragData.data || !dragData.data.length) {
				if (dt.types.contains('text/x-moz-url')) {
					dragData.dataType = 'text/x-moz-url';
					var urls = [];
					for (var i=0; i<len; i++) {
						var url = dt.getData("text/x-moz-url").split("\n")[0];
						urls.push(url);
					}
					dragData.data = urls;
				}
			}
		}
		
		return dragData;
	},
	
	
	getDragSource: function (dataTransfer) {
		if (!dataTransfer) {
			//Zotero.debug("Drag data not available", 2);
			return false;
		}
		
		// For items, the drag source is the CollectionTreeRow of the parent window
		// of the source tree
		if (dataTransfer.types.contains("zotero/item")) {
			let sourceNode = dataTransfer.mozSourceNode || this.currentSourceNode;
			if (!sourceNode || sourceNode.tagName != 'treechildren'
					|| sourceNode.parentElement.id != 'zotero-items-tree') {
				return false;
			}
			var win = sourceNode.ownerDocument.defaultView;
			if (win.document.documentElement.getAttribute('windowtype') == 'zotero:search') {
				return win.ZoteroAdvancedSearch.itemsView.collectionTreeRow;
			}
			return win.ZoteroPane.collectionsView.selectedTreeRow;
		}
		
		return false;
	},
	
	
	getDragTarget: function (event) {
		var target = event.target;
		if (target.tagName == 'treechildren') {
			var tree = target.parentNode;
			if (tree.id == 'zotero-collections-tree') {
				let row = {}, col = {}, obj = {};
				tree.treeBoxObject.getCellAt(event.clientX, event.clientY, row, col, obj);
				let win = tree.ownerDocument.defaultView;
				return win.ZoteroPane.collectionsView.getRow(row.value);
			}
		}
		return false;
	}
}


/**
 * Functions for creating and destroying hidden browser objects
 **/
Zotero.Browser = new function() {
	var nBrowsers = 0;
	
	this.createHiddenBrowser = createHiddenBrowser;
	this.deleteHiddenBrowser = deleteHiddenBrowser;
	
	function createHiddenBrowser(win) {
	 	if (!win) {
			var win = Services.wm.getMostRecentWindow("navigator:browser");
			if(!win) {
				var win = Services.ww.activeWindow;
			}
			if (!win) {
				throw new Error("Parent window not available for hidden browser");
			}
		}
		
		// Create a hidden browser
		var hiddenBrowser = win.document.createElement("browser");
		hiddenBrowser.setAttribute('type', 'content');
		hiddenBrowser.setAttribute('disablehistory', 'true');
		win.document.documentElement.appendChild(hiddenBrowser);
		// Disable some features
		hiddenBrowser.docShell.allowAuth = false;
		hiddenBrowser.docShell.allowDNSPrefetch = false;
		hiddenBrowser.docShell.allowImages = false;
		hiddenBrowser.docShell.allowJavascript = true;
		hiddenBrowser.docShell.allowMetaRedirects = false;
		hiddenBrowser.docShell.allowPlugins = false;
		Zotero.debug("Created hidden browser (" + (nBrowsers++) + ")");
		return hiddenBrowser;
	}
	
	function deleteHiddenBrowser(myBrowsers) {
		if(!(myBrowsers instanceof Array)) myBrowsers = [myBrowsers];
		for(var i=0; i<myBrowsers.length; i++) {
			var myBrowser = myBrowsers[i];
			myBrowser.stop();
			myBrowser.destroy();
			myBrowser.parentNode.removeChild(myBrowser);
			myBrowser = null;
			Zotero.debug("Deleted hidden browser (" + (--nBrowsers) + ")");
		}
	}
}


/*
 * Implements nsIWebProgressListener
 */
Zotero.WebProgressFinishListener = function(onFinish) {
	var _request;
	
	this.getRequest = function () {
		return _request;
	};
	
	this.onStateChange = function(wp, req, stateFlags, status) {
		//Zotero.debug('onStageChange: ' + stateFlags);
		if (stateFlags & Components.interfaces.nsIWebProgressListener.STATE_STOP
				&& stateFlags & Components.interfaces.nsIWebProgressListener.STATE_IS_NETWORK) {
			_request = null;
			onFinish();
		}
		else {
			_request = req;
		}
	}
	
	this.onProgressChange = function(wp, req, curSelfProgress, maxSelfProgress, curTotalProgress, maxTotalProgress) {
		//Zotero.debug('onProgressChange');
		//Zotero.debug('Current: ' + curTotalProgress);
		//Zotero.debug('Max: ' + maxTotalProgress);
	}
	
	this.onLocationChange = function(wp, req, location) {}
	this.onSecurityChange = function(wp, req, stateFlags, status) {}
	this.onStatusChange = function(wp, req, status, msg) {}
}

/*
 * Saves or loads JSON objects.
 */
Zotero.JSON = new function() {
	this.serialize = function(arg) {
		Zotero.debug("WARNING: Zotero.JSON.serialize() is deprecated; use JSON.stringify()");
		return JSON.stringify(arg);
	}
	
	this.unserialize = function(arg) {
		Zotero.debug("WARNING: Zotero.JSON.unserialize() is deprecated; use JSON.parse()");
		return JSON.parse(arg);
	}
}
