"use strict";
/*
    ***** 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 *****
*/

const DATA_VERSION = 3;

// Specifies that citations should only be updated if changed
const FORCE_CITATIONS_FALSE = 0;
// Specifies that citations should only be updated if formattedText has changed from what is encoded
// in the field code
const FORCE_CITATIONS_REGENERATE = 1;
// Specifies that citations should be reset regardless of whether formattedText has changed
const FORCE_CITATIONS_RESET_TEXT = 2;

// These must match the constants in corresponding word plugins 
const DIALOG_ICON_STOP = 0;
const DIALOG_ICON_WARNING = 1;
const DIALOG_ICON_CAUTION = 2;

const DIALOG_BUTTONS_OK = 0;
const DIALOG_BUTTONS_OK_CANCEL = 1;
const DIALOG_BUTTONS_YES_NO = 2;
const DIALOG_BUTTONS_YES_NO_CANCEL = 3;

const NOTE_FOOTNOTE = 1;
const NOTE_ENDNOTE = 2;

const INTEGRATION_TYPE_ITEM = 1;
const INTEGRATION_TYPE_BIBLIOGRAPHY = 2;
const INTEGRATION_TYPE_TEMP = 3;

const DELAY_CITATIONS_PROMPT_TIMEOUT = 15/*seconds*/;

const DELAYED_CITATION_RTF_STYLING = "\\uldash";
const DELAYED_CITATION_RTF_STYLING_CLEAR = "\\ulclear";

const DELAYED_CITATION_HTML_STYLING = "<div class='delayed-zotero-citation-updates'>"
const DELAYED_CITATION_HTML_STYLING_END = "</div>"

const EXPORTED_DOCUMENT_MARKER = "ZOTERO_TRANSFER_DOCUMENT";

const NOTE_CITATION_PLACEHOLDER_LINK = 'https://www.zotero.org/?';

const TEMPLATE_VERSIONS = {
	MacWord16: 2,
	WinWord: 1,
	OpenOffice: 1
};

const MENDELEY_URI_RE = /^http:\/\/www\.mendeley\.com\/documents\/\?uuid=(.*)/;

const PLUGIN_PATHS = {
	LibreOffice: 'chrome://zotero-libreoffice-integration-components/content/zoteroLibreOfficeIntegration.mjs',
	WinWord: 'chrome://zotero-winword-integration/content/zoteroWinWordIntegration.mjs',
	MacWord: 'chrome://zotero-macword-integration/content/zoteroMacWordIntegration.mjs'
};


Zotero.Integration = new function () {
	// TODO: fx140: Does this cause a side effect that we need?
	// AddonManager is not used in this file
	ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs");
	
	this.currentWindow = false;
	this.currentCommandPromise = Promise.resolve();
	this.sessions = {};
	var upgradeTemplateNotNowTime = 0;

	/**
	 * Initialize LibreOffice, Word for Mac and Word for Windows plugin components.
	 */
	this.init = function () {
		if (Zotero.test) return;
		let entryPoints = [PLUGIN_PATHS.LibreOffice];
		if (Zotero.isMac) {
			entryPoints.push(PLUGIN_PATHS.MacWord);
		}
		else if (Zotero.isWin) {
			entryPoints.push(PLUGIN_PATHS.WinWord);
		}
		for (let entryPoint of entryPoints) {
			try {
				const { init } = ChromeUtils.importESModule(entryPoint);
				init();
			}
			catch (e) {
				Zotero.logError(e);
			}
		}
	}
	
	/**
	 * Begin listening for integration commands on the given pipe
	 * @param {String} pipe The path to the pipe
	 * @param {Function} callback The callback to call on pipe read
	 */
	this.initPipe = function (pipe, callback) {
		Zotero.IPC.Pipe.initPipeListener(pipe, function (string) {
			if(string != "") {
				if (typeof callback == 'function') callback(string);
				// exec command if possible
				var parts = string.match(/^([^ \n]*) ([^ \n]*) (\/[^\n\\]*\/)(?: ([^ \n]*))?\n?$/);
				if(parts) {
					var agent = parts[1].toString();
					var cmd = parts[2].toString();
					var document = parts[3].toString();
					var templateVersion = parts[4] ? parseInt(parts[4].toString()) : 0;
					Zotero.Integration.execCommand(agent, cmd, document, templateVersion);
				} else {
					Components.utils.reportError("Zotero: Invalid integration input received: "+string);
				}
			}
		});
	}
	
	/**
	 * Deletes a defunct pipe on OS X
	 */
	this.deletePipe = function (pipe) {
		try {
			if(pipe.exists()) {
				pipe.remove(false);
			}
			return true;
		} catch (e) {
			// if pipe can't be deleted, log an error
			Zotero.debug("Error removing old integration pipe "+pipe.path, 1);
			Zotero.logError(e);
			Components.utils.reportError(
				"Zotero word processor integration initialization failed. "
					+ "See http://forums.zotero.org/discussion/12054/#Item_10 "
					+ "for instructions on correcting this problem."
			);
			
			// can attempt to delete on OS X
			try {
				let promptService = Services.prompt;
				var deletePipe = promptService.confirm(null, Zotero.getString("integration.error.title"), Zotero.getString("integration.error.deletePipe"));
				if(!deletePipe) return false;
				let escapedFifoFile = pipe.path.replace("'", "'\\''");
				Zotero.Utilities.Internal.executeAppleScript("do shell script \"rmdir '"+escapedFifoFile+"'; rm -f '"+escapedFifoFile+"'\" with administrator privileges", true);
				if(pipe.exists()) return false;
			} catch(e) {
				Zotero.logError(e);
				return false;
			}
		}
	}

	/**
	 * @param {String} agent Agent string provided by the integration plugin
	 * @param {Integer} templateVersion Plugin reported template version
	 * @returns {Boolean} true if integration operation should be cancelled
	 */
	this.warnOutdatedTemplate = function (agent, templateVersion) {
		const expectedTemplateVersion = TEMPLATE_VERSIONS[agent];
		if (typeof expectedTemplateVersion == 'undefined' || templateVersion >= expectedTemplateVersion) return false;
		const daysToIgnore = 30;
		const now = Math.floor(Date.now() / 1000);
		const updateTemplateDelayedOn = Zotero.Prefs.get('integration.updateTemplateDelayedOn');
		if (updateTemplateDelayedOn + (daysToIgnore * 86400) > now || upgradeTemplateNotNowTime + 86400 > now) {
			return false;
		}
		Zotero.debug(`Integration: ${agent} command invoked with outdated template.`);
		
		var ps = Services.prompt;
		var title = Zotero.getString('general.warning');
		var client = agent == "OpenOffice" ? "LibreOffice" : "Microsoft Word";
		var message = Zotero.getString('integration.upgradeTemplate', [Zotero.appName, client]);
		var buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING)
			+ (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_IS_STRING);
		var checkbox = {};
		var index = ps.confirmEx(
			null,
			title,
			message,
			buttonFlags,
			Zotero.getString('general.openPreferences'),
			Zotero.getString('general.notNow'),
			null,
			Zotero.getString(
				'general.dontShowAgainFor',
				daysToIgnore,
				daysToIgnore
			),
			checkbox
		);

		if (index == 0) {
			Zotero.Utilities.Internal.openPreferences('zotero-prefpane-cite', {
				scrollTo: '#wordProcessors'
			});
			return true;
		}

		upgradeTemplateNotNowTime = now;
		if (checkbox.value) {
			Zotero.Prefs.set('integration.upgradeTemplateDelayedOn', now);
		}
		return false;
	};
	
	this.resetSessionStyles = async function () {
		for (let sessionID in Zotero.Integration.sessions) {
			let session = Zotero.Integration.sessions[sessionID];
			await session.setData(session.data, true);
		}
	};
	
	this.getApplication = function (agent, command, docId) {
		if (agent == 'http') {
			return new Zotero.HTTPIntegrationClient.Application();
		}
		// Replace MacWord2016 and MacWord16 with just MacWord.
		agent = agent.startsWith('MacWord') ? 'MacWord' : agent;
		var entryPoint = PLUGIN_PATHS[agent];
		Zotero.debug("Integration: Instantiating "+agent+" plugin handler for command "+command+(docId ? " with doc "+docId : ""));
		const { Application } = ChromeUtils.importESModule(entryPoint);
		return new Application();
	};
	
	/**
	 * Executes an integration command, first checking to make sure that versions are compatible
	 */
	this.execCommand = async function (agent, command, docId, templateVersion=0) {
		var document, session, documentImported;
		Zotero.debug(`Integration: ${agent}-${command}${docId ? `:'${docId}'` : ''} invoked`)
		if (Zotero.Integration.warnOutdatedTemplate(agent, templateVersion)) return;

		let shouldAbort = await this.shouldAbortCommand();
		if (shouldAbort) {
			Zotero.debug("Integration: Request already in progress; not executing "+agent+" "+command);
			return;
		}
		let deferred = Zotero.Promise.defer();
		Zotero.Integration.currentCommandPromise = deferred.promise;
		Zotero.Integration.currentDoc = true;

		var startTime = (new Date()).getTime();

		// Try to execute the command; otherwise display an error in alert service or word processor
		// (depending on what is possible)
		try {
			// Word for windows throws RPC_E_CANTCALLOUT_ININPUTSYNCCALL if we invoke an OLE call in the
			// current event loop (which.. who would have guessed would be the case?)
			await Zotero.Promise.delay();
			var application = Zotero.Integration.getApplication(agent, command, docId);
			
			var documentPromise = (application.getDocument && docId ? application.getDocument(docId) : application.getActiveDocument());
			if (!documentPromise.then) {
				Zotero.debug('Synchronous integration plugin functions are deprecated -- ' +
					'update to asynchronous methods');
				application = Zotero.Integration.LegacyPluginWrapper(application);
				documentPromise = new Zotero.Promise(resolve =>
					resolve(Zotero.Integration.LegacyPluginWrapper.wrapDocument(documentPromise)));
			}
			Zotero.Integration.currentDoc = document = await documentPromise;
			
			[session, documentImported] = await Zotero.Integration.getSession(application, document, agent, command);
			Zotero.Integration.currentSession = session;
			// TODO: figure this out
			// Zotero.Notifier.trigger('delete', 'collection', 'document');
			if (!documentImported) {
				await (new Zotero.Integration.Interface(application, document, session))[command]();
			}
			await document.setDocumentData(session.data.serialize());
		}
		catch (e) {
			if (!(e instanceof Zotero.Exception.UserCancelled)) {
				await Zotero.Integration._handleCommandError(document, session, e);
			}
			else {
				if (session) {
					// If user cancels we should still write the currently assigned session ID
					try {
						await document.setDocumentData(session.data.serialize());
						// And any citations marked for processing (like retraction warning ignore flag changes)
						if (Object.keys(session.processIndices).length) {
							await session.updateDocument(FORCE_CITATIONS_FALSE, false, false);
						}
					// Since user cancelled we can ignore if processor fails here.
					} catch(e) {}
				}
			}
		}
		finally {
			var diff = ((new Date()).getTime() - startTime)/1000;
			Zotero.debug(`Integration: ${agent}-${command}${docId ? `:'${docId}'` : ''} complete in ${diff}s`)
		
			if (Zotero.Integration.currentWindow && !Zotero.Integration.currentWindow.closed) {
				var oldWindow = Zotero.Integration.currentWindow;
				oldWindow.close();
				await Zotero.Promise.delay(50);
			}

			if (Zotero.Integration.currentSession && Zotero.Integration.currentSession.progressBar) {
				Zotero.Integration.currentSession.progressBar.hide();
				await Zotero.Promise.delay(50);
			}
			
			if (document) {
				try {
					await document.cleanup();
					if (!Zotero.Integration.currentSession?._dontActivateDocument) {
						await document.activate();
					}
					
					// Call complete function if one exists
					if (document.complete) {
						await document.complete();
					}
				} catch(e) {
					Zotero.logError(e);
				}
			}
			
			// This technically shouldn't be necessary since we call document.activate(),
			// but http integration plugins may not have OS level access to windows to be
			// able to activate themselves. E.g. Google Docs on Safari.
			if (Zotero.isMac && agent == 'http') {
				Zotero.Utilities.Internal.sendToBack();
			}
			
			Zotero.Integration.currentDoc = Zotero.Integration.currentWindow = false;
			deferred.resolve();
		}
	};

	/**
	 * @returns {Promise<boolean>} Whether the new integration command should be aborted or continue
	 */
	this.shouldAbortCommand = async function () {
		const ps = Services.prompt;
		if (!Zotero.Integration.currentDoc) return false;
		
		if (Zotero.Integration.currentWindow) {
			if (!Zotero.Integration.currentWindow.isPristine) {
				Zotero.Utilities.Internal.activate();
				Zotero.Integration.currentWindow.focus();
				// Prompt user that changes will be lost in the existing dialog
				let result = Zotero.Prompt.confirm({
					title: Zotero.getString('general.warning'),
					text: Zotero.getString(`integration-warning-${Zotero.Integration.currentWindowType}-changes-will-be-lost`),
					button0: Zotero.getString('integration-warning-discard-changes'),
					button1: Zotero.Prompt.BUTTON_TITLE_CANCEL
				});
				if (result == 1) {
					return true;
				}
			}
			Zotero.Integration.currentWindow.cancel();
			await Zotero.Integration.currentCommandPromise;
		}
		else {
			// Prompt the user that an integration command is already running
			let ps = Services.prompt;
			ps.alert(null, Zotero.getString('general.warning'),
				Zotero.getString('integration-warning-command-is-running'));
			return true;
		}
		return false;
	};
	
	this._handleCommandError = async function (document, session, e) {
		try {
			let supportURL = "https://www.zotero.org/support/kb/debugging_broken_documents";
			var displayError;
			if (e instanceof Zotero.Exception.Alert) {
				displayError = e.message;
			}
			else {
				if (e.toString().includes("Could not find a running Word instance.")) {
					displayError = Zotero.getString('integration-error-unable-to-find-winword')
						+ "\n\n" + Zotero.getString("integration.error.viewTroubleshootingInfo");
					supportURL = "https://www.zotero.org/support/kb/could_not_find_a_running_word_instance";
				}
				else if (e.toString().indexOf("ExceptionAlreadyDisplayed") === -1) {
					displayError = Zotero.getString("integration.error.generic")
						+ "\n\n" + Zotero.getString("integration.error.viewTroubleshootingInfo");
				}
				else {
					return;
				}
				if (e.stack) {
					Zotero.debug(e.stack);
				}
			}
			
			if (Zotero.Integration.currentSession && Zotero.Integration.currentSession.progressBar) {
				Zotero.Promise.delay(5).then(() =>
					Zotero.Integration.currentSession.progressBar.hide());
			}
			
			
			// display alerts in the document processor
			if (document) {
				try {
					await document.activate();
					if (e instanceof Zotero.Exception.Alert) {
						await document.displayAlert(displayError, DIALOG_ICON_STOP, DIALOG_BUTTONS_OK);
					} else {
						let index = await document.displayAlert(displayError, DIALOG_ICON_STOP, DIALOG_BUTTONS_YES_NO);
						if (index == 1) {
							Zotero.launchURL(supportURL);
						}
					}
					return;
				}
				catch (e) {
					Zotero.debug("Integration: An error occurred while trying to display an alert. Falling back to Zotero");
					Zotero.logError(e);
				}
			}
			
			Zotero.Utilities.Internal.activate(Zotero.Integration.currentWindow);
			let ps = Services.prompt;
			if (e instanceof Zotero.Exception.Alert) {
				ps.alert(null, Zotero.getString('integration.error.title'), displayError);
			}
			else {
				let index = ps.confirm(null, Zotero.getString('integration.error.title'), displayError);
				if (index == 1) {
					Zotero.launchURL(supportURL);
				}
			}
			
			// CiteprocRsDriverError available only if citeproc-rs is enabled
			try {
				// If the driver panicked we cannot reuse it
				if (e instanceof Zotero.CiteprocRs.CiteprocRsDriverError) {
					session.style.free(true);
					delete Zotero.Integration.sessions[session.id];
				}
			} catch (e) {}
		}
		finally {
			Zotero.logError(e);
		}
	};
	
	/**
	 * Displays a dialog in a modal-like fashion without hanging the thread 
	 * @param {String} url The chrome:// URI of the window
	 * @param {String} [options] Options to pass to the window
	 * @param {String} [io] Data to pass to the window
	 * @return {Promise} Promise resolved when the window is closed
	 */
	this.displayDialog = async function displayDialog(url, options, io, windowType) {
		Zotero.debug(`Integration: Displaying dialog ${url}`);
		// On macOS (and potentially in the future with Word JS)
		// we can only run request sequentially (native async field fetching was dropped
		// with fx102 due to ctypes crashing when passing a callback function) which means
		// that the webWorker thread is blocked while fetching fields.
		// We are technically fetching them asynchronously and open the citation dialog
		// while they are being fetched. However, if we await this cleanup call here
		// the display of the dialog is blocked until fields are fetched and it is able to run.
		// So we make sure cleanup is finished before the dialog is closed, but otherwise
		// we should not delay the dialog display
		let cleanupPromise = Zotero.Integration.currentDoc.cleanup();
		await Zotero.Integration.currentSession?.progressBar.hide(true);
		Zotero.Integration.currentWindowType = windowType;
		
		var allOptions = 'chrome,centerscreen';
		// without this, Firefox gets raised with our windows under Compiz
		if(Zotero.isLinux) allOptions += ',dialog=no';
		if(options) allOptions += ','+options;
		
		var window = Components.classes["@mozilla.org/embedcomp/window-watcher;1"]
			.getService(Components.interfaces.nsIWindowWatcher)
			.openWindow(null, url, '', allOptions, (io ? io : null));
		Zotero.Integration.currentWindow = window;
		Zotero.Utilities.Internal.activate(window);
		
		var deferred = Zotero.Promise.defer();
		var listener = function () {
			if(window.location.toString() === "about:blank") return;
			
			if(window.newWindow) {
				window = window.newWindow;
				Zotero.Integration.currentWindow = window;
				window.addEventListener("unload", listener, false);
				return;
			}
			
			Zotero.Integration.currentWindow = false;
			deferred.resolve();
		}
		window.addEventListener("unload", listener, false);

		await deferred.promise;
		Zotero.Integration.currentSession?.progressBar.show();
		await cleanupPromise;
	};
	
	/**
	 * Gets a session for a given doc.
	 * Either loads a cached session if doc communicated since restart or creates a new one
	 * @return {Zotero.Integration.Session} Promise
	 */
	this.getSession = async function (app, doc, agent, command) {
		let documentImported = false;
		try {
			var progressBar = new Zotero.Integration.Progress(4, command == "addNote");
			// Avoid showing the progress bar on macOS when initiating commands that
			// display an UI interface, otherwise the Zotero window is brought to the front
			// along with the UI when the progress bar is closed.
			if (!Zotero.isMac || ["refresh", "removeCodes", "addEditBibliography"].includes(command)) {
				progressBar.show();
			}
			
			var dataString = await doc.getDocumentData(),
				data, session;
			
			try {
				data = new Zotero.Integration.DocumentData(dataString);
			} catch(e) {
				data = new Zotero.Integration.DocumentData();
			}
			
			if (dataString != EXPORTED_DOCUMENT_MARKER && data.prefs.fieldType) {
				if (data.dataVersion < DATA_VERSION) {
					if (data.dataVersion == 1
							&& data.prefs.fieldType == "Field"
							&& app.primaryFieldType == "ReferenceMark") {
						// Converted OOo docs use ReferenceMarks, not fields
						data.prefs.fieldType = "ReferenceMark";
					}
					
					var warning = await doc.displayAlert(Zotero.getString("integration.upgradeWarning", [Zotero.clientName, '5.0']),
						DIALOG_ICON_WARNING, DIALOG_BUTTONS_OK_CANCEL);
					if (!warning) {
						throw new Zotero.Exception.UserCancelled("document upgrade");
					}
				// Don't throw for version 4(JSON) during the transition from 4.0 to 5.0
				} else if ((data.dataVersion > DATA_VERSION) && data.dataVersion != 4) {
					throw new Zotero.Exception.Alert("integration.error.newerDocumentVersion",
							[data.zoteroVersion, Zotero.version], "integration.error.title");
				}
				
				if (data.prefs.fieldType !== app.primaryFieldType
						&& data.prefs.fieldType !== app.secondaryFieldType) {
					throw new Zotero.Exception.Alert("integration.error.fieldTypeMismatch",
							[], "integration.error.title");
				}

				session = Zotero.Integration.sessions[data.sessionID];
			}
			// Make sure we don't maintain the session if agent changes (i.e. LO -> Word)
			// and display wrong field types in doc preferences.
			if (!session || session.agent != agent) {
				session = new Zotero.Integration.Session(doc, app);
				session.rebuildCiteprocState = true;
			}
			session.agent = agent;
			session._doc = doc;
			session.progressBar = progressBar;
			session._fields = null;
			session.ignoreEmptyBibliography = true;
			session.progressCallback = null;
			session._removeCodeFields = {};
			session._deleteFields = {};
			session._bibliographyFields = [];
			session._shouldMerge = false;
			session._transactionUpToDate = false;

			if (dataString == EXPORTED_DOCUMENT_MARKER) {
				Zotero.Integration.currentSession = session;
				data = await session.importDocument();
				documentImported = true;
				// We're slightly abusing the system here, but importing a document should cancel
				// any other operation the user was trying to perform since the document will change
				// significantly
			} else {
				try {
					await session.setData(data);
				} catch(e) {
					// make sure style is defined
					if (e instanceof Zotero.Exception.Alert && e.name === "integration.error.invalidStyle") {
						if (data.style.styleID) {
							let trustedSource =
								/^https?:\/\/(www\.)?(zotero\.org|citationstyles\.org)/.test(data.style.styleID);
							let errorString = Zotero.getString("integration.error.styleMissing", data.style.styleID);
							if (trustedSource ||
								(await doc.displayAlert(errorString, DIALOG_ICON_WARNING, DIALOG_BUTTONS_YES_NO))) {

								let installed = false;
								try {
									let { styleTitle, styleID } = await Zotero.Styles.install(
										{url: data.style.styleID}, data.style.styleID, true
									);
									data.style.styleID = styleID;
									installed = true;
								}
								catch (e) {
									await doc.displayAlert(
										Zotero.getString(
											'integration.error.styleNotFound', data.style.styleID
										),
										DIALOG_ICON_WARNING,
										DIALOG_BUTTONS_OK
									);
								}
								if (installed) {
									await session.setData(data, true);
								} else {
									await session.setDocPrefs();
								}
							} else {
								await session.setDocPrefs();
							}
						} else {
							await session.setDocPrefs();
						}
					} else {
						throw e;
					}
				}
			}
		} catch (e) {
			progressBar.hide(true);
			throw e;
		}
		if (session.progressBar) {
			progressBar.reset();
			progressBar.segments = session.progressBar.segments;
		}
		return [session, documentImported];
	};
	
}

