"use strict";

/**
 * Utility functions for dealing with citations
 * @namespace
 */
Zotero.Cite = {
	/**
	 * Locator labels
	 */
	"labels": [
		"act",
		"appendix",
		"article-locator",
		"book",
		"canon",
		"chapter",
		"column",
		"elocation",
		"equation",
		"figure",
		"folio",
		"issue",
		"line",
		"note",
		"opus",
		"page",
		"paragraph",
		"part",
		"rule",
		"scene",
		"section",
		"sub-verbo",
		"table",
		"timestamp",
		"title-locator",
		"verse",
		"volume"
	],
	
	_locatorStrings: new Map(),
	
	/**
	 * Get localized string for locator
	 *
	 * @param {String} locator - Locator name (e.g., 'book')
	 * @param {string} form - Fetch a specific form of the locator. E.g. form="short" for "page" is "p."
	 * @return {String} - Localized string (e.g., 'Livre')
	 */
	getLocatorString: function (locator, form = null) {
		// Get the best CSL locale for the current Zotero locale
		var cslLocale = Zotero.Utilities.Internal.resolveLocale(
			Zotero.locale,
			Object.keys(Zotero.Styles.locales)
		);
		
		// If locator strings are already cached for the current locale, use that
		if (this._locatorStrings.has(cslLocale)) {
			// If a specific form is requested, try to return it. Fallback to the regular form if not found.
			if (form) {
				let locatorWithForm = this._locatorStrings.get(cslLocale).get(locator + `_${form}`);
				if (locatorWithForm) return locatorWithForm;
			}
			return this._locatorStrings.get(cslLocale).get(locator);
		}
		var map = new Map();
		this._locatorStrings.set(cslLocale, map);
		
		var localeXML = Zotero.Cite.Locale.get(cslLocale);
		var parser;
		if (Zotero.platformMajorVersion > 60) {
			parser = new DOMParser();
		}
		else {
			parser = Components.classes["@mozilla.org/xmlextras/domparser;1"]
				.createInstance(Components.interfaces.nsIDOMParser);
		}
		var doc = parser.parseFromString(localeXML, 'text/xml');
		var englishDoc;
		// Cache all locators for the current locale
		for (let locator of this.labels) {
			// 'timestamp' not included in CSL locales
			if (locator == 'timestamp') {
				map.set(locator, Zotero.getString('citation.locator.timestamp'));
				continue;
			}
			
			// Fetch all <term>s - including the short form
			let terms = [...doc.querySelectorAll(`term[name="${locator}"]`)];
			if (!terms.length) {
				// If locator not found, get from the U.S. English locale
				if (cslLocale != 'en-US') {
					Zotero.logError(`Locator '${locator}' not found in ${cslLocale} locale -- trying en-US`);
					if (!englishDoc) {
						englishDoc = parser.parseFromString(Zotero.Cite.Locale.get('en-US'), 'text/xml');
					}
					terms = englishDoc.querySelectorAll(`term[name="${locator}"]}`);
					if (!terms.length) {
						Zotero.logError(`Locator '${locator}' not found in en-US locale -- using name`);
					}
				}
				else {
					Zotero.logError(`Locator '${locator}' not found in en-US locale -- using name`);
				}
				// If still not found, use the locator name directly
				if (!terms.length) {
					map.set(locator, Zotero.Utilities.capitalize(locator));
					continue;
				}
			}
			// Add all terms to the cache. If a term has a form, add "_<form>" suffix after the locator.
			// E.g. <term name="page" form="short"> is saved as "page_short" = "p."
			for (let elem of terms) {
				let single = elem.querySelector("single");
				// If <single> is empty, use the locator name directly
				if (!single) {
					single = elem;
				}
				let str = single.textContent;
				if (!str) {
					Zotero.logError(`Locator '${locator}' is empty in ${cslLocale} locale -- using name`);
					map.set(locator, Zotero.Utilities.capitalize(locator));
					continue;
				}
				let form = elem.getAttribute('form');
				map.set(locator + (form ? `_${form}` : ''), Zotero.Utilities.capitalize(str));
			}
		}

		return map.get(locator + (form ? `_${form}` : ''));
	},
	
	/**
	 * Remove specified item IDs in-place from a citeproc-js bibliography object returned
	 * by makeBibliography()
	 * @param {bib} citeproc-js bibliography object
	 * @param {Set} itemsToRemove Set of items to remove
	 */
	"removeFromBibliography":function (bib, itemsToRemove) {
		var removeItems = [];
		for(let i in bib[0].entry_ids) {
			for(let j in bib[0].entry_ids[i]) {
				if(itemsToRemove.has(`${bib[0].entry_ids[i][j]}`)) {
					removeItems.push(i);
					break;
				}
			}
		}
		for(let i=removeItems.length-1; i>=0; i--) {
			bib[0].entry_ids.splice(removeItems[i], 1);
			bib[1].splice(removeItems[i], 1);
		}
	},

	/**
	 * Convert formatting data from citeproc-js bibliography object into explicit format
	 * parameters for RTF or word processors
	 * @param {bib} citeproc-js bibliography object
	 * @return {Object} Bibliography style parameters.
	 */
	"getBibliographyFormatParameters":function getBibliographyFormatParameters(bib) {
		var bibStyle = {"tabStops":[], "indent":0, "firstLineIndent":0,
						"lineSpacing":(240*bib[0].linespacing),
						"entrySpacing":(240*bib[0].entryspacing)};
		if(bib[0].hangingindent) {
			bibStyle.indent = 720;				// 720 twips = 0.5 in
			bibStyle.firstLineIndent = -720;	// -720 twips = -0.5 in
		} else if(bib[0]["second-field-align"]) {
			// this is a really sticky issue. the below works for first fields that look like "[1]"
			// and "1." otherwise, i have no idea. luckily, this will be good enough 99% of the time.
			var alignAt = 24+bib[0].maxoffset*120;
			bibStyle.firstLineIndent = -alignAt;
			if(bib[0]["second-field-align"] == "margin") {
				bibStyle.tabStops = [0];
			} else {
				bibStyle.indent = alignAt;
				bibStyle.tabStops = [alignAt];
			}
		}
		
		return bibStyle;
	},

	/**
	 * Makes a formatted bibliography, if the style defines one; otherwise makes a
	 * formatted list of citations for the items.
	 * @param {Zotero.Style} style The style to use
	 * @param {Zotero.Item[]} items An array of items
	 * @param {String} format The format of the output (html, text, or rtf)
	 * @param {boolean} [asCitationList]
	 * @return {String} Bibliography or citation list in specified format
	 */
	"makeFormattedBibliographyOrCitationList":function (cslEngine, items, format, asCitationList) {
		cslEngine.setOutputFormat(format);
		cslEngine.updateItems(items.map(item => item.id));
		 		
		if(!asCitationList) {
			var bibliography = Zotero.Cite.makeFormattedBibliography(cslEngine, format);
			if(bibliography) return bibliography;
		}
		
		var styleClass = cslEngine.opt.class;
		var citations=[];
		for (let item of items) {
			citations.push(cslEngine.previewCitationCluster(
				{ citationItems: [{ id: item.id }], properties: {} },
				[],
				[],
				format,
			));
		}
		
		if(styleClass == "note") {
			if(format == "html") {
				return "<ol>\n\t<li>"+citations.join("</li>\n\t<li>")+"</li>\n</ol>";
			} else if(format == "text") {
				var output = [];
				for(var i=0; i<citations.length; i++) {
					output.push((i+1)+". "+citations[i]+"\r\n");
				}
				return output.join("");
			} else if(format == "rtf") {
				var output = ["{\\rtf \n{\\*\\listtable{\\list\\listtemplateid1\\listhybrid{\\listlevel"+
					"\\levelnfc0\\levelnfcn0\\leveljc0\\leveljcn0\\levelfollow0\\levelstartat1"+
					"\\levelspace360\\levelindent0{\\*\\levelmarker \\{decimal\\}.}{\\leveltext"+
					"\\leveltemplateid1\\'02\\'00.;}{\\levelnumbers\\'01;}\\fi-360\\li720\\lin720 }"+
					"{\\listname ;}\\listid1}}\n{\\*\\listoverridetable{\\listoverride\\listid1"+
					"\\listoverridecount0\\ls1}}\n\\tx720\\li720\\fi-480\\ls1\\ilvl0\n"];
				for(var i=0; i<citations.length; i++) {
					output.push("{\\listtext "+(i+1)+".	}"+citations[i]+"\\\n");
				}
				output.push("}");
				return output.join("");
			} else {
				throw new Error("Unimplemented bibliography format "+format);
			}
		} else {
			if(format == "html") {
				return citations.join("<br />");
			} else if(format == "text") {
				return citations.join("\r\n");
			} else if(format == "rtf") {
				return "<\\rtf \n"+citations.join("\\\n")+"\n}";
			}
		}
	},
	
	/**
	 * Makes a formatted bibliography
	 * @param {Zotero.Style} style The style
	 * @param {String} format The format of the output (html, text, or rtf)
	 * @return {String} Bibliography in specified format
	 */
	"makeFormattedBibliography":function makeFormattedBibliography(cslEngine, format) {
		var bib = cslEngine.makeBibliography();
		if(!bib) return false;
		
		if(format == "html") {
			var output = [bib[0].bibstart];
			for(var i in bib[1]) {
				output.push(bib[1][i]);
				
				// add COinS
				for (let itemID of bib[0].entry_ids[i]) {
					try {
						var co = Zotero.OpenURL.createContextObject(Zotero.Items.get(itemID), "1.0");
						if(!co) continue;
						output.push('  <span class="Z3988" title="'+
							co.replace("&", "&amp;", "g").replace("<", "&lt;", "g").replace(">", "&gt;", "g")+
							'"></span>\n');
					} catch(e) {
						Zotero.logError(e);
					}
				}
			}
			output.push(bib[0].bibend);
			var html = output.join("");
			
			var inlineCSS = true;
			if (!inlineCSS) {
				return html;
			}
			
			//Zotero.debug("maxoffset: " + bib[0].maxoffset);
			//Zotero.debug("entryspacing: " + bib[0].entryspacing);
			//Zotero.debug("linespacing: " + bib[0].linespacing);
			//Zotero.debug("hangingindent: " + bib[0].hangingindent);
			//Zotero.debug("second-field-align: " + bib[0]["second-field-align"]);
			
			var maxOffset = parseInt(bib[0].maxoffset);
			var entrySpacing = parseInt(bib[0].entryspacing);
			var lineSpacing = parseInt(bib[0].linespacing);
			var hangingIndent = bib[0].hangingindent;
			var secondFieldAlign = bib[0]["second-field-align"];
			
			// Validate input
			if(maxOffset == NaN) throw new Error("Invalid maxoffset");
			if(entrySpacing == NaN) throw new Error("Invalid entryspacing");
			if(lineSpacing == NaN) throw new Error("Invalid linespacing");
			
			var str;
			var parser = new DOMParser(),
				doc = parser.parseFromString("<!DOCTYPE html><html><body></body></html>", "text/html");
			doc.body.insertAdjacentHTML("afterbegin", html);
			var div = doc.body.firstChild,
				leftMarginDivs = Zotero.Utilities.xpath(doc, '//div[@class="csl-left-margin"]'),
				multiField = !!leftMarginDivs.length,
				clearEntries = multiField;
			
			// One of the characters is usually a period, so we can adjust this down a bit
			maxOffset = Math.max(1, maxOffset - 2);
			
			// Force a minimum line height
			if(lineSpacing <= 1.35) lineSpacing = 1.35;
			
			var style = div.getAttribute("style");
			if(!style) style = "";
			style += "line-height: " + lineSpacing + "; ";
			
			if(hangingIndent) {
				if (multiField && !secondFieldAlign) {
					throw ("second-field-align=false and hangingindent=true combination is not currently supported");
				}
				// If only one field, apply hanging indent on root
				else if (!multiField) {
					style += "margin-left: 2em; text-indent:-2em;";
				}
			}
			
			if(style) div.setAttribute("style", style);
			
			// csl-entry
			var divs = Zotero.Utilities.xpath(doc, '//div[@class="csl-entry"]');
			for(var i=0, n=divs.length; i<n; i++) {
				var div = divs[i],
					divStyle = div.getAttribute("style");
				if(!divStyle) divStyle = "";
				
				if (clearEntries) {
					divStyle += "clear: left; ";
				}
				
				if(entrySpacing && i !== n - 1) {
					divStyle += "margin-bottom: " + entrySpacing + "em;";
				}
				
				if(divStyle) div.setAttribute("style", divStyle);
			}
			
			// Padding on the label column, which we need to include when
			// calculating offset of right column
			var rightPadding = .5;
			
			// div.csl-left-margin
			for (let div of leftMarginDivs) {
				var divStyle = div.getAttribute("style");
				if(!divStyle) divStyle = "";
				
				divStyle = "float: left; padding-right: " + rightPadding + "em;";
				
				// Right-align the labels if aligning second line, since it looks
				// better and we don't need the second line of text to align with
				// the left edge of the label
				if (secondFieldAlign) {
					divStyle += "text-align: right; width: " + maxOffset + "em;";
				}
				
				div.setAttribute("style", divStyle);
			}
			
			// div.csl-right-inline
			for (let div of Zotero.Utilities.xpath(doc, '//div[@class="csl-right-inline"]')) {
				var divStyle = div.getAttribute("style");
				if(!divStyle) divStyle = "";
				
				divStyle = "margin: 0 .4em 0 " + (secondFieldAlign ? maxOffset + rightPadding : "0") + "em;";
				
				if (hangingIndent) {
					divStyle += "padding-left: 2em; text-indent:-2em;";
				}
				
				div.setAttribute("style", divStyle);
			}
			
			// div.csl-indent
			for (let div of Zotero.Utilities.xpath(doc, '//div[@class="csl-indent"]')) {
				div.setAttribute("style", "margin: .5em 0 0 2em; padding: 0 0 .2em .5em; border-left: 5px solid #ccc;");
			}
			
			return doc.body.innerHTML;
		} else if(format == "text") {
			return bib[0].bibstart+bib[1].join("")+bib[0].bibend;
		} else if(format == "rtf") {
			var bibStyle = Zotero.Cite.getBibliographyFormatParameters(bib);
			
			var preamble = (bibStyle.tabStops.length ? "\\tx"+bibStyle.tabStops.join(" \\tx")+" " : "");
			preamble += "\\li"+bibStyle.indent+" \\fi"+bibStyle.firstLineIndent+" "
					   +"\\sl"+bibStyle.lineSpacing+" \\slmult1 "
					   +"\\sa"+bibStyle.entrySpacing+" ";
			
			return bib[0].bibstart+preamble+bib[1].join("\\\r\n")+"\\\r\n"+bib[0].bibend;
		} else {
			throw new Error("Unimplemented bibliography format "+format);
		}
	},

	/**
	 * Get an item by ID, either by retrieving it from the library or looking for the document it
	 * belongs to.
	 * @param {String|Number|Array} id
	 * @return {Zotero.Item} item
	 */
	"getItem":function getItem(id) {
		var slashIndex;
		
		if(id instanceof Array) {
			return id.map(anId => Zotero.Cite.getItem(anId));
		} else if(typeof id === "string" && (slashIndex = id.indexOf("/")) !== -1) {		
			var sessionID = id.substr(0, slashIndex),
				session = Zotero.Integration.sessions[sessionID],
				item;
			if (session) {
				item = session.embeddedZoteroItems[id.substr(slashIndex+1)];
			}
			
			if(!item) {
				item = new Zotero.Item("document");
				item.setField("title", "Missing Item");
				Zotero.log("CSL item "+id+" not found");
			}
			return item;
		} else {
			return Zotero.Items.get(id);
		}
	},
	
	extraToCSL: function (extra) {
		Zotero.debug(`Zotero.Cite.extraToCSL() is deprecated -- use Zotero.Utilities.Item.extraToCSL() instead`);
		return Zotero.Utilities.Item.extraToCSL(extra);
	}
};