Zotero.Integration.confirmExportDocument = function () {
	const documentationURL = "https://www.zotero.org/support/kb/moving_documents_between_word_processors";
	
	var ps = Services.prompt;
	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);
	var result = ps.confirmEx(null,
		Zotero.getString('integration.exportDocument.title'),
		Zotero.getString('integration.exportDocument.description1')
			+ "\n\n"
			+ Zotero.getString('integration.exportDocument.description2'),
		buttonFlags,
		Zotero.getString('general.continue'),
		null,
		Zotero.getString('general.moreInformation'), null, {});
	if (result == 0) {
		return true;
	}
	else if (result == 2) {
		Zotero.launchURL(documentationURL);
	}
	return false;
}

/**
 * An exception thrown when a document contains an item that no longer exists in the current document.
 */
Zotero.Integration.MissingItemException = function (item) {this.item = item;};
Zotero.Integration.MissingItemException.prototype = {
	"name":"MissingItemException",
	"message":`An item in this document is missing from your Zotero library.}`,
	"toString":function () { return this.message + `\n ${JSON.stringify(this.item)}` }
};

Zotero.Integration.NO_ACTION = 0;
Zotero.Integration.UPDATE = 1;
Zotero.Integration.DELETE = 2;
Zotero.Integration.REMOVE_CODE = 3;

/**
 * All methods for interacting with a document
 * @constructor
 */
Zotero.Integration.Interface = function (app, doc, session) {
	this._app = app;
	this._doc = doc;
	this._session = session;
}

/**
 * Adds a citation to the current document.
 * @return {Promise}
 */
Zotero.Integration.Interface.prototype.addCitation = async function () {
	await this._session.init(false, false);
	
	let citations = await this._session.cite(null);
	if (this._session.data.prefs.delayCitationUpdates) {
		for (let citation of citations) {
			await this._session.writeDelayedCitation(citation.field, citation);
		}
	}
	else {
		return this._session.updateDocument(FORCE_CITATIONS_FALSE, false, false);
	}
};

/**
 * Edits the citation at the cursor position.
 * @return {Promise}
 */
Zotero.Integration.Interface.prototype.editCitation = async function () {
	await this._session.init(true, false);
	var docField = await this._doc.cursorInField(this._session.data.prefs['fieldType']);
	if(!docField) {
		throw new Zotero.Exception.Alert("integration.error.notInCitation", [],
			"integration.error.title");
	}
	return this.addEditCitation(docField);
};

/**
 * Edits the citation at the cursor position if one exists, or else adds a new one.
 * @return {Promise}
 */
Zotero.Integration.Interface.prototype.addEditCitation = async function (docField) {
	await this._session.init(false, false);
	docField = docField || (await this._doc.cursorInField(this._session.data.prefs['fieldType']));

	let citations = await this._session.cite(docField);
	if (this._session.data.prefs.delayCitationUpdates) {
		for (let citation of citations) {
			await this._session.writeDelayedCitation(citation.field, citation);
		}
	} else {
		return this._session.updateDocument(FORCE_CITATIONS_FALSE, false, false);
	}
};

/**
 * Adds a note to the current document.
 * @return {Promise}
 */
Zotero.Integration.Interface.prototype.addNote = async function () {
	await this._session.init(false, false);

	if ((!(await this._doc.canInsertField(this._session.data.prefs['fieldType'])))) {
		throw new Zotero.Exception.Alert("integration.error.cannotInsertHere", [],
			"integration.error.title");
	}

	let citations = await this._session.cite(null, true);
	if (this._session.data.prefs.delayCitationUpdates) {
		for (let citation of citations) {
			await this._session.writeDelayedCitation(citation.field, citation);
		}
	}
	else {
		return this._session.updateDocument(FORCE_CITATIONS_FALSE, false, false);
	}
};

/**
 * Insert annotations combined into one note into the current document.
 * @return {Promise}
 */
Zotero.Integration.Interface.prototype.addAnnotation = async function () {
	await this._session.init(false, false);

	if ((!await this._doc.canInsertField(this._session.data.prefs.fieldType))) {
		throw new Zotero.Exception.Alert("integration.error.cannotInsertHere", [],
			"integration.error.title");
	}

	let citations = await this._session.cite(null, false, true);
	if (this._session.data.prefs.delayCitationUpdates) {
		return Promise.all(citations.map((citation) => {
			return this._session.writeDelayedCitation(citation.field, citation);
		}));
	}
	else {
		return this._session.updateDocument(FORCE_CITATIONS_FALSE, false, false);
	}
};

/**
 * Adds a bibliography to the current document.
 * @return {Promise}
 */
Zotero.Integration.Interface.prototype.addBibliography = async function () {
	var me = this;
	await this._session.init(true, false);
	// Make sure we can have a bibliography
	if(!me._session.data.style.hasBibliography) {
		throw new Zotero.Exception.Alert("integration.error.noBibliography", [],
			"integration.error.title");
	}
	
	let field = new Zotero.Integration.BibliographyField(await this._session.addField());
	var citationsMode = FORCE_CITATIONS_FALSE;
	await field.clearCode();
	if(this._session.data.prefs.delayCitationUpdates) {
		// Refreshes citeproc state before proceeding
		this._session.rebuildCiteprocState = true;
		citationsMode = FORCE_CITATIONS_REGENERATE;
	}
	await this._session.updateFromDocument(citationsMode);
	await this._session.updateDocument(citationsMode, true, false);
}

/**
 * Edits bibliography metadata.
 * @return {Promise}
 */
Zotero.Integration.Interface.prototype.editBibliography = async function () {
	// Make sure we have a bibliography
	await this._session.init(true, false);
	var fields = await this._session.getFields();
	
	var bibliographyField;
	for (let i = fields.length-1; i >= 0; i--) {
		let field = await Zotero.Integration.Field.loadExisting(fields[i]);
		if (field.type == INTEGRATION_TYPE_BIBLIOGRAPHY) {
			bibliographyField = field;
			break;
		}
	}
	
	if(!bibliographyField) {
		throw new Zotero.Exception.Alert("integration.error.mustInsertBibliography",
			[], "integration.error.title");
	}
	let bibliography = new Zotero.Integration.Bibliography(bibliographyField, await bibliographyField.unserialize());
	var citationsMode = FORCE_CITATIONS_FALSE;
	if(this._session.data.prefs.delayCitationUpdates) {
		// Refreshes citeproc state before proceeding
		this._session.rebuildCiteprocState = true;
		citationsMode = FORCE_CITATIONS_REGENERATE;
	}
	await this._session.updateFromDocument(citationsMode);
	await this._session.editBibliography(bibliography);
	await this._session.updateDocument(citationsMode, true, false);
};


Zotero.Integration.Interface.prototype.addEditBibliography = async function () {
	// Check if we have a bibliography
	await this._session.init(true, false);
	
	if (!this._session.data.style.hasBibliography) {
		throw new Zotero.Exception.Alert("integration.error.noBibliography", [],
			"integration.error.title");
	}
	
	var fields = await this._session.getFields();
	
	var bibliographyField;
	for (let i = fields.length-1; i >= 0; i--) {
		let field = await Zotero.Integration.Field.loadExisting(fields[i]);
		if (field.type == INTEGRATION_TYPE_BIBLIOGRAPHY) {
			bibliographyField = field;
			break;
		}
	}
	
	var newBibliography = !bibliographyField;
	if (!bibliographyField) {
		bibliographyField = new Zotero.Integration.BibliographyField(await this._session.addField());
		await bibliographyField.clearCode();
	}
	
	let bibliography = new Zotero.Integration.Bibliography(bibliographyField, await bibliographyField.unserialize());
	var citationsMode = FORCE_CITATIONS_FALSE;
	if(this._session.data.prefs.delayCitationUpdates) {
		// Refreshes citeproc state before proceeding
		this._session.rebuildCiteprocState = true;
		citationsMode = FORCE_CITATIONS_REGENERATE;
	}
	await this._session.updateFromDocument(citationsMode);
	if (!newBibliography) {
		await this._session.editBibliography(bibliography);
	}
	await this._session.updateDocument(citationsMode, true, false);
};

Zotero.Integration.Interface.prototype.citationExplorer = async function () {
	await this._session.init(true, false);
	
	var citationsMode = FORCE_CITATIONS_FALSE;
	if(this._session.data.prefs.delayCitationUpdates) {
		// Refreshes citeproc state before proceeding
		this._session.rebuildCiteprocState = true;
		citationsMode = FORCE_CITATIONS_REGENERATE;
	}
	await this._session.updateFromDocument(citationsMode);
	await this._session.openCitationExplorer();
	return this._session.updateDocument(citationsMode, true, false);
}


/**
 * Updates the citation data for all citations and bibliography entries.
 * @return {Promise}
 */
Zotero.Integration.Interface.prototype.refresh = async function () {
	await this._session.init(true, false);
	this._session._shouldMerge = true;
	
	this._session.rebuildCiteprocState = this._session.rebuildCiteprocState || this._session.data.prefs.delayCitationUpdates;
	await this._session.updateFromDocument(FORCE_CITATIONS_REGENERATE);
	await this._session.updateDocument(FORCE_CITATIONS_REGENERATE, true, false);
}

/**
 * Deletes field codes.
 * @return {Promise}
 */
Zotero.Integration.Interface.prototype.removeCodes = async function () {
	await this._session.init(true, false)
	let fields = await this._session.getFields()
	var result = await this._doc.displayAlert(Zotero.getString("integration.removeCodesWarning"),
				DIALOG_ICON_WARNING, DIALOG_BUTTONS_OK_CANCEL);
	if (result) {
		for(var i=fields.length-1; i>=0; i--) {
			await fields[i].removeCode();
		}
	}
}

/**
 * Displays a dialog to set document preferences (style, footnotes/endnotes, etc.)
 * @return {Promise}
 */
Zotero.Integration.Interface.prototype.setDocPrefs = async function () {
	var oldData;
	let haveSession = await this._session.init(false, true);
	
	if(!haveSession) {
		// This is a brand new document; don't try to get fields
		oldData = await this._session.setDocPrefs(true);
	} else {
		// Can get fields while dialog is open
		[, oldData] = await Promise.all([
			this._session.getFields(),
			this._session.setDocPrefs(true)
		]);
	}
	
	// If oldData is null, then there was no document data, so we don't need to update
	// fields
	if (!oldData) return false;

	// Perform noteType or fieldType conversion
	let fields = await this._session.getFields();

	const styleHasChanged = oldData.style.styleID != this._session.data.style.styleID;
	var convertBibliographies = oldData.prefs.fieldType != this._session.data.prefs.fieldType;
	var convertItems = convertBibliographies
		|| oldData.prefs.noteType != this._session.data.prefs.noteType;
	var fieldsToConvert = new Array();
	var fieldNoteTypes = new Array();
	for (var i=0, n=fields.length; i<n; i++) {
		let field = await Zotero.Integration.Field.loadExisting(fields[i]);
		
		if (convertItems && field.type === INTEGRATION_TYPE_ITEM) {
			var citation = await field.unserialize();
			if (!citation.properties.dontUpdate) {
				fieldsToConvert.push(fields[i]);
				fieldNoteTypes.push(this._session.data.prefs.noteType);
			}
		} else if(convertBibliographies
				&& field.type === INTEGRATION_TYPE_BIBLIOGRAPHY) {
			fieldsToConvert.push(fields[i]);
			fieldNoteTypes.push(0);
		}
	}
	
	if(fieldsToConvert.length) {
		// Pass to conversion function
		await this._doc.convert(fieldsToConvert,
			this._session.data.prefs.fieldType, fieldNoteTypes,
			fieldNoteTypes.length);
	}
	
	// Refresh field info
	this._session._fields = null;
	this._session.ignoreEmptyBibliography = true;

	if (this._session.data.prefs.delayCitationUpdates && !fieldsToConvert.length && !styleHasChanged) return;
	
	await this._session.updateFromDocument(FORCE_CITATIONS_RESET_TEXT);
	return this._session.updateDocument(FORCE_CITATIONS_RESET_TEXT, true, true);
};

/**
 * Exports the citations in the document to a format importable in other word processors
 * @return {Promise}
 */
Zotero.Integration.Interface.prototype.exportDocument = async function () {
	await this._session.init(true, false);
	if (Zotero.Integration.confirmExportDocument()) {
		await this._session.exportDocument();
	}
}

/**
 * An exceedingly simple nsISimpleEnumerator implementation
 */
Zotero.Integration.JSEnumerator = function (objArray) {
	this.objArray = objArray;
}
Zotero.Integration.JSEnumerator.prototype.hasMoreElements = function () {
	return this.objArray.length;
}
Zotero.Integration.JSEnumerator.prototype.getNext = function () {
	return this.objArray.shift();
}

/**
 * Keeps track of all session-specific variables
 */
Zotero.Integration.Session = function (doc, app) {
	this.embeddedItems = {};
	this.embeddedZoteroItems = {};
	this.embeddedItemsByURI = {};
	this.citationsByIndex = {};
	this.resetRequest(doc);
	this.primaryFieldType = app.primaryFieldType;
	this.secondaryFieldType = app.secondaryFieldType;
	this.outputFormat = app.outputFormat || 'rtf';
	this._dontActivateDocument = false;

	// Set to true upon updateFromDocument()
	// Changes to false after delayed citation insert
	// updateFromDocument() not called for most operations in delayed update mode
	this._sessionUpToDate = false;
	// Set to true upon updateFromDocument()
	// Changes to false on new transaction
	this._transactionUpToDate = false;
	// Controls whether adjacent fields should be merged
	// Generally only enabled for Interface.refresh() calls
	this._shouldMerge = false;
	this._app = app;

	this._fields = null;
	this.ignoreEmptyBibliography = true;

	// Callback called while retrieving fields with the percentage complete.
	this.progressCallback = null;

	this._removeCodeFields = {};
	this._deleteFields = {};
	this._bibliographyFields = [];

	this.sessionID = Zotero.randomString();
	Zotero.Integration.sessions[this.sessionID] = this;
}

/**
 * Checks that it is appropriate to add fields to the current document at the current
 * position, then adds one.
 */
Zotero.Integration.Session.prototype.addField = async function (note, fieldIndex=-1) {
	// Get citation types if necessary
	if (!(await this._doc.canInsertField(this.data.prefs['fieldType']))) {
		return Promise.reject(new Zotero.Exception.Alert("integration.error.cannotInsertHere",
		[], "integration.error.title"));
	}
	
	var field = await this._doc.cursorInField(this.data.prefs['fieldType']);
	if (field) {
		if (!(await this.displayAlert(Zotero.getString("integration.replace"),
				DIALOG_ICON_STOP,
				DIALOG_BUTTONS_OK_CANCEL))) {
			return Promise.reject(new Zotero.Exception.UserCancelled("inserting citation"));
		}
	}
	
	if (!field) {
		field = await this._doc.insertField(this.data.prefs['fieldType'],
			(note ? this.data.prefs["noteType"] : 0));
		// Older doc plugins do not initialize the field code to anything meaningful
		// so we ensure it here manually
		field.setCode('TEMP');
	}
	// If fields already retrieved, further this.getFields() calls will returned the cached version
	// So add this field to the cache
	if (this._fields) {
		if (fieldIndex == -1) {
			this._fields.push(field);
		}
		else {
			this._fields.splice(fieldIndex, 0, field);
		}
	}
	
	return field;
}

/**
 * Gets all fields for a document
 * @return {Promise} Promise resolved with field list.
 */
Zotero.Integration.Session.prototype.getFields = new function () {
	var deferred;
	return async function (force=false) {
		// If we already have fields, just return them
		if(!force && this._fields != undefined) {
			return this._fields;
		}
		
		if (deferred) {
			return deferred.promise;
		}
		deferred = Zotero.Promise.defer();
		var promise = deferred.promise;
		
		// Otherwise, start getting fields
		var timer = new Zotero.Integration.Timer();
		timer.start();
		this.progressBar.start();
		try {
			var fields = this._fields = Array.from(await this._doc.getFields(this.data.prefs['fieldType']));
			
			var retrieveTime = timer.stop();
			this.progressBar.finishSegment();
			Zotero.debug("Integration: Retrieved " + fields.length + " fields in " +
				retrieveTime + "; " + fields.length/retrieveTime + " fields/second");
			deferred.resolve(fields);
		} catch(e) {
			deferred.reject(e);
		}
			
		deferred = null;
		return promise;
	}
}

/**
 * Updates Zotero.Integration.Session citations from the session document
 */
Zotero.Integration.Session.prototype.updateFromDocument = async function (forceUpdateAllCitations) {
	if (this._transactionUpToDate) {
		return;
	}
	await this.getFields();
	this.resetRequest(this._doc);
	
	this._removeCodeFields = {};
	this._deleteFields = {};
	this._bibliographyFields = [];
	
	var timer = new Zotero.Integration.Timer();
	timer.start();
	this.progressBar.start();
	if (forceUpdateAllCitations) {
		this.forceUpdateAllCitations = true;
		// See Session.restoreProcessorState() for a comment
		if (!Zotero.Prefs.get('cite.useCiteprocRs')) {
			this.rebuildCiteprocState = true;
		}
	}
	await this._processFields();
	try {
		await this.handleRetractedItems();
	}
	catch (e) {
		Zotero.debug('Retracted item handling failed', 2);
		Zotero.logError(e);
	}
	this.forceUpdateAllCitations = false;

	var updateTime = timer.stop();
	this.progressBar.finishSegment();
	Zotero.debug("Integration: Updated session data for " + this._fields.length + " fields in "
		+ updateTime + "; " + this._fields.length/updateTime + " fields/second");
	
	if (this.rebuildCiteprocState) {
		this.restoreProcessorState();
		this.rebuildCiteprocState = false;
	}
	this._sessionUpToDate = this._transactionUpToDate = true;
};

/**
 * Keep processing fields until all have been processed
 */
Zotero.Integration.Session.prototype._processFields = async function () {
	if (!this._fields) {
		throw new Error("_processFields called without fetching fields first");
	}

	let adjacentCitations = [];
	for (var i = 0; i < this._fields.length; i++) {
		let field = await Zotero.Integration.Field.loadExisting(this._fields[i]);
		if (field.type === INTEGRATION_TYPE_ITEM) {
			var noteIndex = await field.getNoteIndex(),
				data = await field.unserialize(),
				citation = new Zotero.Integration.Citation(field, data, noteIndex);
				citation.fieldIndex = i;

			if (this._shouldMerge && typeof field.isAdjacentToNextField === 'function' && (await field.isAdjacentToNextField())) {
				adjacentCitations.push(citation);
				this._deleteFields[i] = true;
				continue;
			}
			await this.addCitation(i, noteIndex, citation, adjacentCitations);
			if (adjacentCitations.length) {
				adjacentCitations = [];
			}
		} else if (field.type === INTEGRATION_TYPE_BIBLIOGRAPHY) {
			if (this.ignoreEmptyBibliography && (await field.getText()).trim() === "") {
				this._removeCodeFields[i] = true;
			} else {
				field.index = i;
				this._bibliographyFields.push(field);
			}
		}
	}
	if (this._bibliographyFields.length) {
		var data = await this._bibliographyFields[0].unserialize()
		this.bibliography = new Zotero.Integration.Bibliography(this._bibliographyFields[0], data);
		await this.bibliography.loadItemData();
	} else {
		delete this.bibliography;
	}
	// TODO: figure this out
	// Zotero.Notifier.trigger('add', 'collection', 'document');
};

/**
 * Updates bibliographies and fields within a document
 * @param {Boolean} forceCitations Whether to regenerate all citations
 * @param {Boolean} forceBibliography Whether to regenerate all bibliography entries
 * @param {Boolean} [ignoreCitationChanges] Whether to ignore changes to citations that have been 
 *	   modified since they were created, instead of showing a warning
 * @return {Promise} A promise resolved when the document is updated
 */
Zotero.Integration.Session.prototype.updateDocument = async function (forceCitations, forceBibliography, ignoreCitationChanges) {
	this.timer = new Zotero.Integration.Timer();
	this.timer.start();
	
	this.progressBar.start();
	await this._updateCitations();
	this.progressBar.finishSegment();
	this.progressBar.start();
	await this._updateDocument(forceCitations, forceBibliography, ignoreCitationChanges)
	this.progressBar.finishSegment();

	var diff = this.timer.stop();
	this.timer = null;
	Zotero.debug(`Integration: updateDocument complete in ${diff}s`)
	// If the update takes longer than 5s suggest delaying citation updates
	if (diff > DELAY_CITATIONS_PROMPT_TIMEOUT && !this.data.prefs.dontAskDelayCitationUpdates && !this.data.prefs.delayCitationUpdates) {
		await this._doc.activate();
		
		var interfaceType = 'tab';
		if (['MacWord2008', 'OpenOffice'].includes(this.agent)) {
			interfaceType = 'toolbar';
		}
		
		var result = await this.displayAlert(
				Zotero.getString('integration.delayCitationUpdates.alert.text1')
					+ "\n\n"
					+ Zotero.getString(`integration.delayCitationUpdates.alert.text2.${interfaceType}`)
					+ "\n\n"
					+ Zotero.getString('integration.delayCitationUpdates.alert.text3'),
				DIALOG_ICON_WARNING,
				DIALOG_BUTTONS_YES_NO_CANCEL
		);
		if (result == 2) {
			this.data.prefs.delayCitationUpdates = true;
		}
		if (result) {
			this.data.prefs.dontAskDelayCitationUpdates = true;
			// yield this.setDocPrefs(true);
		}
	}
};

/**
 * Helper function to update bibliographys and fields within a document
 * @param {Boolean} forceCitations Whether to regenerate all citations
 * @param {Boolean} forceBibliography Whether to regenerate all bibliography entries
 * @param {Boolean} [ignoreCitationChanges] Whether to ignore changes to citations that have been 
 *	modified since they were created, instead of showing a warning
 */
Zotero.Integration.Session.prototype._updateDocument = async function (forceCitations, forceBibliography,
		ignoreCitationChanges) {
	if(this.progressCallback) {
		var nFieldUpdates = Object.keys(this.processIndices).length;
		if(this.bibliographyHasChanged || forceBibliography) {
			nFieldUpdates += this._bibliographyFields.length*5;
		}
	}
	
	// We will be updating the document in reverse order below.
	//
	// While technically we should live in a pure heavenly place
	// where field update order wouldn't matter and integration
	// plugins would know how to update any field without breaking other
	// field access, in practice this doesn't actually work.
	// It doesn't work at least with MacWord, because (un)fortunately
	// it allows nested field codes, and creating an automatic index
	// on author names produces exactly the sorts of nested field codes
	// in styles like full footnote chicago. Upon text update on such citation
	// Zotero removes the automatically added index fields, effectively changing
	// the order of appearance of all following fields, and since
	// fields are hard-tied to their index in the document for MacWord subsequent
	// field updates start failing.
	//
	// There's also an unfortunate side-effect to this to the end user, which
	// is maybe not entirely a big deal:
	// When refreshing the document, and there is a need to prompt for multiple
	// modified citations, these prompts will appear in reverse-document order too.
	// Although at least for macword you don't get to interact with the document
	// while a prompt is being displayed, so the order of these prompts is to a large
	// degree a minor issue.
	var indicesToUpdate = Object.keys(this.processIndices);
	
	// Add bibliography indices to the above indices
	if (this.bibliography	 				// if bibliography exists
			&& Object.keys(this.citationsByIndex).length // and doc has citations
			&& (this.bibliographyHasChanged	// and bibliography changed
			|| forceBibliography)) {					// or if we should generate regardless of
														// changes
		for (let field of this._bibliographyFields) {
			indicesToUpdate.push(field.index);
		}
	}
	
	// Descending order sort
	indicesToUpdate = indicesToUpdate.sort((a, b) => b - a);
	
	var nUpdated=0;
	for(var i of indicesToUpdate) {
		if(this.progressCallback && nUpdated % 10 == 0) {
			try {
				this.progressCallback(75+(nUpdated/nFieldUpdates)*25);
			} catch(e) {
				Zotero.logError(e);
			}
		}
		// Jump to next event loop step for UI updates
		await Zotero.Promise.delay();
		
		var citation = this.citationsByIndex[i];
		if (citation) {
			let citationField = citation.field;
			
			var isRich = false;
			if (!citation.properties.dontUpdate) {
				var formattedCitation = citation.properties.custom
					? citation.properties.custom : citation.text;
				var plainCitation = citation.properties.plainCitation && (await citationField.getText());
				var plaintextChanged = citation.properties.plainCitation 
						&& plainCitation !== citation.properties.plainCitation;
									
				if (!ignoreCitationChanges && plaintextChanged) {
					// Citation manually modified; ask user if they want to save changes
					Zotero.debug("[_updateDocument] Attempting to update manually modified citation.\n"
						+ "Original: " + citation.properties.plainCitation + "\n"
						+ "Current:  " + plainCitation
					);
					await citationField.select();
					var result = await this.displayAlert(
						Zotero.getString("integration.citationChanged")+"\n\n"
							+ Zotero.getString("integration.citationChanged.description")+"\n\n"
							+ Zotero.getString("integration.citationChanged.original", citation.properties.plainCitation)+"\n"
							+ Zotero.getString("integration.citationChanged.modified", plainCitation)+"\n", 
						DIALOG_ICON_CAUTION, DIALOG_BUTTONS_YES_NO);
					if (result) {
						citation.properties.dontUpdate = true;
						// Sigh. This hurts. setCode in LO forces a text reset. If the formattedText is rtf
						// it is reinserted, however that breaks what properties.dontUpdate should do
						if (this.primaryFieldType == "ReferenceMark"
							&& citation.properties.formattedCitation.includes('\\')) {
							citation.properties.formattedCitation = citation.properties.plainCitation;
						}
					}
				}
				
				// Update citation text:
				// If we're looking to reset the text even if it matches previous text (i.e. style change)
				if (forceCitations == FORCE_CITATIONS_RESET_TEXT
						// Or metadata has changed thus changing the formatted citation
						|| ((formattedCitation && citation.properties.formattedCitation !== formattedCitation
						// Or plaintext has changed and user does not want to keep the change
						|| plaintextChanged) && !citation.properties.dontUpdate)) {

					
					// Word will preserve previous text styling, so we need to force remove it
					// for citations that were inserted with delay styling
					var wasDelayed = citation.properties.formattedCitation
						&& citation.properties.formattedCitation.includes(DELAYED_CITATION_RTF_STYLING);
					if (this.outputFormat == 'rtf' && wasDelayed) {
						isRich = await citationField.setText(`${DELAYED_CITATION_RTF_STYLING_CLEAR}{${formattedCitation}}`);
					} else {
						isRich = await citationField.setText(formattedCitation);
					}
					
					citation.properties.formattedCitation = formattedCitation;
					citation.properties.plainCitation = await citationField.getText();
				}
			}
			
			var serializedCitation = citation.serialize();
			if (serializedCitation != citation.properties.field) {
				await citationField.setCode(serializedCitation);
			}
			nUpdated++;
		}
		else {
			let bibliographyField;
			for (let f of this._bibliographyFields) {
				if (f.index == i) {
					bibliographyField = f;
					break;
				}
			}
			if (!bibliographyField) {
				throw new Error(`Attempting to update field ${i} which does not exist`);
			}
			
			if (forceBibliography || this.bibliographyDataHasChanged) {
				let code = this.bibliography.serialize();
				await bibliographyField.setCode(code);
			}
			
			// get bibliography and format as RTF
			var bib = this.bibliography.getCiteprocBibliography(this.style);
			
			var bibliographyText = "";
			if (bib) {
				if (this.outputFormat == 'rtf') {
					bibliographyText = bib[0].bibstart+bib[1].join("\\\r\n")+"\\\r\n"+bib[0].bibend;
				} else {
					bibliographyText = bib[0].bibstart+bib[1].join("")+bib[0].bibend;
				}
				
				// Only set the bibliography style once so that customizations
				// to Bibliography style in word processors are maintained
				if(!this.data.style.bibliographyStyleHasBeenSet) {
					var bibStyle = Zotero.Cite.getBibliographyFormatParameters(bib);
					
					// set bibliography style
					await this._doc.setBibliographyStyle(bibStyle.firstLineIndent, bibStyle.indent,
						bibStyle.lineSpacing, bibStyle.entrySpacing, bibStyle.tabStops, bibStyle.tabStops.length);
					
					// set bibliographyStyleHasBeenSet parameter to prevent further changes	
					this.data.style.bibliographyStyleHasBeenSet = true;
				}
			}
			
			// set bibliography text
			if(this.progressCallback) {
				try {
					this.progressCallback(75+(nUpdated/nFieldUpdates)*25);
				}
				catch (e) {
					Zotero.logError(e);
				}
			}
			// Jump to next event loop step for UI updates
			await Zotero.Promise.delay();
			
			if (bibliographyText) {
				await bibliographyField.setText(bibliographyText);
			}
			else {
				await bibliographyField.setText("{Bibliography}");
			}
			nUpdated += 5;
		}
	}
	
	// Do these operations in reverse in case plug-ins care about order
	var removeCodeFields = Object.keys(this._removeCodeFields).sort((a, b) => b - a);
	for (let fieldIndex of removeCodeFields) {
		await this._fields[fieldIndex].removeCode();
	}
	
	var deleteFields = Object.keys(this._deleteFields).sort((a, b) => b - a);
	for (let fieldIndex of deleteFields) {
		this._fields[fieldIndex].delete();
	}
	this.processIndices = {}
}