/**
 * Get a CSL abbreviation in the format expected by citeproc-js
 */
Zotero.Cite.getAbbreviation = new function () {
	var abbreviations,
		abbreviationCategories;

	/**
	 * Initialize abbreviations database.
	 */
	function init() {
		if(!abbreviations) loadAbbreviations();
	}

	function loadAbbreviations() {
		var file = Zotero.File.pathToFile(Zotero.DataDirectory.dir);
		file.append("abbreviations.json");

		var json, origin;
		if(file.exists()) {
			json = Zotero.File.getContents(file);
			origin = file.path;
		} else {
			json = Zotero.File.getContentsFromURL("resource://zotero/schema/abbreviations.json");
			origin = "resource://zotero/schema/abbreviations.json";
		}

		try {
			abbreviations = JSON.parse(json);
		} catch(e) {
			throw new Zotero.Exception.Alert("styles.abbreviations.parseError", origin,
				"styles.abbreviations.title", e);
		}

		if(!abbreviations.info || !abbreviations.info.name || !abbreviations.info.URI) {
			throw new Zotero.Exception.Alert("styles.abbreviations.missingInfo", origin,
				"styles.abbreviations.title");
		}

		abbreviationCategories = {};
		for(var jurisdiction in abbreviations) {
			for(var category in abbreviations[jurisdiction]) {
				abbreviationCategories[category] = true;
			}
		}
	}
	
	/**
	 * Normalizes a key
	 */
	function normalizeKey(key) {
		// Strip periods, normalize spacing, and convert to lowercase
		return key.toString().
			replace(/(?:\b|^)(?:and|et|y|und|l[ae]|the|[ld]')(?:\b|$)|[\x21-\x2C.\/\x3A-\x40\x5B-\x60\\\x7B-\x7E]/ig, "").
			replace(/\s+/g, " ").trim();
	}

	function lookupKey(key) {
		return key.toLowerCase().replace(/\s*\./g, "." );
	}
	
	/**
	 * Replace getAbbreviation on citeproc-js with our own handler.
	 */
	return function getAbbreviation(listname, obj, jurisdiction, category, key) {
		init();

		// Short circuit if we know we don't handle this kind of abbreviation
		if(!abbreviationCategories[category] && !abbreviationCategories[category+"-word"]) return;

		var normalizedKey = normalizeKey(key),
			lcNormalizedKey = lookupKey(normalizedKey),
			abbreviation;
		if(!normalizedKey) return;
		
		var jurisdictions = ["default"];
		if(jurisdiction !== "default" && abbreviations[jurisdiction]) {
			jurisdictions.unshift(jurisdiction);
		}

		// Look for full abbreviation
		var jur, cat;
		for(var i=0; i<jurisdictions.length && !abbreviation; i++) {
			if((jur = abbreviations[jurisdictions[i]]) && (cat = jur[category])) {
				abbreviation = cat[lcNormalizedKey];
			}
		}

		if(!abbreviation) {
			// Abbreviate words individually
			var words = normalizedKey.split(/([ \-])/);

			if(words.length > 1) {
				var lcWords = [];
				for(var j=0; j<words.length; j+=2) {
					lcWords[j] = lookupKey(words[j]);
				}
				for(var j=0; j<words.length; j+=2) {
					var word = words[j],
						lcWord = lcWords[j],
						newWord = undefined,
						exactMatch = false;
					
					for(var i=0; i<jurisdictions.length && newWord === undefined; i++) {
						if(!(jur = abbreviations[jurisdictions[i]])) continue;
						if(!(cat = jur[category+"-word"])) continue;
						
						if(cat.hasOwnProperty(lcWord)) {
							// Complete match
							newWord = cat[lcWord];
							exactMatch = true;
						} else if(lcWord.charAt(lcWord.length-1) == 's' && cat.hasOwnProperty(lcWord.substr(0, lcWord.length-1))) {
							// Try dropping 's'
							newWord = cat[lcWord.substr(0, lcWord.length-1)];
							exactMatch = true;
						} else {
							if(j < words.length-2) {
								// Two-word match
								newWord = cat[lcWord+words[j+1]+lcWords[j+2]];
								if(newWord !== undefined) {
									words.splice(j+1, 2);
									lcWords.splice(j+1, 2);
									exactMatch = true;
								}
							}

							if(newWord === undefined) {
								// Partial match
								for(var k=lcWord.length; k>0 && newWord === undefined; k--) {
									newWord = cat[lcWord.substr(0, k)+"-"];
								}
							}
						}
					}
					
					// Don't substitute with a longer word
					if(newWord && !exactMatch && word.length - newWord.length < 1) {
						newWord = word;
					}
					
					// Fall back to full word
					if(newWord === undefined) newWord = word;
					
					// Don't discard last word (e.g. Climate of the Past => Clim. Past)
					if(!newWord && j == words.length-1) newWord = word;
					
					words[j] = newWord.substr(0, 1).toUpperCase() + newWord.substr(1);
				}
				abbreviation = words.join("").replace(/\s+/g, " ").trim();
			} else {
				abbreviation = key;
			}
		}

		if(!abbreviation) abbreviation = key; //this should never happen, but just in case
		
		Zotero.debug("Abbreviated "+key+" as "+abbreviation);
		
		// Add to jurisdiction object
		if(!obj[jurisdiction]) {
			obj[jurisdiction] = new Zotero.CiteProc.CSL.AbbreviationSegments();
		}
		obj[jurisdiction][category][key] = abbreviation;
	}
};

/**
 * citeproc-js system object
 *
 * @class
 * @param {Object} options
 * @param {Boolean} [options.automaticJournalAbbreviations]
 * @param {Boolean} [options.uppercaseSubtitles]
 */
Zotero.Cite.System = function ({ automaticJournalAbbreviations, uppercaseSubtitles }) {
	if (automaticJournalAbbreviations) {
		this.getAbbreviation = Zotero.Cite.getAbbreviation;
	}
	if (uppercaseSubtitles) {
		this.uppercase_subtitles = true;  
	}
};

Zotero.Cite.System.prototype = {
	/**
	 * citeproc-js system function for getting items
	 * See http://gsl-nagoya-u.net/http/pub/citeproc-doc.html#retrieveitem
	 * @param {String|Integer} item - Item ID, or string item for embedded citations
	 * @return {Object} citeproc-js item
	 */
	"retrieveItem":function retrieveItem(item) {
		var zoteroItem, slashIndex;
		if(typeof item === "object" && item !== null &&  item instanceof Zotero.Item) {
			//if(this._cache[item.id]) return this._cache[item.id];
			zoteroItem = item;
		} else if(typeof item === "string" && (slashIndex = item.indexOf("/")) !== -1) {
			// is an embedded item
			var sessionID = item.substr(0, slashIndex);
			var session = Zotero.Integration.sessions[sessionID];
			if(session) {
				var embeddedCitation = session.embeddedItems[item.substr(slashIndex+1)];
				if (embeddedCitation) {
					embeddedCitation.id = item;
					return embeddedCitation;
				}
			}
		} else {
			// is an item ID
			//if(this._cache[item]) return this._cache[item];
			try {
				zoteroItem = Zotero.Items.get(item);
			} catch(e) {}
		}

		if(!zoteroItem) {
			throw new Error("Zotero.Cite.System.retrieveItem called on non-item "+item);
		}
		
		var cslItem = Zotero.Utilities.Item.itemToCSLJSON(zoteroItem);
		
		// TEMP: citeproc-js currently expects the id property to be the item DB id
		cslItem.id = zoteroItem.id;
		
		if (!Zotero.Prefs.get("export.citePaperJournalArticleURL")) {
			var itemType = Zotero.ItemTypes.getName(zoteroItem.itemTypeID);
			// don't return URL or accessed information for journal articles if a
			// pages field exists
			if (["journalArticle", "newspaperArticle", "magazineArticle"].indexOf(itemType) !== -1
				&& zoteroItem.getField("pages")
			) {
				delete cslItem.URL;
				delete cslItem.accessed;
			}
		}
		
		return cslItem;
	},

	/**
	 * citeproc-js system function for getting locale
	 * See http://gsl-nagoya-u.net/http/pub/citeproc-doc.html#retrieveLocale
	 * @param {String} lang Language to look for a locale for
	 * @return {String|Boolean} The locale as a string if it exists, or false if it doesn't
	 */
	"retrieveLocale":function retrieveLocale(lang) {
		switch (lang) {
			// citeproc-js limitation -- see https://github.com/zotero/zotero/issues/5741
			case 'sr-CYRL':
				lang = 'sr-Cyrl-RS';
				break;
			
			case 'sr-LATN':
				lang = 'sr-Latn-RS';
				break;
		}
		return Zotero.Cite.Locale.get(lang);
	}
};

Zotero.Cite.Locale = {
	_cache: new Map(),
	
	get: function (locale) {
		var str = this._cache.get(locale);
		if (str) {
			return str;
		}
		try {
			str = Zotero.File.getResource(`chrome://zotero/content/locale/csl/locales-${locale}.xml`);
			this._cache.set(locale, str);
			return str;
		}
		catch (e) {
			//Zotero.debug(e);
			return false;
		}
	}
};

if (typeof process === 'object' && process + '' === '[object process]'){
    module.exports = Zotero.Cite;
}