/**
 * Insert a citation at cursor location or edit the existing one,
 * display the citation dialog and perform any field/text inserts after
 * the dialog edits are accepted
 */
Zotero.Integration.Session.prototype.cite = async function (field, addNote=false, addAnnotations = false) {
	var newField;
	var citation;
	
	if (field) {
		field = await Zotero.Integration.Field.loadExisting(field);

		if (field.type != INTEGRATION_TYPE_ITEM) {
			throw new Zotero.Exception.Alert("integration.error.notInCitation");
		}
		citation = new Zotero.Integration.Citation(field, await field.unserialize(), await field.getNoteIndex());
	} else {
		newField = true;
		field = new Zotero.Integration.CitationField(await this.addField(true));
		citation = new Zotero.Integration.Citation(field);
	}
	
	await citation.prepareForEditing();

	// -------------------
	// Preparing data to pass into CitationEditInterface
	
	var fieldIndexPromise, citationsByItemIDPromise;
	const citationDataLoadedFromDocument = Object.keys(this.citationsByItemID).length;
	if (this.data.prefs.delayCitationUpdates && citationDataLoadedFromDocument) {
		fieldIndexPromise = Promise.resolve(-1);
		citationsByItemIDPromise = Promise.resolve(this.citationsByItemID);
	}
	else {
		fieldIndexPromise = this.getFields().then(async function (fields) {
			for (var i = 0, n = fields.length; i < n; i++) {
				if (await fields[i].equals(field._field)) {
					// This is needed, because LibreOffice integration plugin caches the field code instead of asking
					// the document every time when calling #getCode().
					field = new Zotero.Integration.CitationField(fields[i]);
					return i;
				}
			}
			return -1;
		});
		citationsByItemIDPromise =
			this.updateFromDocument(FORCE_CITATIONS_FALSE).then(() => this.citationsByItemID);
	}

	var previewFn = async function (citation) {
		let idx = await fieldIndexPromise;
		await citationsByItemIDPromise;

		var [citations, fieldToCitationIdxMapping, citationToFieldIdxMapping] = this.getCiteprocLists();
		for (var prevIdx = idx-1; prevIdx >= 0; prevIdx--) {
			if (prevIdx in fieldToCitationIdxMapping) break;
		}
		let sliceIdx = fieldToCitationIdxMapping[prevIdx]+1;
		if (sliceIdx == NaN) {
			sliceIdx = 0;
		}
		let citationsPre = citations.slice(0, sliceIdx);
		let citationsPost = citations.slice(sliceIdx);
		let citationID = citation.citationID;
		try {
			var result = this.style.previewCitationCluster(citation, citationsPre, citationsPost, "rtf");
		} catch(e) {
			throw e;
		} finally {
			// CSL.previewCitationCluster() sets citationID, which means that we do not mark it
			// as a new citation in Session.addCitation() if the ID is still present
			citation.citationID = citationID;
		}
		return result;
	}.bind(this);
		
	var io = new Zotero.Integration.CitationEditInterface(
		citation, this.style.opt.sort_citations,
		fieldIndexPromise, citationsByItemIDPromise, previewFn
	);
	io.isCitingNotes = addNote;
	io.isAddingAnnotations = addAnnotations;
	Zotero.debug(`Editing citation:`);
	Zotero.debug(JSON.stringify(citation.toJSON()));

	var mode = "chrome,centerscreen,resizable=true";
	if (!Zotero.isMac && Zotero.Prefs.get('integration.keepAddCitationDialogRaised')) {
		mode += ",popup";
	}
	else {
		mode += ",alwaysRaised";
	}

	Zotero.Integration.displayDialog('chrome://zotero/content/integration/citationDialog.xhtml', mode, io, "citation");

	// -------------------
	// io.promise resolves when the citation dialog is closed
	this.progressCallback = await io.promise;
	
	if (!io.citation.citationItems.length) {
		// Try to delete new field on cancel
		if (newField) {
			try {
				await field.delete();
			} catch(e) {}
		}
		throw new Zotero.Exception.UserCancelled("inserting citation");
	}

	var fieldIndex = await fieldIndexPromise;
	// Make sure session is updated
	await citationsByItemIDPromise;
	
	let citations;
	try {
		citations = await this._insertCitingResult(fieldIndex, field, io.citation);
	}
	catch (e) {
		if (e instanceof Zotero.Exception.UserCancelled) {
			if (newField) {
				try {
					await field.delete();
				} catch(e) {}
			}
		}
		throw e;
	}
	if (!this.data.prefs.delayCitationUpdates) {
		if (citations.length != 1) {
			// We need to refetch fields because we've inserted multiple.
			// This is not super optimal, but you're inserting 2+ citations at the time,
			// so that sets it off
			var fields = await this.getFields(true);
		}
		// And resync citations with ones in the doc
		await this.updateFromDocument(FORCE_CITATIONS_FALSE);
	}
	for (let citation of citations) {
		if (fields) {
			citation.field = new Zotero.Integration.CitationField(fields[citation.fieldIndex]);
		}
		await this.addCitation(citation.fieldIndex, await citation.field.getNoteIndex(), citation);
	}
	return citations;
};

/**
 * Inserts a citing result, where a citing result is either multiple Items or a Note item.
 * Notes may contain Items in them, which means that
 * a single citing result insert can produce multiple Citations.
 *
 * Returns an array of Citation objects which correspond to inserted citations. At least 1 Citation
 * is always returned.
 *
 * @param fieldIndex
 * @param field
 * @param citation
 * @returns {Promise<[]>}
 * @private
 */
Zotero.Integration.Session.prototype._insertCitingResult = async function (fieldIndex, field, citation) {
	await citation.loadItemData();
	
	let allItems = citation.citationItems.map(item => Zotero.Cite.getItem(item.id));
	// Handle adding selected annotations as a mock note
	if (allItems.some(item => item.isAnnotation())) {
		if (!allItems.every(item => item.isAnnotation())) {
			throw new Error("Citing result with annotations must not include other item types");
		}
		let includeComments = Zotero.Prefs.get("integration.annotationDialogIncludeComments");
		// Note is created with embedded data to be inserted but nothing is saved to DB
		let mockNote = await Zotero.EditorInstance.createNoteFromAnnotations(
			allItems, { noSave: true, noHeader: true, noComments: !includeComments }
		);
		return this._insertNoteIntoDocument(fieldIndex, field, mockNote);
	}
	let firstItem = Zotero.Cite.getItem(citation.citationItems[0].id);
	if (firstItem && firstItem.isNote()) {
		return this._insertNoteIntoDocument(fieldIndex, field, firstItem);
	}
	else {
		return [await this._insertItemsIntoDocument(fieldIndex++, field, citation)];
	}
};

/**
 * Splits out cited items from the note text and converts them to placeholder links.
 *
 * Returns the modified note text and an array of citation objects and their corresponding
 * placeholder IDs
 * @param item {Zotero.Item}
 */
Zotero.Integration.Session.prototype._processNote = async function (item) {
	let text = await Zotero.Notes.getExportableNote(item);
	let parser = new DOMParser();
	let doc = parser.parseFromString(text, "text/html");
	let citationsElems = doc.querySelectorAll('.citation[data-citation]');
	let citations = [];
	let placeholderIDs = [];
	for (let citationElem of citationsElems) {
		try {
			// Add the citation code object to citations array
			let citation = JSON.parse(decodeURIComponent(citationElem.dataset.citation));
			delete citation.properties;
			citations.push(citation);
			let placeholderID = Zotero.Utilities.randomString(6);
			// Add the placeholder we'll be using for the link to placeholder array
			placeholderIDs.push(placeholderID);
			let placeholderURL = NOTE_CITATION_PLACEHOLDER_LINK + placeholderID;
			// Split out the citation element and replace with a placeholder link
			let startIndex = text.indexOf(citationElem.outerHTML);
			let endIndex = startIndex + citationElem.outerHTML.length;
			text = text.slice(0, startIndex)
				+ `<a href="${placeholderURL}">${citationElem.textContent}</a>`
				+ text.slice(endIndex);
		}
		catch (e) {
			e.message = `Failed to parse a citation from a note: ${decodeURIComponent(citationElem.dataset.citation)}`;
			Zotero.debug(e, 1);
			Zotero.logError(e);
		}
	}
	// Encode unicode chars
	let value = '';
	for (let char of text) {
		let code = char.codePointAt(0);
		value += code > 127 ? '&#' + code + ';' : char;
	}
	text = value.trim();
	
	if (text.length == 0) {
		throw new Zotero.Exception.UserCancelled("inserted empty note, cancelling");
	}
	
	if (!text.startsWith('<html>')) {
		text = `<html><body>${text}</body></html>`;
	}
	return [text, citations, placeholderIDs];
};

Zotero.Integration.Session.prototype._insertNoteIntoDocument = async function (fieldIndex, field, noteItem) {
	let [text, citations, placeholderIDs] = await this._processNote(noteItem);
	await field.delete();
	await this._doc.insertText(text);
	if (!citations.length) return [];
	
	// Do these in reverse order to ensure we don't get messy document edits
	placeholderIDs.reverse();
	citations.reverse();
	let fields = await this._doc.convertPlaceholdersToFields(placeholderIDs, this.data.prefs.noteType, this.data.prefs.fieldType);
	
	let insertedCitations = await Promise.all(fields.map(async (field, index) => {
		let citation = new Zotero.Integration.Citation(new Zotero.Integration.CitationField(field, 'TEMP'),
			citations[index]);
		citation.fieldIndex = fieldIndex + fields.length - 1 - index;
		return citation;
	}));
	return insertedCitations;
};

Zotero.Integration.Session.prototype._insertItemsIntoDocument = async function (fieldIndex, field, citation) {
	if (!field) {
		field = new Zotero.Integration.CitationField(await this.addField(true, fieldIndex));
	}
	citation.field = field;
	citation.fieldIndex = fieldIndex;
	return citation;
};

/**
 * Citation editing functions and propertiesaccessible to citationDialog.js
 */
Zotero.Integration.CitationEditInterface = function (items, sortable, fieldIndexPromise,
		citationsByItemIDPromise, previewFn){
	this.citation = items;
	this.sortable = sortable;
	this.previewFn = previewFn;
	this._fieldIndexPromise = fieldIndexPromise;
	this._citationsByItemIDPromise = citationsByItemIDPromise;
	
	// Not available in citationDialog.js if this unspecified
	this.wrappedJSObject = this;

	this._acceptDeferred = Zotero.Promise.defer();
	this._isAccepted = false;
	this.promise = this._acceptDeferred.promise;

	// Resolve when all data needed to run getItems() or sort() is loaded
	this.allCitedDataLoadedPromise = Promise.all([fieldIndexPromise, citationsByItemIDPromise]);
	this.isAllCitedDataLoaded = false;
	this.allCitedDataLoadedPromise.then(() => this.isAllCitedDataLoaded = true);
}

Zotero.Integration.CitationEditInterface.prototype = {
	/**
	 * Execute a callback with a preview of the given citation
	 * @return {Promise} A promise resolved with the previewed citation string
	 */
	preview: function () {
		return this.previewFn(this.citation);
	},
	
	/**
	 * Sort the citationItems within citation (depends on this.citation.properties.unsorted)
	 * @return {Promise} A promise resolved with the previewed citation string
	 */
	sort: function () {
		return this.preview();
	},
	
	/**
	 * Accept changes to the citation
	 * @param {Function} [progressCallback] A callback to be run when progress has changed.
	 *     Receives a number from 0 to 100 indicating current status.
	 */
	accept: function (progressCallback) {
		if (!this._isAccepted) {
			this._isAccepted = true;
			this._acceptDeferred.resolve(progressCallback);
		}
	},
	
	/**
	 * Cancel changes to the citation
	 */
	cancel: function () {
		this.citation.citationItems = [];
		return this.accept();
	},
	
	/**
	 * Get a list of items used in the current document
	 * @return {Promise} A promise resolved by the items
	 */
	getItems: async function () {
		var fieldIndex = await this._fieldIndexPromise;
		var citationsByItemID = await this._citationsByItemIDPromise;
		var ids = Object.keys(citationsByItemID).filter(itemID => {
			return citationsByItemID[itemID]
				&& citationsByItemID[itemID].length
				// Exclude the present item
				&& (citationsByItemID[itemID].length > 1
					|| citationsByItemID[itemID][0].properties.zoteroIndex !== fieldIndex);
		});
		
		// Sort all previously cited items at top, and all items cited later at bottom
		ids.sort(function (a, b) {
			var indexA = citationsByItemID[a][0].properties.zoteroIndex,
				indexB = citationsByItemID[b][0].properties.zoteroIndex;
			
			if (indexA >= fieldIndex){
				if(indexB < fieldIndex) return 1;
				return indexA - indexB;
			}
			
			if (indexB > fieldIndex) return -1;
			return indexB - indexA;
		});
		
		return Zotero.Cite.getItem(ids);
	},
}

/**
 * Resets per-request variables in the CitationSet
 */
Zotero.Integration.Session.prototype.resetRequest = function (doc) {
	this.uriMap = new Zotero.Integration.URIMap(this);
	
	this.bibliographyHasChanged = false;
	this.bibliographyDataHasChanged = false;

	// When processing citations this list will be checked for citations that are new to the document
	// (i.e. copied from somewhere else) and marked as newIndices to be processed with citeproc if
	// not present
	this.oldCitations = new Set();
	for (let i in this.citationsByIndex) {
		// But ignore indices from this.newIndices. If any are present it means that the last
		// call to this.updateFromDocument() was never followed up with this.updateDocument()
		// i.e. the operation was user cancelled
		if (i in this.newIndices) continue;
		this.oldCitations.add(this.citationsByIndex[i].citationID);
	}
	
	// After adding fields to the session
	// citations that are new to the document will be marked
	// as new,  so that they are correctly loaded into and processed with citeproc
	this.newIndices = {};
	// Citations that are not new to the session but where the item metadata
	// has changed will be marked in updateIndices
	this.updateIndices = {};
	// Forces all citations to be added to updateIndices, which runs them through
	// citeproc and updates in document if necessary
	this.forceUpdateAllCitations = false;
	// Reloads citeproc with all citation data.
	this.rebuildCiteprocState = false;
	
	this.processIndices = {};
	this.citationsByItemID = {};
	this.citationsByIndex = {};
	this.documentCitationIDs = {};
	
	this._doc = doc;
}

/**
 * Prepares session data and displays docPrefs dialog if needed
 * @param require {Boolean} Whether an error should be thrown if no preferences or fields
 *     exist (otherwise, the set doc prefs dialog is shown)
 * @param dontRunSetDocPrefs {Boolean} Whether to show the Document Preferences window if no preferences exist
 * @return {Promise{Boolean}} true if session ready to, false if preferences dialog needs to be displayed first
 */
Zotero.Integration.Session.prototype.init = async function (require, dontRunSetDocPrefs) {
	var data = this.data;
	var haveFields = false;
	
	let fieldTypes = [this._app.primaryFieldType, this._app.secondaryFieldType];
	if (data.prefs.fieldType) {
		fieldTypes = [data.prefs.fieldType];
	}
	
	// If prefs not present
	if (require) {
		// check to see if fields already exist
		for (let fieldType of fieldTypes) {
			var fields = await this._doc.getFields(fieldType);
			if (fields.length) {
				haveFields = true;
				break;
			}
		}
	}
		
	if (require && !haveFields) {
		// If required but no fields throw an error
		return Promise.reject(new Zotero.Exception.Alert(
			"integration.error.mustInsertCitation",
			[], "integration.error.title"));
	}
	if (!data.prefs.fieldType) {
		// Unless explicitly disabled
		if (dontRunSetDocPrefs) return false;

		// Show the doc prefs dialogue
		await this.setDocPrefs();
	}
	
	return true;
};

Zotero.Integration.Session.prototype.displayAlert = async function () {
	if (this.timer) {
		this.timer.pause();
	}
	var result = await this._doc.displayAlert.apply(this._doc, arguments);
	if (this.timer) {
		this.timer.resume();
	}
	return result;
}

/**
 * Changes the Session style and data
 * @param data {Zotero.Integration.DocumentData}
 * @param resetStyle {Boolean} Whether to force the style to be reset
 *     regardless of whether it has changed. This is desirable if the
 *     automaticJournalAbbreviations or locale has changed.
 */
Zotero.Integration.Session.prototype.setData = async function (data, resetStyle) {
	var oldStyle = (this.data && this.data.style ? this.data.style : false);
	this.data = data;
	this.data.sessionID = this.sessionID;
	if (data.style.styleID && (!oldStyle || oldStyle.styleID != data.style.styleID || resetStyle)) {
		try {
			await Zotero.Styles.init();
			var getStyle = Zotero.Styles.get(data.style.styleID);
			data.style.hasBibliography = getStyle.hasBibliography;
			if (this.style && this.style.free) {
				this.style.free();
			}
			this.style = getStyle.getCiteProc(data.style.locale, this.outputFormat, {
				automaticJournalAbbreviations: data.prefs.automaticJournalAbbreviations,
			});
			// Disable wrap_url_and_doi to prevent double-encoding of special characters in DOIs
			// https://github.com/zotero/zotero/issues/5557
			this.style.opt.development_extensions.wrap_url_and_doi = false;
			this.styleClass = getStyle.class;
			// We're changing the citeproc instance, so we'll have to reinsert all citations into the registry
			this.rebuildCiteprocState = true;
			this.styleID = data.style.styleID;
		} catch (e) {
			Zotero.logError(e);
			throw new Zotero.Exception.Alert("integration.error.invalidStyle");
		}
		
		return true;
	} else if (oldStyle) {
		data.style = oldStyle;
	}
	return false;
};

/**
 * Displays a dialog to set document preferences
 * @return {Promise} A promise resolved with old document data, if there was any or null,
 *    if there wasn't, or rejected with Zotero.Exception.UserCancelled if the dialog was
 *    cancelled.
 */
Zotero.Integration.Session.prototype.setDocPrefs = async function (showImportExport=false) {
	var io = new function () { this.wrappedJSObject = this; };
	io.primaryFieldType = this.primaryFieldType;
	io.secondaryFieldType = this.secondaryFieldType;
	io.showImportExport = false;
	
	if (this.data) {
		io.style = this.data.style.styleID;
		io.locale = this.data.style.locale;
		io.supportedNotes = this._app.supportedNotes;
		io.useEndnotes = this.data.prefs.noteType == 0 ? 0 : this.data.prefs.noteType-1;
		io.fieldType = this.data.prefs.fieldType;
		io.delayCitationUpdates = this.data.prefs.delayCitationUpdates;
		io.dontAskDelayCitationUpdates = this.data.prefs.dontAskDelayCitationUpdates;
		io.automaticJournalAbbreviations = this.data.prefs.automaticJournalAbbreviations;
		io.requireStoreReferences = !Zotero.Utilities.isEmpty(this.embeddedItems);
		io.showImportExport = showImportExport && this.data.prefs.fieldType && this._app.supportsImportExport;
	}
	
	// Make sure styles are initialized for new docs
	await Zotero.Styles.init();
	await Zotero.Integration.displayDialog('chrome://zotero/content/integration/integrationDocPrefs.xhtml', '', io, "documentPreferences");

	if (io.exportDocument) {
		return this.exportDocument();
	}
	
	if (!io.style || !io.fieldType) {
		this._dontActivateDocument = io.dontActivateDocument;
		throw new Zotero.Exception.UserCancelled("document preferences window");
	}
	
	// set data
	var oldData = this.data;
	var data = new Zotero.Integration.DocumentData();
	data.dataVersion = oldData.dataVersion;
	data.sessionID = oldData.sessionID;
	data.style.styleID = io.style;
	data.style.locale = io.locale;
	data.style.bibliographyStyleHasBeenSet = false;
	data.prefs = oldData ? Object.assign({}, oldData.prefs) : {};
	data.prefs.fieldType = io.fieldType;
	data.prefs.automaticJournalAbbreviations = io.automaticJournalAbbreviations;
	data.prefs.delayCitationUpdates = io.delayCitationUpdates
	
	var forceStyleReset = oldData
		&& (
			oldData.prefs.automaticJournalAbbreviations != data.prefs.automaticJournalAbbreviations
			|| oldData.style.locale != io.locale
		);
	await this.setData(data, forceStyleReset);

	// need to do this after setting the data so that we know if it's a note style
	this.data.prefs.noteType = this.style && this.styleClass == "note" ? io.useEndnotes+1 : 0;
	
	if (!oldData || oldData.style.styleID != data.style.styleID
			|| oldData.prefs.noteType != data.prefs.noteType
			|| oldData.prefs.fieldType != data.prefs.fieldType
			|| (!data.prefs.delayCitationUpdates && oldData.prefs.delayCitationUpdates != data.prefs.delayCitationUpdates)
			|| oldData.prefs.automaticJournalAbbreviations != data.prefs.automaticJournalAbbreviations) {
		this.forceUpdateAllCitations = true;
		this.rebuildCiteprocState = true;
	}
	
	return oldData || null;
}

Zotero.Integration.Session.prototype.exportDocument = async function () {
	Zotero.debug("Integration: Exporting the document");
	var timer = new Zotero.Integration.Timer();
	timer.start();
	try {
		this.data.style.bibliographyStyleHasBeenSet = false;
		await this._doc.setDocumentData(this.data.serialize());
		await this._doc.exportDocument(this.data.prefs.fieldType,
			Zotero.getString('integration.importInstructions'));
	} finally {
		Zotero.debug(`Integration: Export finished in ${timer.stop()}`);
	}
}


Zotero.Integration.Session.prototype.importDocument = async function () {
	const documentationURL = "https://www.zotero.org/support/kb/moving_documents_between_word_processors";
	
	var ps = Services.prompt;

	if (!this._app.supportsImportExport) {
		// Technically you will only reach this part in the code if getDocumentData returns
		// ZOTERO_TRANSFER_DOCUMENT, which is only viable for Word.
		// Let's add a parameter this changes later.
		ps.alert(null, Zotero.getString('integration.importDocument.title'),
			Zotero.getString('integration.importDocument.notAvailable', "Word"));
		return;
	}

	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);
	var result = ps.confirmEx(null,
		Zotero.getString('integration.importDocument.title'),
		Zotero.getString('integration.importDocument.description', [Zotero.clientName, this._app.processorName]),
		buttonFlags,
		Zotero.getString('integration.importDocument.button'),
		null,
		Zotero.getString('general.moreInformation'), null, {});
	if (result == 1) {
		throw new Zotero.Exception.UserCancelled("the document import");
	}
	if (result == 2) {
		Zotero.launchURL(documentationURL);
		throw new Zotero.Exception.UserCancelled("the document import");
	}
	Zotero.debug("Integration: Importing the document");
	var timer = new Zotero.Integration.Timer();
	timer.start();
	try {
		var importSuccessful = await this._doc.importDocument(this._app.primaryFieldType);
		if (!importSuccessful) {
			Zotero.debug("Integration: No importable data found in the document");
			return this.displayAlert("No importable data found", DIALOG_ICON_WARNING, DIALOG_BUTTONS_OK);
		}
		var data = new Zotero.Integration.DocumentData(await this._doc.getDocumentData());
		data.prefs.fieldType = this._app.primaryFieldType;
		await this.setData(data, true);
		await this.getFields(true);
		await this.updateFromDocument(FORCE_CITATIONS_RESET_TEXT);
		// Make sure we ignore the dont update flags since we do not know what the original text was
		for (let index in this.citationsByIndex) {
			delete this.citationsByIndex[index].properties.dontUpdate;
		}
		await this.updateDocument(FORCE_CITATIONS_RESET_TEXT, true, true);
	} finally {
		Zotero.debug(`Integration: Import finished in ${timer.stop()}`);
	}
	return data;
}

/**
 * Adds a citation to the arrays representing the document
 */
Zotero.Integration.Session.prototype.addCitation = async function (index, noteIndex, citation, adjacentCitations=[]) {
	index = parseInt(index, 10);
	
	if (adjacentCitations.length) {
		Zotero.debug(`Merging adjacent citations ${adjacentCitations.map(c => c.citationID)} to citation ${citation.citationID}`);
		for (let adjacentCitation of adjacentCitations) {
			citation.mergeCitation(adjacentCitation);
		}
		this.updateIndices[index] = true;
	}
	
	var action = await citation.loadItemData();
	
	if (action == Zotero.Integration.REMOVE_CODE) {
		// Mark for removal and return
		this._removeCodeFields[index] = true;
		return;
	} else if (action == Zotero.Integration.DELETE) {
		// Mark for deletion and return
		this._deleteFields[index] = true;
		return;
	} else if (action == Zotero.Integration.UPDATE) {
		this.updateIndices[index] = true;
	}
	// All new fields will initially be marked for deletion because they contain no
	// citationItems
	delete this._deleteFields[index];

	citation.properties.zoteroIndex = index;
	citation.properties.noteIndex = noteIndex;
	this.citationsByIndex[index] = citation;
	
	// add to citationsByItemID and citationsByIndex
	for (var i=0; i<citation.citationItems.length; i++) {
		var citationItem = citation.citationItems[i];
		if (!this.citationsByItemID[citationItem.id]) {
			this.citationsByItemID[citationItem.id] = [citation];
			this.bibliographyHasChanged = true;
		} else {
			var byItemID = this.citationsByItemID[citationItem.id];
			if (byItemID[byItemID.length-1].properties.zoteroIndex < index) {
				// if index is greater than the last index, add to end
				byItemID.push(citation);
			} else {
				// otherwise, splice in at appropriate location
				for (var j=0; byItemID[j].properties.zoteroIndex < index && j<byItemID.length-1; j++) {}
				byItemID.splice(j++, 0, citation);
			}
		}
	}
	
	// We need a new ID if there's another citation with the same citation ID in this document
	var duplicateIndex = this.documentCitationIDs[citation.citationID];
	var needNewID = !citation.citationID || duplicateIndex != undefined;
	if (needNewID) {
		if (duplicateIndex != undefined && duplicateIndex != index) {
			// If this is a duplicate, we need to mark both citations as "new"
			// since we do not know which one was the "original" one
			// and either one may need to be updated
			this.newIndices[duplicateIndex] = true;
		}
		Zotero.debug("Integration: "+citation.citationID+" ("+index+") needs new citationID");
		citation.citationID = Zotero.Utilities.randomString();
		this.newIndices[index] = true;
	}
	// Deal with citations that are copied into the document from somewhere else
	// and have not been added to the processor yet
	if (!this.oldCitations.has(citation.citationID)) {
		this.newIndices[index] = true;
	}
	if (this.forceUpdateAllCitations && !this.newIndices[index]) {
		this.updateIndices[index] = true;
	}
	Zotero.debug("Integration: Adding citationID "+citation.citationID);
	this.documentCitationIDs[citation.citationID] = index;
};

Zotero.Integration.Session.prototype.getCiteprocLists = function () {
	var citations = [];
	var fieldToCitationIdxMapping = {};
	var citationToFieldIdxMapping = {};
	var i = 0;
	// This relies on the order of citationsByIndex keys being stable and sorted in ascending order
	// Which it seems to currently be true for every modern JS engine, so we're probably fine
	for (let idx in this.citationsByIndex) {
		if (idx in this.newIndices) {
			continue;
		}
		citations.push([this.citationsByIndex[idx].citationID, this.citationsByIndex[idx].properties.noteIndex]);
		fieldToCitationIdxMapping[i] = parseInt(idx);
		citationToFieldIdxMapping[idx] = i++;
	}
	return [citations, fieldToCitationIdxMapping, citationToFieldIdxMapping];
}

/**
 * Updates the list of citations to be serialized to the document
 */
Zotero.Integration.Session.prototype._updateCitations = async function () {
	if (Zotero.Prefs.get('cite.useCiteprocRs')) {
		return this._updateCitationsCiteprocRs();
	}
	Zotero.debug("Integration: Indices of new citations");
	Zotero.debug(Object.keys(this.newIndices));
	Zotero.debug("Integration: Indices of updated citations");
	Zotero.debug(Object.keys(this.updateIndices));
	
	let citations, fieldToCitationIdxMapping, citationToFieldIdxMapping;
	for (let indexList of [this.newIndices, this.updateIndices]) {
		for (let index in indexList) {
			if (indexList == this.newIndices) {
				delete this.newIndices[index];
				delete this.updateIndices[index];
				[citations, fieldToCitationIdxMapping, citationToFieldIdxMapping] =
					this.getCiteprocLists()
			}
		
			// Jump to next event loop step for UI updates
			await Zotero.Promise.delay();
			index = parseInt(index);
			
			var citation = this.citationsByIndex[index];
			if (!citation) continue;
			citation = citation.toJSON();

			let citationsPre = citations.slice(0, citationToFieldIdxMapping[index]);
			var citationsPost = citations.slice(citationToFieldIdxMapping[index]+1);
			
			Zotero.debug("Integration: style.processCitationCluster("+citation.toSource()+", "+citationsPre.toSource()+", "+citationsPost.toSource());
			let [info, newCitations] = this.style.processCitationCluster(citation, citationsPre, citationsPost);
			
			this.bibliographyHasChanged |= info.bibchange;
			
			for (let citationInfo of newCitations) {
				let idx = fieldToCitationIdxMapping[citationInfo[0]], text = citationInfo[1];
				this.processIndices[idx] = true;
				this.citationsByIndex[idx].text = text;
			}
		}
		[citations, fieldToCitationIdxMapping, citationToFieldIdxMapping] =
			this.getCiteprocLists();
	}
}


/**
 * Updates the list of citations to be serialized to the document with citeproc-rs
 */
Zotero.Integration.Session.prototype._updateCitationsCiteprocRs = async function () {
	Zotero.debug("Integration: Indices of new citations");
	Zotero.debug(Object.keys(this.newIndices));
	Zotero.debug("Integration: Indices of updated citations");
	Zotero.debug(Object.keys(this.updateIndices));

	for (let indexList of [this.newIndices, this.updateIndices]) {
		for (let index in indexList) {
			if (indexList == this.newIndices) {
				delete this.newIndices[index];
				delete this.updateIndices[index];
			}

			var citation = this.citationsByIndex[index];
			citation = citation.toJSON();

			Zotero.debug(`Integration: citeprocRs.insertCluster(${citation.toSource()})`);
			this.style.insertCluster(citation);
		}
	}
	
	let citationIDToIndex = {};
	for (const key in this.citationsByIndex) {
		citationIDToIndex[this.citationsByIndex[key].citationID] = key;
	}

	const citations = this.getCiteprocLists()[0];
	Zotero.debug("Integration: citeprocRs.setClusterOrder()");
	this.style.setClusterOrder(citations);
	Zotero.debug("Integration: citeprocRs.getBatchedUpdates()");
	const updateSummary = this.style.getBatchedUpdates();
	Zotero.debug("Integration: got UpdateSummary from citeprocRs");
	for (const [citationID, text] of updateSummary.clusters) {
		const index = citationIDToIndex[citationID];
		this.citationsByIndex[index].text = text;
		this.processIndices[index] = true;
	}

	this.bibliographyHasChanged |= updateSummary.bibliography
		&& Object.keys(updateSummary.bibliography.updatedEntries).length;
}

/**
 * Restores processor state from document, without requesting citation updates
 */
Zotero.Integration.Session.prototype.restoreProcessorState = function () {
	if (this._bibliographyFields.length && !this.bibliography) {
		throw new Error ("Attempting to restore processor state without loading bibliography");
	}
	let uncited = [];
	if (this.bibliography) {
		uncited = Array.from(this.bibliography.uncitedItemIDs.values());
	}
	
	var citations = [];
	for(var i in this.citationsByIndex) {
		if(this.citationsByIndex[i] && !this.newIndices[i]) {
			citations.push(this.citationsByIndex[i]);
		}
	}
	if (!Zotero.Prefs.get('cite.useCiteprocRs')) {
		// rebuildProcessorState() doesn't reset the disambiguation cache when
		// passed a non-empty citation list. This causes items to be disambiguated
		// even after being modified so they're no longer ambiguous. Work around
		// this by manually clearing the item list first.
		this.style.updateItems([]);
	}
	this.style.rebuildProcessorState(citations, this.outputFormat, uncited);
}


Zotero.Integration.Session.prototype.writeDelayedCitation = async function (field, citation) {
	try {
		var text = citation.properties.custom || this.style.previewCitationCluster(citation, [], [], this.outputFormat);
	}
	catch (e) {
		throw e;
	}
	if (this.outputFormat == 'rtf') {
		text = `${DELAYED_CITATION_RTF_STYLING}{${text}}`;
	}
	else {
		text = `${DELAYED_CITATION_HTML_STYLING}${text}${DELAYED_CITATION_HTML_STYLING_END}`;
	}
	
	// Make sure we'll prompt for manually edited citations
	if (!citation.properties.dontUpdate) {
		await field.setText(text);
		
		citation.properties.formattedCitation = text;
		citation.properties.plainCitation = await field._field.getText();
	}
	
	await field.setCode(citation.serialize());
	
	// Update citationsByItemID for later cited item display
	for (var i=0; i<citation.citationItems.length; i++) {
		var citationItem = citation.citationItems[i];
		if (!this.citationsByItemID[citationItem.id]) {
			this.citationsByItemID[citationItem.id] = [citation];
		} else {
			this.citationsByItemID[citationItem.id].push(citation);
		}
	}
	
	// Update bibliography with a static string
	// Note: fields._fields will only contain items upon first delayed insert
	// after a full update
	if (this._sessionUpToDate) {
		var fields = await this.getFields();
		for (let i = fields.length - 1; i >= 0; i--) {
			let field = await Zotero.Integration.Field.loadExisting(fields[i]);
			if (field.type == INTEGRATION_TYPE_BIBLIOGRAPHY) {
				var interfaceType = 'tab';
				if (['MacWord2008', 'OpenOffice'].includes(this.agent)) {
					interfaceType = 'toolbar';
				}
			
				await field.setText(Zotero.getString(`integration.delayCitationUpdates.bibliography.${interfaceType}`), false);
				break;
			}
		}
	}
	this._sessionUpToDate = false;
};


Zotero.Integration.Session.prototype.getItems = function (itemIDs) {
	itemIDs = itemIDs || Object.keys(this.citationsByItemID);
	return Zotero.Cite.getItem(itemIDs);
}

Zotero.Integration.Session.prototype.handleRetractedItems = async function () {
	const dealWithRetracted = (citedItem, inLibrary) => {
		let dontPromptAgain = this.promptForRetraction(citedItem, inLibrary);
		if (dontPromptAgain) {
			if (citedItem.id) {
				Zotero.Retractions.disableCitationWarningsForItem(citedItem);
			}
			let itemID = citedItem.id || citedItem.cslItemID;
			for (let citation of this.citationsByItemID[itemID]) {
				for (let item of citation.citationItems) {
					if (item.id == itemID || item.cslItemID == itemID) {
						item.ignoreRetraction = true;
						this.processIndices[this.documentCitationIDs[citation.citationID]] = true;
					}
				}
			}
		}
	};
	let zoteroItems = this.getItems();
	let embeddedZoteroItems = [];
	for (let zoteroItem of zoteroItems) {
		let itemID = zoteroItem.id || zoteroItem.cslItemID;
		let citation = this.citationsByItemID[itemID][0];
		let citationItem = citation.citationItems.find(i => i.id == itemID);
		if (!citationItem.ignoreRetraction) {
			if (zoteroItem.cslItemID) {
				embeddedZoteroItems.push(zoteroItem);
			}
			else if (Zotero.Retractions.shouldShowCitationWarning(zoteroItem)) {
				dealWithRetracted(zoteroItem, true);
			}
		}
	}
	var retractedIndices = await Promise.race([Zotero.Retractions.getRetractionsFromJSON(
		embeddedZoteroItems.map(item => item.toJSON())
	), Zotero.Promise.delay(1000).then(() => [])]);
	for (let index of retractedIndices) {
		dealWithRetracted(embeddedZoteroItems[index]);
	}
};

Zotero.Integration.Session.prototype.promptForRetraction = function (citedItem, inLibrary) {
	let ps = Services.prompt;
	let buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_OK);
	// Cannot use citedItem.firstCreator since embedded items do not have that
	let creator = citedItem.getCreator(0);
	let year = citedItem.getField('year');
	let itemString = (creator ? creator.lastName + ", " : "")
		+ (year ? year + ", " : "")
		+ citedItem.getDisplayTitle();
	let promptText = Zotero.getString('retraction.citationWarning')
		+ "\n\n"
		+ itemString;
	if (inLibrary) {
		promptText += "\n\n" + Zotero.getString('retraction.citeWarning.text2');
	}
	let checkbox = { value: false };
	ps.confirmEx(null,
		Zotero.getString('general.warning'),
		promptText,
		buttonFlags,
		null, null, null,
		Zotero.getString('retraction.citationWarning.dontWarn'), checkbox);
	
	return checkbox.value;
}

/**
 * Opens the citation explorer
 */
Zotero.Integration.Session.prototype.openCitationExplorer = async function () {
	if (!Object.keys(this.citationsByIndex).length) {
		throw new Error('Integration.Session.openCitationExplorer: called without loaded citations');
	}
	
	let io = {
		citations: this.citationsByIndex,
		uncitedItems: this.bibliography ? Array.from(await this.getItems(Array.from(this.bibliography.uncitedItemIDs))) : [],
		items: await this.getItems(),
		activateDocument: async () => this._doc.activate(),
		selectCitation: async citation => citation.field.select(),
		cursorInCitation: async (citation) => {
			const field = await this._doc.cursorInField(this.data.prefs['fieldType']);
			if (!field) return false;
			const citationField = await Zotero.Integration.Field.loadExisting(field);
			const data = await citationField.unserialize();
			return data.citationID === citation.citationID;
		},
		updateIndex: index => this.updateIndices[index] = true
	};
	
	await Zotero.Integration.displayDialog('chrome://zotero/content/integration/citationExplorer.xhtml', 'resizable', io);
	
	if (io.openCitationDialog) {
		let citations = await this.cite(io.openCitationDialog);
		if (this.data.prefs.delayCitationUpdates) {
			for (let citation of citations) {
				await this.writeDelayedCitation(citation.field, citation);
			}
		} else {
			return this.updateDocument(FORCE_CITATIONS_FALSE, false, false);
		}
	}
};


/**
 * Edits integration bibliography
 * @param {Zotero.Integration.Bibliography} bibliography
 */
Zotero.Integration.Session.prototype.editBibliography = async function (bibliography) {
	if (!Object.keys(this.citationsByIndex).length) {
		throw new Error('Integration.Session.editBibliography: called without loaded citations');
	}
	// Update citeproc with citations in the doc
	await this._updateCitations();
	await bibliography.loadItemData();
	await bibliography.getCiteprocBibliography(this.style);
	
	var bibliographyEditor = new Zotero.Integration.BibliographyEditInterface(bibliography, this.citationsByItemID, this.style);
	
	await Zotero.Integration.displayDialog('chrome://zotero/content/integration/editBibliographyDialog.xhtml', 'resizable', bibliographyEditor, "bibliography");
	if (bibliographyEditor.cancelled) throw new Zotero.Exception.UserCancelled("bibliography editing");
	
	this.bibliographyDataHasChanged = this.bibliographyHasChanged = true;
	this.bibliography = bibliographyEditor.bibliography;
};

/**
 * @class Interface for bibliography editor to alter document bibliography
 * @constructor
 * Creates a new bibliography editor interface
 * @param bibliography {Zotero.Integration.Bibliography}
 */
Zotero.Integration.BibliographyEditInterface = function (bibliography, citationsByItemID, citeproc) {
	this.bibliography = bibliography;
	this.citeproc = citeproc;
	this.wrappedJSObject = this;
	this._citationsByItemID = citationsByItemID;
	this._update();
}


Zotero.Integration.BibliographyEditInterface.prototype._update = function () {
	this.bib = this.bibliography.getCiteprocBibliography(this.citeproc);
};

/**
 * Reverts the text of an individual bibliography entry
 */
Zotero.Integration.BibliographyEditInterface.prototype.revert = function (itemID) {
	delete this.bibliography.customEntryText[itemID];
	return this._update();
}

/**
 * Reverts bibliography to condition in which no edits have been made
 */
Zotero.Integration.BibliographyEditInterface.prototype.revertAll = function () {
	this.bibliography.customEntryText = {};
	this.bibliography.uncitedItemIDs.clear();
	this.bibliography.omittedItemIDs.clear();
	return this._update();
};

/**
 * Reverts bibliography to condition before BibliographyEditInterface was opened
 */
Zotero.Integration.BibliographyEditInterface.prototype.cancel = function () { 
	this.cancelled = true;
};

/**
 * Checks whether a given reference is cited within the main document text
 */
Zotero.Integration.BibliographyEditInterface.prototype.isCited = function (item) {
	return this._citationsByItemID[item];
}

/**
 * Checks whether an item ID is cited in the bibliography being edited
 */
Zotero.Integration.BibliographyEditInterface.prototype.isEdited = function (itemID) {
	return itemID in this.bibliography.customEntryText;
}

/**
 * Checks whether any citations in the bibliography have been edited
 */
Zotero.Integration.BibliographyEditInterface.prototype.isAnyEdited = function () {
	return Object.keys(this.bibliography.customEntryText).length ||
		this.bibliography.uncitedItemIDs.size ||
		this.bibliography.omittedItemIDs.size;
}

/**
 * Adds an item to the bibliography
 */
Zotero.Integration.BibliographyEditInterface.prototype.add = function (itemID) {
	if (this.bibliography.omittedItemIDs.has(`${itemID}`)) {
		this.bibliography.omittedItemIDs.delete(`${itemID}`);
	} else {
		this.bibliography.uncitedItemIDs.add(`${itemID}`);
	}
	return this._update();
}

/**
 * Removes an item from the bibliography being edited
 */
Zotero.Integration.BibliographyEditInterface.prototype.remove = function (itemID) {
	if (this.bibliography.uncitedItemIDs.has(`${itemID}`)) {
		this.bibliography.uncitedItemIDs.delete(`${itemID}`);
	} else {
		this.bibliography.omittedItemIDs.add(`${itemID}`);
	}
	return this._update();
}

/**
 * Sets custom bibliography text for a given item
 */
Zotero.Integration.BibliographyEditInterface.prototype.setCustomText = function (itemID, text) {
	this.bibliography.customEntryText[itemID] = text;
	return this._update();
}

/**
 * A class for parsing and passing around document-specific data
 */
Zotero.Integration.DocumentData = function (string) {
	this.style = {};
	this.prefs = {};
	this.sessionID = null;
	if (string) {
		this.unserialize(string);
	}
}

/**
 * Serializes document-specific data as JSON
 */
Zotero.Integration.DocumentData.prototype.serialize = function () {
	// If we've retrieved data with version 4 (JSON), serialize back to JSON
	if (this.dataVersion == 4) {
		// Filter style properties
		let style = {};
		for (let prop of ['styleID', 'locale', 'hasBibliography', 'bibliographyStyleHasBeenSet']) {
			style[prop] = this.style[prop];
		}
		return JSON.stringify({
			style,
			prefs: this.prefs,
			sessionID: this.sessionID,
			zoteroVersion: Zotero.version,
			dataVersion: 4
		});
	}
	// Otherwise default to XML for now
	var prefs = "";
	for (var pref in this.prefs) {
		if (!this.prefs[pref]) continue;
		prefs += `<pref name="${Zotero.Utilities.htmlSpecialChars(pref)}" `+
			`value="${Zotero.Utilities.htmlSpecialChars(this.prefs[pref].toString())}"/>`;
	}
	
	return '<data data-version="'+Zotero.Utilities.htmlSpecialChars(`${DATA_VERSION}`)+'" '+
		'zotero-version="'+Zotero.Utilities.htmlSpecialChars(Zotero.version)+'">'+
			'<session id="'+Zotero.Utilities.htmlSpecialChars(this.sessionID)+'"/>'+
		'<style id="'+Zotero.Utilities.htmlSpecialChars(this.style.styleID)+'" '+
			(this.style.locale ? 'locale="' + Zotero.Utilities.htmlSpecialChars(this.style.locale) + '" ': '') +
			'hasBibliography="'+(this.style.hasBibliography ? "1" : "0")+'" '+
			'bibliographyStyleHasBeenSet="'+(this.style.bibliographyStyleHasBeenSet ? "1" : "0")+'"/>'+
		(prefs ? '<prefs>'+prefs+'</prefs>' : '<prefs/>')+'</data>';
};

/**
 * Unserializes document-specific XML
 */
Zotero.Integration.DocumentData.prototype.unserializeXML = function (xmlData) {
	var parser = new DOMParser(),
		doc = parser.parseFromString(xmlData, "application/xml");
	
	this.sessionID = Zotero.Utilities.xpathText(doc, '/data/session[1]/@id');
	this.style = {"styleID":Zotero.Utilities.xpathText(doc, '/data/style[1]/@id'),
		"locale":Zotero.Utilities.xpathText(doc, '/data/style[1]/@locale'),
		"hasBibliography":(Zotero.Utilities.xpathText(doc, '/data/style[1]/@hasBibliography') == 1),
		"bibliographyStyleHasBeenSet":(Zotero.Utilities.xpathText(doc, '/data/style[1]/@bibliographyStyleHasBeenSet') == 1)};
	this.prefs = {};
	for (let pref of Zotero.Utilities.xpath(doc, '/data/prefs[1]/pref')) {
		var name = pref.getAttribute("name");
		var value = pref.getAttribute("value");
		if(value === "true") {
			value = true;
		} else if(value === "false") {
			value = false;
		}
		
		this.prefs[name] = value;
	}
	
	this.prefs.noteType = parseInt(this.prefs.noteType) || 0;
	if (this.prefs["automaticJournalAbbreviations"] === undefined) this.prefs["automaticJournalAbbreviations"] = false;
	this.zoteroVersion = doc.documentElement.getAttribute("zotero-version");
	if (!this.zoteroVersion) this.zoteroVersion = "2.0";
	this.dataVersion = doc.documentElement.getAttribute("data-version");
	if (!this.dataVersion) this.dataVersion = 2;
};

/**
 * Unserializes document-specific data, either as XML or as the string form used previously
 */
Zotero.Integration.DocumentData.prototype.unserialize = function (input) {
	try {
		return Object.assign(this, JSON.parse(input))
	} catch (e) {
		if (!(e instanceof SyntaxError)) {
			throw e;
		}
	}
	if (input[0] == "<") {
		this.unserializeXML(input);
	} else {
		const splitRe = /(^|[^:]):(?!:)/;
		
		var splitOutput = input.split(splitRe);
		var prefParameters = [];
		for(var i=0; i<splitOutput.length; i+=2) {
			prefParameters.push((splitOutput[i]+(splitOutput[i+1] ? splitOutput[i+1] : "")).replace("::", ":", "g"));
		}
		
		this.sessionID = prefParameters[0];
		this.style = {"styleID":prefParameters[1], 
			"hasBibliography":(prefParameters[3] == "1" || prefParameters[3] == "True"),
			"bibliographyStyleHasBeenSet":false};
		this.prefs = {"fieldType":((prefParameters[5] == "1" || prefParameters[5] == "True") ? "Bookmark" : "Field")};
		if(prefParameters[2] == "note") {
			if(prefParameters[4] == "1" || prefParameters[4] == "True") {
				this.prefs.noteType = NOTE_ENDNOTE;
			} else {
				this.prefs.noteType = NOTE_FOOTNOTE;
			}
		} else {
			this.prefs.noteType = 0;
		}
		
		this.zoteroVersion = "2.0b6 or earlier";
		this.dataVersion = 1;
	}
}

/**
 * Handles mapping of item IDs to URIs
 */
Zotero.Integration.URIMap = function (session) {
	this.itemIDURIs = {};
	this.session = session;
}

/**
 * Adds a given mapping to the URI map
 */
Zotero.Integration.URIMap.prototype.add = function (id, uris) {
	this.itemIDURIs[id] = uris;
}

/**
 * Gets URIs for a given item ID, and adds to map
 */
Zotero.Integration.URIMap.prototype.getURIsForItemID = function (id) {
	if(typeof id === "string" && id.indexOf("/") !== -1) {
		return Zotero.Cite.getItem(id).cslURIs;
	}
	
	if(!this.itemIDURIs[id]) {
		this.itemIDURIs[id] = [Zotero.URI.getItemURI(Zotero.Items.get(id))];
	}
	
	return this.itemIDURIs[id];
}

/**
 * Gets Zotero item for a given set of URIs
 */
Zotero.Integration.URIMap.prototype.getZoteroItemForURIs = async function (uris) {
	var zoteroItem = false;
	var needUpdate = false;
	var embeddedItem = false;;
	
	for(var i=0, n=uris.length; i<n; i++) {
		var uri = uris[i];
		
		// First try embedded URI
		if(this.session.embeddedItemsByURI[uri]) {
			embeddedItem = this.session.embeddedItemsByURI[uri];
		}
		
		// Next try getting URI directly
		try {
			var replacer = await Zotero.URI.getURIItem(uri);
			if (replacer && !replacer.deleted) {
				zoteroItem = replacer;
				break;
			}
		} catch(e) {}
		
		// Try merged item mapping
		var replacer = await Zotero.Relations.getByPredicateAndObject(
			'item', Zotero.Relations.replacedItemPredicate, uri
		);
		if (replacer.length && !replacer[0].deleted) {
			zoteroItem = replacer[0];
			break;
		}
		
		// Check if it's a mendeley URI and if we have imported the item
		let m = MENDELEY_URI_RE.exec(uri);
		if (m) {
			replacer = await Zotero.Relations.getByPredicateAndObject(
				'item', 'mendeleyDB:documentUUID', m[1]
			);
			if (replacer.length) {
				if (!replacer[0].deleted) {
					zoteroItem = replacer[0];
					break;
				}
			}
			// If not blocked by user having pressed skip in this session,
			// or user having checked the checkbox to not be prompted about this,
			// or user having imported their library with the new version of importer
			else if (!(this.session.dontPromptForMendeley
				|| Zotero.Prefs.get('integration.dontPromptMendeleyImport')
				|| (await Zotero.DB.valueQueryAsync("SELECT value FROM settings WHERE setting='mendeleyImport' AND key='version'"))
			)) {
				// Prompt user to (re)import their mendeley database which might make us recognize
				// these items
				let checkbox = {};
				let result = Zotero.Prompt.confirm({
					title: Zotero.getString('integration.mendeleyImport.title'),
					text: Zotero.getString('integration.mendeleyImport.description', [Zotero.appName]),
					button0: Zotero.getString('integration.mendeleyImport.openImporter'),
					button1: Zotero.getString('general.skip'),
					checkLabel: Zotero.getString('general.dontAskAgain'),
					checkbox
				});
				if (result === 0) {
					setTimeout(
						() => Zotero.getMainWindow().Zotero_File_Interface.showImportWizard(
							{
								pageID: 'page-mendeley-online-intro',
								relinkOnly: true
							}
						)
					);
					throw new Zotero.Exception.UserCancelled("Importing mendeley citations");
				}
				else {
					this.session.dontPromptForMendeley = true;
				}
				if (checkbox.value) {
					Zotero.Prefs.set('integration.dontPromptMendeleyImport', true);
				}
			}
		};
		
	}
	
	if (zoteroItem) {
		// make sure URI is up to date (in case user just began syncing)
		var newURI = Zotero.URI.getItemURI(zoteroItem);
		if (!uris.includes(newURI)) {
			uris.push(newURI);
			needUpdate = true;
		}
		// cache uris
		this.add(zoteroItem.id, uris)
	}
	else if (embeddedItem) {
		return [embeddedItem, false];
	}
	
	return [zoteroItem, needUpdate];
};

Zotero.Integration.Field = class {
	constructor(field, rawCode) {
		if (field instanceof Zotero.Integration.Field) {
			throw new Error("Trying to instantiate Integration.Field with Integration.Field, not doc field");
		}
		for (let func of Zotero.Integration.Field.INTERFACE) {
			if (!(func in this) && (func in field)) {
				this[func] = field[func].bind(field);
			}
		}
		this._field = field;
		this._code = rawCode;
		this.type = INTEGRATION_TYPE_TEMP;
	}
	
	async setCode(code) {
		// Boo. Inconsistent order.
		if (this.type == INTEGRATION_TYPE_ITEM) {
			await this._field.setCode(`ITEM CSL_CITATION ${code}`);
		} else if (this.type == INTEGRATION_TYPE_BIBLIOGRAPHY) {
			await this._field.setCode(`BIBL ${code} CSL_BIBLIOGRAPHY`);
		} else {
			await this._field.setCode(`TEMP`);
		}
		this._code = code;
	}

	getCode() {
		if (!this._code) {
			this._code = this._field.getCode();
		}
		let start = this._code.indexOf('{');
		if (start == -1) {
			return '{}';
		}
		return this._code.substring(start, this._code.lastIndexOf('}')+1);
	}

	async clearCode() {
		return await this.setCode('{}');
	}
	
	async getText() {
		if (this._text) {
			return this._text;
		}
		this._text = await this._field.getText();
		return this._text;
	}
		
	async setText(text) {
		this._text = null;
		var isRich = false;
		// If RTF wrap with RTF tags
		if (Zotero.Integration.currentSession.outputFormat == "rtf" && text.includes("\\")) {
			if (text.substr(0,5) != "{\\rtf") {
				text = "{\\rtf "+text+"}";
			}
			isRich = true;
		}
		await this._field.setText(text, isRich);
		return isRich;
	}
};
Zotero.Integration.Field.INTERFACE = ['delete', 'removeCode', 'select', 'setText',
	'getText', 'setCode', 'getCode', 'equals', 'getNoteIndex', 'isAdjacentToNextField'];

/**
 * Load existing field in document and return correct instance of field type
 * @param docField
 * @param rawCode
 * @param idx
 * @returns {Zotero.Integration.Field|Zotero.Integration.CitationField|Zotero.Integration.BibliographyField}
 */
Zotero.Integration.Field.loadExisting = async function (docField) {
	var field;
	// Already loaded
	if (docField instanceof Zotero.Integration.Field) return docField;
	let rawCode = await docField.getCode();
	
	// ITEM/CITATION CSL_ITEM {json: 'data'} 
	for (let type of ["ITEM", "CITATION"]) {
		if (rawCode.substr(0, type.length) === type) {
			field = new Zotero.Integration.CitationField(docField, rawCode);
		}
	}
	// BIBL {json: 'data'} CSL_BIBLIOGRAPHY
	if (rawCode.substr(0, 4) === "BIBL") {
		field = new Zotero.Integration.BibliographyField(docField, rawCode);
	}
	
	if (!field) {
		field = new Zotero.Integration.Field(docField, rawCode);
	}
	
	return field;
};

/**
 * Adds a citation based on a serialized Word field
 */
Zotero.Integration._oldCitationLocatorMap = {
	p:"page",
	g:"paragraph",
	l:"line"
};


Zotero.Integration.CitationField = class extends Zotero.Integration.Field {
	constructor(field, rawCode) {
		super(field, rawCode);
		this.type = INTEGRATION_TYPE_ITEM;
	}
	
	/**
	 * Don't be fooled, this should be as simple as JSON.parse().
	 * The schema for the code is defined @ https://raw.githubusercontent.com/citation-style-language/schema/master/csl-citation.json
	 *
	 * However, over the years and different versions of Zotero there's been changes to the schema,
	 * incorrect serialization, etc. Therefore this function is cruft-full and we can't get rid of it.
	 *
	 * @returns {{citationItems: Object[], properties: Object}}
	 */
	async unserialize() {
		function unserialize(code) {
			try {
				return JSON.parse(code);
			} catch(e) {
				// fix for corrupted fields (corrupted by 2.1b1)
				return JSON.parse(code.replace(/{{((?:\s*,?"unsorted":(?:true|false)|\s*,?"custom":"(?:(?:\\")?[^"]*\s*)*")*)}}/, "{$1}"));
			}
		}
		
		function upgradeCruft(citation, code) {
			// fix for uppercase citation codes
			if(citation.CITATIONITEMS) {
				citation.citationItems = [];
				for (var i=0; i<citation.CITATIONITEMS.length; i++) {
					for (var j in citation.CITATIONITEMS[i]) {
						switch (j) {
							case 'ITEMID':
								var field = 'itemID';
								break;

							// 'position', 'custom'
							default:
								var field = j.toLowerCase();
						}
						if (!citation.citationItems[i]) {
							citation.citationItems[i] = {};
						}
						citation.citationItems[i][field] = citation.CITATIONITEMS[i][j];
					}
				}
			}

			// Fix for Mac Word "All Caps" formatting corrupting field codes.
			// All text including JSON keys and string values gets uppercased,
			// but numbers/booleans are unaffected. We recover items via their
			// integer ID and discard corrupted URIs/itemData.
			// Distinct from old Zotero 2.x uppercase which only affected CITATIONITEMS.
			if (citation.CITATIONID) {
				Zotero.debug("Integration: Recovering citation field corrupted by All Caps formatting");
				citation.citationID = citation.CITATIONID;
				delete citation.CITATIONID;
				
				if (citation.PROPERTIES) {
					if (!citation.properties) {
						citation.properties = {};
					}
					// Preserve non-string values (unaffected by All Caps).
					// Don't preserve dontUpdate -- we need to update since the
					// presentation is corrupted.
					if ('NOTEINDEX' in citation.PROPERTIES) {
						citation.properties.noteIndex = citation.PROPERTIES.NOTEINDEX;
					}
					if ('UNSORTED' in citation.PROPERTIES) {
						citation.properties.unsorted = citation.PROPERTIES.UNSORTED;
					}
					delete citation.PROPERTIES;
				}
				
				// Delete corrupted URIs and itemData from citation items.
				// loadItemData() will fall through to Zotero.Items.get(citationItem.id)
				// for resolution, or handleMissingItem() if the item isn't found.
				for (let citationItem of citation.citationItems) {
					delete citationItem.uris;
					// Existing CITATIONITEMS handling lowercases ITEMDATA to 'itemdata'
					delete citationItem.itemdata;
					delete citationItem.itemData;
				}
				
				delete citation.SCHEMA;
			}

			if(!citation.properties) citation.properties = {};

			for (let citationItem of citation.citationItems) {
				// for upgrade from Zotero 2.0 or earlier
				if(citationItem.locatorType) {
					citationItem.label = citationItem.locatorType;
					delete citationItem.locatorType;
				} else if(citationItem.suppressAuthor) {
					citationItem["suppress-author"] = citationItem["suppressAuthor"];
					delete citationItem.suppressAuthor;
				}

				// fix for improper upgrade from Zotero 2.1 in <2.1.5
				if(parseInt(citationItem.label) == citationItem.label) {
					const locatorTypeTerms = ["page", "book", "chapter", "column", "figure", "folio",
						"issue", "line", "note", "opus", "paragraph", "part", "section", "sub verbo",
						"volume", "verse"];
					citationItem.label = locatorTypeTerms[parseInt(citationItem.label)];
				}

				// for update from Zotero 2.1 or earlier
				if (citationItem.uri) {
					if (Array.isArray(citationItem.uris)) {
						citationItem.uris = Array.from(new Set(citationItem.uris.concat(citationItem.uri)));
					}
					else {
						citationItem.uris = citationItem.uri;
					}
					delete citationItem.uri;
				}
			}

			// for upgrade from Zotero 2.0 or earlier
			if(citation.sort) {
				citation.properties.unsorted = !citation.sort;
				delete citation.sort;
			}
			if(citation.custom) {
				citation.properties.custom = citation.custom;
				delete citation.custom;
			}

			citation.properties.field = code;	
			return citation;
		}
		
		function unserializePreZotero1_0(code) {
			var underscoreIndex = code.indexOf("_");
			var itemIDs = code.substr(0, underscoreIndex).split("|");

			var lastIndex = code.lastIndexOf("_");
			if(lastIndex != underscoreIndex+1) {
				var locatorString = code.substr(underscoreIndex+1, lastIndex-underscoreIndex-1);
				var locators = locatorString.split("|");
			}

			var citationItems = new Array();
			for(var i=0; i<itemIDs.length; i++) {
				var citationItem = {id:itemIDs[i]};
				if(locators) {
					citationItem.locator = locators[i].substr(1);
					citationItem.label = Zotero.Integration._oldCitationLocatorMap[locators[i][0]];
				}
				citationItems.push(citationItem);
			}

			return {"citationItems":citationItems, properties:{}};
		}


		let code = this.getCode();
		try {
			if (code[0] == '{') {		// JSON field
				return upgradeCruft(unserialize(code), code);
			} else {				// ye olde style field
				return unserializePreZotero1_0(code);
			}
		} catch (e) {
			return this.resolveCorrupt(code);
		}
	}
	
	async clearCode() {
		await this.setCode(JSON.stringify({citationItems: [], properties: {}}));
	}
		
	async resolveCorrupt(code) {
		Zotero.debug(`Integration: handling corrupt citation field ${code}`);
		var msg = Zotero.getString("integration.corruptField")+'\n\n'+
				  Zotero.getString('integration.corruptField.description');
		await this.select();
		Zotero.Integration.currentDoc.activate();
		var result = await Zotero.Integration.currentSession.displayAlert(msg, DIALOG_ICON_CAUTION, DIALOG_BUTTONS_YES_NO_CANCEL);
		if (result == 0) { // Cancel
			throw new Zotero.Exception.UserCancelled("corrupt citation resolution");
		} else if (result == 1) {		// No
			return false;
		} else { // Yes
			// Clear current code and subsequent addEditCitation dialog will be the reselection
			await this.clearCode();
			return this.unserialize();
		}
	}
};


Zotero.Integration.BibliographyField = class extends Zotero.Integration.Field {
	constructor(field, rawCode) {
		super(field, rawCode);
		this.type = INTEGRATION_TYPE_BIBLIOGRAPHY;
	};
	
	async unserialize() {
		var code = this.getCode();
		try {
			return JSON.parse(code);
		} catch(e) {
			return this.resolveCorrupt(code);
		}
	}
	async resolveCorrupt(code) {
		Zotero.debug(`Integration: handling corrupt bibliography field ${code}`);
		var msg = Zotero.getString("integration.corruptBibliography")+'\n\n'+
				  Zotero.getString('integration.corruptBibliography.description');
		var result = await Zotero.Integration.currentSession.displayAlert(msg, DIALOG_ICON_CAUTION, DIALOG_BUTTONS_OK_CANCEL);
		if (result == 0) {
			throw new Zotero.Exception.UserCancelled("corrupt bibliography resolution");
		} else {
			await this.clearCode();
			return this.unserialize();
		}
	}
};

Zotero.Integration.Citation = class {
	static refreshEmbeddedData(itemData) {
		if (itemData.shortTitle) {
			itemData['title-short'] = itemData.shortTitle;
			delete itemData.shortTitle;
		}
		return itemData;
	}

	constructor(citationField, data, noteIndex) {
		data = Object.assign({ citationItems: [], properties: {} }, data)
		this.citationID = data.citationID;
		this.citationItems = data.citationItems;
		this.properties = data.properties;
		this.properties.noteIndex = noteIndex;

		this.field = citationField;
	}

	/**
	 * @deprecated
	 */
	get _field() {
		Zotero.debug('Citation._field is deprecated. Use Citation.field');
		return this.field;
	}

	/**
	 * Merge citation items and remove duplicates, unless the items have different
	 * @param citation {Citation}
	 */
	mergeCitation(citation) {
		let items = this.citationItems.concat(citation.citationItems);
		let addedItems = new Set();
		this.citationItems = []
		for (let item of items) {
			if (addedItems.has(item.id)) {
				continue;
			}
			addedItems.add(item.id);
			this.citationItems.push(item);
		}
	}

	/**
	 * Load citation item data
	 * @param {Boolean} [promptToReselect=true] - will throw a MissingItemException if false
	 * @returns {Promise{Number}}
	 * 	- Zotero.Integration.NO_ACTION
	 * 	- Zotero.Integration.UPDATE
	 * 	- Zotero.Integration.REMOVE_CODE
	 * 	- Zotero.Integration.DELETE
	 */
	async loadItemData(promptToReselect=true) {
		let items = [];
		var needUpdate = false;
		
		if (!this.citationItems.length) {
			return Zotero.Integration.DELETE;
		}
		for (var i=0, n=this.citationItems.length; i<n; i++) {
			var citationItem = this.citationItems[i];
			
			// get Zotero item
			var zoteroItem = false;
			if ('uri' in citationItem && !('uris' in citationItem)) {
				citationItem.uris = [citationItem.uri];
			}
			if (citationItem.uris) {
				let itemNeedsUpdate;
				[zoteroItem, itemNeedsUpdate] = await Zotero.Integration.currentSession.uriMap.getZoteroItemForURIs(citationItem.uris);
				needUpdate = needUpdate || itemNeedsUpdate;
				
				// Unfortunately, people do weird things with their documents. One weird thing people
				// apparently like to do (http://forums.zotero.org/discussion/22262/) is to copy and
				// paste citations from other documents created with earlier versions of Zotero into
				// their documents and then not refresh the document. Usually, this isn't a problem. If
				// document is edited by the same user, it will work without incident. If the first
				// citation of a given item doesn't contain itemData, the user will get a
				// MissingItemException. However, it may also happen that the first citation contains
				// itemData, but later citations don't, because the user inserted the item properly and
				// then copied and pasted the same citation from another document. We check for that
				// possibility here.
				if (zoteroItem.cslItemData && !citationItem.itemData) {
					citationItem.itemData = zoteroItem.cslItemData;
					needUpdate = true;
				}
			} else {
				if (citationItem.key && citationItem.libraryID) {
					// DEBUG: why no library id?
					zoteroItem = Zotero.Items.getByLibraryAndKey(citationItem.libraryID, citationItem.key);
				} else if (citationItem.itemID) {
					zoteroItem = Zotero.Items.get(citationItem.itemID);
				} else if (citationItem.id) {
					zoteroItem = Zotero.Items.get(citationItem.id);
				}
				if (zoteroItem) needUpdate = true;
			}
			
			// Item no longer in library
			if (!zoteroItem) {
				// Use embedded item
				if (citationItem.itemData) {
					Zotero.debug(`Item ${JSON.stringify(citationItem.uris)} not in library. Using embedded data`);
					citationItem.itemData = Zotero.Integration.Citation.refreshEmbeddedData(citationItem.itemData);
					// add new embedded item
					var itemData = Zotero.Utilities.deepCopy(citationItem.itemData);
					
					// assign a random string as an item ID
					var anonymousID = Zotero.randomString();
					var globalID = itemData.id = citationItem.id = Zotero.Integration.currentSession.data.sessionID+"/"+anonymousID;
					Zotero.Integration.currentSession.embeddedItems[anonymousID] = itemData;
					
					// assign a Zotero item
					var surrogateItem = Zotero.Integration.currentSession.embeddedZoteroItems[anonymousID] = new Zotero.Item();
					Zotero.Utilities.itemFromCSLJSON(surrogateItem, itemData);
					surrogateItem.cslItemID = globalID;
					surrogateItem.cslURIs = citationItem.uris;
					surrogateItem.cslItemData = itemData;
					
					for(var j=0, m=citationItem.uris.length; j<m; j++) {
						Zotero.Integration.currentSession.embeddedItemsByURI[citationItem.uris[j]] = surrogateItem;
					}
				} else if (promptToReselect) {
					zoteroItem = await this.handleMissingItem(i);
					if (zoteroItem) needUpdate = true;
					else return Zotero.Integration.REMOVE_CODE;
				} else {
					// throw a MissingItemException
					throw (new Zotero.Integration.MissingItemException(this, this.citationItems[i]));
				}
			}
			
			if (zoteroItem) {
				if (zoteroItem.cslItemID) {
					citationItem.id = zoteroItem.cslItemID;
				}
				else {
					citationItem.id = zoteroItem.id;
					items.push(zoteroItem);
				}
			}
		}
		
		// Items may be in libraries that haven't been loaded, and retrieveItem() is synchronous, so load
		// all data (as required by toJSON(), which is used by itemToExportFormat(), which is used by
		// itemToCSLJSON()) now
		if (items.length) {
			await Zotero.Items.loadDataTypes(items);
		}
		return needUpdate ? Zotero.Integration.UPDATE : Zotero.Integration.NO_ACTION;
	}
		
	async handleMissingItem(idx) {
		// Ask user what to do with this item
		if (this.citationItems.length == 1) {
			var msg = Zotero.getString("integration.missingItem.single");
		} else {
			var msg = Zotero.getString("integration.missingItem.multiple", (idx).toString());
		}
		msg += '\n\n'+Zotero.getString('integration.missingItem.description');
		await this.field.select();
		await Zotero.Integration.currentDoc.activate();
		var result = await Zotero.Integration.currentSession.displayAlert(msg,
			DIALOG_ICON_WARNING, DIALOG_BUTTONS_YES_NO_CANCEL);
		if (result == 0) {			// Cancel
			throw new Zotero.Exception.UserCancelled("document update");
		} else if(result == 1) {	// No
			return false;
		}
		
		// Yes - prompt to reselect
		var io = new function () { this.wrappedJSObject = this; };
		
		io.addBorder = Zotero.isWin;
		io.singleSelection = true;
		io.itemTreeID = "handle-missing-item-select-item-dialog";
		
		await Zotero.Integration.displayDialog('chrome://zotero/content/selectItemsDialog.xhtml', 'resizable', io);
			
		if (io.dataOut && io.dataOut.length) {
			return Zotero.Items.get(io.dataOut[0]);
		}
	}

	async prepareForEditing() {
		// Check for modified field text or dontUpdate flag
		if (this.properties.dontUpdate
				|| (this.properties.plainCitation
					&& (await this.field.getText()) !== this.properties.plainCitation)) {
			await Zotero.Integration.currentDoc.activate();
			var fieldText = await this.field.getText();
			Zotero.debug("[addEditCitation] Attempting to update manually modified citation.\n"
				+ "citaion.properties.dontUpdate: " + this.properties.dontUpdate + "\n"
				+ "Original: " + this.properties.plainCitation + "\n"
				+ "Current:  " + fieldText
			);
			if (!(await Zotero.Integration.currentDoc.displayAlert(
					Zotero.getString("integration.citationChanged.edit")+"\n\n"
					+ Zotero.getString("integration.citationChanged.original", this.properties.plainCitation)+"\n"
					+ Zotero.getString("integration.citationChanged.modified", fieldText)+"\n",
					DIALOG_ICON_WARNING, DIALOG_BUTTONS_OK_CANCEL))) {
				throw new Zotero.Exception.UserCancelled("editing citation");
			}
		}
		
		// make sure it's going to get updated
		delete this.properties["formattedCitation"];
		delete this.properties["plainCitation"];
		delete this.properties["dontUpdate"];
		
		// Load items to be displayed in edit dialog
		await this.loadItemData();
	}
	
	toJSON() {
		const saveProperties = ["custom", "unsorted", "formattedCitation", "plainCitation", "dontUpdate", "noteIndex"];
		const saveCitationItemKeys = ["locator", "label", "suppress-author", "author-only", "prefix",
			"suffix", "ignoreRetraction"];
		
		var citation = {};
		
		citation.citationID = this.citationID;
		
		citation.properties = {};
		for (let key of saveProperties) {
			if (key in this.properties) citation.properties[key] = this.properties[key];
		}
		
		citation.citationItems = new Array(this.citationItems.length);
		for (let i=0; i < this.citationItems.length; i++) {
			var citationItem = this.citationItems[i],
				serializeCitationItem = {};
			
			// add URI and itemData
			var slashIndex;
			if (typeof citationItem.id === "string" && (slashIndex = citationItem.id.indexOf("/")) !== -1) {
				// this is an embedded item
				serializeCitationItem.id = citationItem.id;
				serializeCitationItem.uris = citationItem.uris;
				
				// always store itemData, since we have no way to get it back otherwise
				serializeCitationItem.itemData = citationItem.itemData;
			} else {
				serializeCitationItem.id = citationItem.id;
				serializeCitationItem.uris = Zotero.Integration.currentSession.uriMap.getURIsForItemID(citationItem.id);
			
				serializeCitationItem.itemData = Zotero.Integration.currentSession.style.sys.retrieveItem(citationItem.id);
			}
			
			for (let key of saveCitationItemKeys) {
				if (key in citationItem) serializeCitationItem[key] = citationItem[key];
			}
			
			citation.citationItems[i] = serializeCitationItem;
		}
		citation.schema = "https://github.com/citation-style-language/schema/raw/master/csl-citation.json";
		
		return citation;
	}

	/**
	 * Serializes the citation into CSL code representation
	 * @returns {string}
	 */
	serialize() {
		return JSON.stringify(this.toJSON());
	}
};

Zotero.Integration.Bibliography = class {
	constructor(bibliographyField, data) {
		this.field = bibliographyField;
		this.data = data;
		
		this.uncitedItemIDs = new Set();
		this.omittedItemIDs = new Set();
		this.customEntryText = {};
		this.dataLoaded = false;
	}
	
	loadItemData() {
		return async function () {
			// set uncited
			var needUpdate = false;
			if (this.data.uncited) {
				if (this.data.uncited[0]) {
					// new style array of arrays with URIs
					let zoteroItem, itemNeedsUpdate;
					for (let uris of this.data.uncited) {
						[zoteroItem, itemNeedsUpdate] = await Zotero.Integration.currentSession.uriMap.getZoteroItemForURIs(uris);
						var id = zoteroItem.cslItemID ? zoteroItem.cslItemID : zoteroItem.id;
						if(zoteroItem && !Zotero.Integration.currentSession.citationsByItemID[id]) {
							this.uncitedItemIDs.add(`${id}`);
						} else {
							needUpdate = true;
						}
						needUpdate |= itemNeedsUpdate;
					}
				} else {
					for(var itemID in this.data.uncited) {
						// if not yet in item set, add to item set
						// DEBUG: why no libraryID?
						var zoteroItem = Zotero.Items.getByLibraryAndKey(0, itemID);
						if (!zoteroItem) zoteroItem = Zotero.Items.get(itemID);
						if (zoteroItem) this.uncitedItemIDs.add(`${id}`);
					}
					needUpdate = true;
				}
			}
			
			// set custom bibliography entries
			if(this.data.custom) {
				if(this.data.custom[0]) {
					// new style array of arrays with URIs
					var zoteroItem, itemNeedsUpdate;
					for (let custom of this.data.custom) {
						[zoteroItem, itemNeedsUpdate] = await Zotero.Integration.currentSession.uriMap.getZoteroItemForURIs(custom[0]);
						if (!zoteroItem) continue;
						if (needUpdate) needUpdate = true;
						
						var id = zoteroItem.cslItemID ? zoteroItem.cslItemID : zoteroItem.id;
						if (Zotero.Integration.currentSession.citationsByItemID[id] || id in this.uncitedItemIDs) {
							this.customEntryText[id] = custom[1];
						}
					}
				} else {
					// old style hash
					for(var itemID in this.data.custom) {
						var zoteroItem = Zotero.Items.getByLibraryAndKey(0, itemID);
						if (!zoteroItem) zoteroItem = Zotero.Items.get(itemID);
						if (!zoteroItem) continue;
						
						if(Zotero.Integration.currentSession.citationsByItemID[zoteroItem.id] || zoteroItem.id in this.uncitedItemIDs) {
							this.customEntryText[zoteroItem.id] = this.data.custom[itemID];
						}
					}
					needUpdate = true;
				}
			}
			
			// set entries to be omitted from bibliography
			if (this.data.omitted) {
				let zoteroItem, itemNeedsUpdate;
				for (let uris of this.data.omitted) {
					[zoteroItem, itemNeedsUpdate] = await Zotero.Integration.currentSession.uriMap.getZoteroItemForURIs(uris);
					var id = zoteroItem.cslItemID ? zoteroItem.cslItemID : zoteroItem.id;
					if (zoteroItem && Zotero.Integration.currentSession.citationsByItemID[id]) {
						this.omittedItemIDs.add(`${id}`);
					} else {
						needUpdate = true;
					}
					needUpdate |= itemNeedsUpdate;
				}
			}
			this.dataLoaded = true;
			return needUpdate;	
		}.apply(this, arguments);
	}

	getCiteprocBibliography(citeproc) {
		if (Zotero.Utilities.isEmpty(Zotero.Integration.currentSession.citationsByItemID)) {
			throw new Error("Attempting to generate bibliography without having updated processor items");
		};
		if (!this.dataLoaded) {
			throw new Error("Attempting to generate bibliography without having loaded item data");
		}

		Zotero.debug(`Integration: style.updateUncitedItems ${Array.from(this.uncitedItemIDs.values()).toSource()}`);
		citeproc.updateUncitedItems(Array.from(this.uncitedItemIDs.values()));
		let bibliography = citeproc.makeBibliography();
		Zotero.Cite.removeFromBibliography(bibliography, this.omittedItemIDs);
	
		for (let i in bibliography[0].entry_ids) {
			if (bibliography[0].entry_ids[i].length != 1) continue;
			let itemID = bibliography[0].entry_ids[i][0];
			if (itemID in this.customEntryText) {
				bibliography[1][i] = this.customEntryText[itemID];
			}
		}
		return bibliography;
	}
	
	serialize() {
		if (!this.dataLoaded) {
			throw new Error("Attempting to generate bibliography without having loaded item data");
		}
		var bibliography = {
			uncited: [],
			omitted: [],
			custom: []
		};
		
		// add uncited if there is anything
		for (let itemID of this.uncitedItemIDs.values()) {
			bibliography.uncited.push(Zotero.Integration.currentSession.uriMap.getURIsForItemID(itemID));
		}
		for (let itemID of this.omittedItemIDs.values()) {
			bibliography.omitted.push(Zotero.Integration.currentSession.uriMap.getURIsForItemID(itemID));
		}
		
		bibliography.custom = Object.keys(this.customEntryText)
			.map(id => [Zotero.Integration.currentSession.uriMap.getURIsForItemID(id), this.customEntryText[id]]);
		
		
		return JSON.stringify(bibliography);
	}
}

// perhaps not the best place for a timer
Zotero.Integration.Timer = class {
	start() {
		this.startTime = (new Date()).getTime();
	}
	
	stop() {
		this.resume();
		this.finalTime = ((new Date()).getTime() - this.startTime)
		return this.finalTime/1000;
	}
	
	pause() {
		this.pauseTime = (new Date()).getTime();
	}
	
	getSplit() {
		var pauseTime = 0;
		if (this.pauseTime) {
			var pauseTime = (new Date()).getTime() - this.pauseTime;
		}
		return ((new Date()).getTime() - this.startTime - pauseTime);
	}
	
	resume() {
		if (this.pauseTime) {
			this.startTime += ((new Date()).getTime() - this.pauseTime);
			this.pauseTime;
		}
	}
}

Zotero.Integration.Progress = class {
	/**
	 * @param {Number} segmentCount
	 */
	constructor(segmentCount=4, isNote=false) {
		this.segments = Array.from({length: segmentCount}, () => undefined);
		this.timer = new Zotero.Integration.Timer();
		this.segmentIdx = 0;
		this.isNote = isNote;
	}
	
	update() {
		if (!this.onProgress) return;
		var currentSegment = this.segments[this.segmentIdx];
		if (!currentSegment) return;
		var total = this.segments.reduce((acc, val) => acc+val, 0);
		var startProgress = 100*this.segments.slice(0, this.segmentIdx).reduce((acc, val) => acc+val, 0)/total;
		var maxProgress = 100.0*currentSegment/total;
		var split = this.timer.getSplit();
		var curProgress = startProgress + maxProgress*Math.min(1, split/currentSegment);
		this.onProgress(curProgress);
		setTimeout(this.update.bind(this), 100);
	}
	
	start() {
		this.timer.start();
	}
	pause() {this.timer.pause();}
	resume() {this.timer.resume();}
	finishSegment() {
		this.timer.stop();
		this.segments[this.segmentIdx++] = this.timer.finalTime;
	}
	reset() {
		this.segmentIdx = 0;
	}
	show() {
		if (this.dontDisplay) return;
		var options = 'chrome,centerscreen,resizable=false';
		// without this, Firefox gets raised with our windows under Compiz
		if (Zotero.isLinux) options += ',dialog=no';
		
		var io = {onLoad: function (onProgress) {
			this.onProgress = onProgress;
			this.update();
		}.bind(this)};
		io.wrappedJSObject = io;
		io.isNote = this.isNote;
		this.window = Components.classes["@mozilla.org/embedcomp/window-watcher;1"]
			.getService(Components.interfaces.nsIWindowWatcher)
			.openWindow(null, 'chrome://zotero/content/integration/progressBar.xhtml', '', options, io);
		Zotero.Utilities.Internal.activate(this.window);
	}
	async hide(fast=false) {
		if (!this.window) return;
		if (!fast) {
			this.onProgress && this.onProgress(100);
			this.onProgress = null;
			await Zotero.Promise.delay(300);
		}
		this.window.close();
	}
}

Zotero.Integration.LegacyPluginWrapper = function (application) {
	return {
		getDocument:
			async function () {
				return Zotero.Integration.LegacyPluginWrapper.wrapDocument(
					application.getDocument.apply(application, arguments))
			},
		getActiveDocument:
			async function () {
				return Zotero.Integration.LegacyPluginWrapper.wrapDocument(
					application.getActiveDocument.apply(application, arguments))
			},
		primaryFieldType: application.primaryFieldType,
		secondaryFieldType: application.secondaryFieldType,
		outputFormat: 'rtf',
		supportedNotes: ['footnotes', 'endnotes'],
		processorName: ''
	}
}
Zotero.Integration.LegacyPluginWrapper.wrapField = function (field) {
	var wrapped = {rawField: field};
	var fns = ['getNoteIndex', 'setCode', 'getCode', 'setText',
		'getText', 'removeCode', 'delete', 'select'];
	for (let fn of fns) {
		wrapped[fn] = async function () {
			return field[fn].apply(field, arguments);
		}
	}
	wrapped.equals = async function (other) {
		return field.equals(other.rawField);
	}
	return wrapped;
}
Zotero.Integration.LegacyPluginWrapper.wrapDocument = function wrapDocument(doc) {
	var wrapped = {};
	var fns = ['complete', 'cleanup', 'setBibliographyStyle', 'setDocumentData',
		'getDocumentData', 'canInsertField', 'activate', 'displayAlert'];
	for (let fn of fns) {
		wrapped[fn] = async function () {
			return doc[fn].apply(doc, arguments);
		}
	}
	// Should return an async array
	wrapped.getFields = async function (fieldType, progressCallback) {
		if ('getFieldsAsync' in doc) {
			var deferred = Zotero.Promise.defer();
			var promise = deferred.promise;
			
			var me = this;
			doc.getFieldsAsync(fieldType,
			{"observe":function (subject, topic, data) {
				if(topic === "fields-available") {
					if(progressCallback) {
						try {
							progressCallback(75);
						} catch(e) {
							Zotero.logError(e);
						};
					}
					
					try {
						// Add fields to fields array
						var fieldsEnumerator = subject.QueryInterface(Components.interfaces.nsISimpleEnumerator);
						var fields = [];
						while (fieldsEnumerator.hasMoreElements()) {
							let field = fieldsEnumerator.getNext();
							try {
								fields.push(Zotero.Integration.LegacyPluginWrapper.wrapField(
									field.QueryInterface(Components.interfaces.zoteroIntegrationField)));
							} catch (e) {
								fields.push(Zotero.Integration.LegacyPluginWrapper.wrapField(field));
							}
						}
					} catch(e) {
						deferred.reject(e);
						deferred = null;
						return;
					}
					
					deferred.resolve(fields);
					deferred = null;
				} else if(topic === "fields-progress") {
					if(progressCallback) {
						try {
							progressCallback((data ? parseInt(data, 10)*(3/4) : null));
						} catch(e) {
							Zotero.logError(e);
						};
					}
				} else if(topic === "fields-error") {
					deferred.reject(data);
					deferred = null;
				}
			}, QueryInterface:ChromeUtils.generateQI([Components.interfaces.nsIObserver])});
			return promise;
		} else {
			var result = doc.getFields.apply(doc, arguments);
			var fields = [];
			if (result.hasMoreElements) {
				while (result.hasMoreElements()) {
					fields.push(Zotero.Integration.LegacyPluginWrapper.wrapField(result.getNext()));
					await Zotero.Promise.delay();
				}
			} else {
				fields = result;
			}
			return fields;
		}
	}
	wrapped.insertField = async function () {
		return Zotero.Integration.LegacyPluginWrapper.wrapField(doc.insertField.apply(doc, arguments));
	}
	wrapped.cursorInField = async function () {
		var result = doc.cursorInField.apply(doc, arguments);
		return !result ? result : Zotero.Integration.LegacyPluginWrapper.wrapField(result);
	}
	// Should take an arrayOfFields instead of an enumerator
	wrapped.convert = async function (arrayOfFields) {
		arguments[0] = new Zotero.Integration.JSEnumerator(arrayOfFields.map(f => f.rawField));
		return doc.convert.apply(doc, arguments);
	}
	return wrapped;
}
