www

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

utilities.js (62803B)


      1 /*
      2     ***** BEGIN LICENSE BLOCK *****
      3     
      4     Copyright © 2009 Center for History and New Media
      5                      George Mason University, Fairfax, Virginia, USA
      6                      http://zotero.org
      7     
      8     This file is part of Zotero.
      9     
     10     Zotero is free software: you can redistribute it and/or modify
     11     it under the terms of the GNU Affero General Public License as published by
     12     the Free Software Foundation, either version 3 of the License, or
     13     (at your option) any later version.
     14     
     15     Zotero is distributed in the hope that it will be useful,
     16     but WITHOUT ANY WARRANTY; without even the implied warranty of
     17     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     18     GNU Affero General Public License for more details.
     19     
     20     You should have received a copy of the GNU Affero General Public License
     21     along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
     22     
     23 	
     24 	Utilities based in part on code taken from Piggy Bank 2.1.1 (BSD-licensed)
     25 	
     26     ***** END LICENSE BLOCK *****
     27 */
     28 
     29 /*
     30  * Mappings for names
     31  * Note that this is the reverse of the text variable map, since all mappings should be one to one
     32  * and it makes the code cleaner
     33  */
     34 var CSL_NAMES_MAPPINGS = {
     35 	"author":"author",
     36 	"editor":"editor",
     37 	"bookAuthor":"container-author",
     38 	"composer":"composer",
     39 	"director":"director",
     40 	"interviewer":"interviewer",
     41 	"recipient":"recipient",
     42 	"reviewedAuthor":"reviewed-author",
     43 	"seriesEditor":"collection-editor",
     44 	"translator":"translator"
     45 }
     46 
     47 /*
     48  * Mappings for text variables
     49  */
     50 var CSL_TEXT_MAPPINGS = {
     51 	"title":["title"],
     52 	"container-title":["publicationTitle",  "reporter", "code"], /* reporter and code should move to SQL mapping tables */
     53 	"collection-title":["seriesTitle", "series"],
     54 	"collection-number":["seriesNumber"],
     55 	"publisher":["publisher", "distributor"], /* distributor should move to SQL mapping tables */
     56 	"publisher-place":["place"],
     57 	"authority":["court","legislativeBody", "issuingAuthority"],
     58 	"page":["pages"],
     59 	"volume":["volume", "codeNumber"],
     60 	"issue":["issue", "priorityNumbers"],
     61 	"number-of-volumes":["numberOfVolumes"],
     62 	"number-of-pages":["numPages"],	
     63 	"edition":["edition"],
     64 	"version":["versionNumber"],
     65 	"section":["section", "committee"],
     66 	"genre":["type", "programmingLanguage"],
     67 	"source":["libraryCatalog"],
     68 	"dimensions": ["artworkSize", "runningTime"],
     69 	"medium":["medium", "system"],
     70 	"scale":["scale"],
     71 	"archive":["archive"],
     72 	"archive_location":["archiveLocation"],
     73 	"event":["meetingName", "conferenceName"], /* these should be mapped to the same base field in SQL mapping tables */
     74 	"event-place":["place"],
     75 	"abstract":["abstractNote"],
     76 	"URL":["url"],
     77 	"DOI":["DOI"],
     78 	"ISBN":["ISBN"],
     79 	"ISSN":["ISSN"],
     80 	"call-number":["callNumber", "applicationNumber"],
     81 	"note":["extra"],
     82 	"number":["number"],
     83 	"chapter-number":["session"],
     84 	"references":["history", "references"],
     85 	"shortTitle":["shortTitle"],
     86 	"journalAbbreviation":["journalAbbreviation"],
     87 	"status":["legalStatus"],
     88 	"language":["language"]
     89 }
     90 
     91 /*
     92  * Mappings for dates
     93  */
     94 var CSL_DATE_MAPPINGS = {
     95 	"issued":"date",
     96 	"accessed":"accessDate",
     97 	"submitted":"filingDate"
     98 }
     99 
    100 /*
    101  * Mappings for types
    102  * Also see itemFromCSLJSON
    103  */
    104 var CSL_TYPE_MAPPINGS = {
    105 	'book':"book",
    106 	'bookSection':'chapter',
    107 	'journalArticle':"article-journal",
    108 	'magazineArticle':"article-magazine",
    109 	'newspaperArticle':"article-newspaper",
    110 	'thesis':"thesis",
    111 	'encyclopediaArticle':"entry-encyclopedia",
    112 	'dictionaryEntry':"entry-dictionary",
    113 	'conferencePaper':"paper-conference",
    114 	'letter':"personal_communication",
    115 	'manuscript':"manuscript",
    116 	'interview':"interview",
    117 	'film':"motion_picture",
    118 	'artwork':"graphic",
    119 	'webpage':"webpage",
    120 	'report':"report",
    121 	'bill':"bill",
    122 	'case':"legal_case",
    123 	'hearing':"bill",				// ??
    124 	'patent':"patent",
    125 	'statute':"legislation",		// ??
    126 	'email':"personal_communication",
    127 	'map':"map",
    128 	'blogPost':"post-weblog",
    129 	'instantMessage':"personal_communication",
    130 	'forumPost':"post",
    131 	'audioRecording':"song",		// ??
    132 	'presentation':"speech",
    133 	'videoRecording':"motion_picture",
    134 	'tvBroadcast':"broadcast",
    135 	'radioBroadcast':"broadcast",
    136 	'podcast':"song",			// ??
    137 	'computerProgram':"book",		// ??
    138 	'document':"article",
    139 	'note':"article",
    140 	'attachment':"article"
    141 };
    142 
    143 /**
    144  * @class Functions for text manipulation and other miscellaneous purposes
    145  */
    146 Zotero.Utilities = {
    147 	/**
    148 	 * Cleans extraneous punctuation off a creator name and parse into first and last name
    149 	 *
    150 	 * @param {String} author Creator string
    151 	 * @param {String} type Creator type string (e.g., "author" or "editor")
    152 	 * @param {Boolean} useComma Whether the creator string is in inverted (Last, First) format
    153 	 * @return {Object} firstName, lastName, and creatorType
    154 	 */
    155 	"cleanAuthor":function(author, type, useComma) {
    156 		var allCaps = 'A-Z' + 
    157 									'\u0400-\u042f';		//cyrilic
    158 
    159 		var allCapsRe = new RegExp('^[' + allCaps + ']+$');
    160 		var initialRe = new RegExp('^-?[' + allCaps + ']$');
    161 
    162 		if(typeof(author) != "string") {
    163 			throw "cleanAuthor: author must be a string";
    164 		}
    165 
    166 		author = author.replace(/^[\s\u00A0\.\,\/\[\]\:]+/, '')
    167 									  .replace(/[\s\u00A0\.\,\/\[\]\:]+$/, '')
    168 									.replace(/[\s\u00A0]+/, ' ');
    169 
    170 		if(useComma) {
    171 			// Add spaces between periods
    172 			author = author.replace(/\.([^ ])/, ". $1");
    173 
    174 			var splitNames = author.split(/, ?/);
    175 			if(splitNames.length > 1) {
    176 				var lastName = splitNames[0];
    177 				var firstName = splitNames[1];
    178 			} else {
    179 				var lastName = author;
    180 			}
    181 		} else {
    182 			// Don't parse "Firstname Lastname [Country]" as "[Country], Firstname Lastname"
    183 			var spaceIndex = author.length;
    184 			do {
    185 				spaceIndex = author.lastIndexOf(" ", spaceIndex-1);
    186 				var lastName = author.substring(spaceIndex + 1);
    187 				var firstName = author.substring(0, spaceIndex);
    188 			} while (!Zotero.Utilities.XRegExp('\\pL').test(lastName[0]) && spaceIndex > 0)
    189 		}
    190 
    191 		if(firstName && allCapsRe.test(firstName) &&
    192 				firstName.length < 4 &&
    193 				(firstName.length == 1 || lastName.toUpperCase() != lastName)) {
    194 			// first name is probably initials
    195 			var newFirstName = "";
    196 			for(var i=0; i<firstName.length; i++) {
    197 				newFirstName += " "+firstName[i]+".";
    198 			}
    199 			firstName = newFirstName.substr(1);
    200 		}
    201 
    202 		//add periods after all the initials
    203 		if(firstName) {
    204 			var names = firstName.replace(/^[\s\.]+/,'')
    205 						.replace(/[\s\,]+$/,'')
    206 						//remove spaces surronding any dashes
    207 						.replace(/\s*([\u002D\u00AD\u2010-\u2015\u2212\u2E3A\u2E3B])\s*/,'-')
    208 						.split(/(?:[\s\.]+|(?=-))/);
    209 			var newFirstName = '';
    210 			for(var i=0, n=names.length; i<n; i++) {
    211 				newFirstName += names[i];
    212 				if(initialRe.test(names[i])) newFirstName += '.';
    213 				newFirstName += ' ';
    214 			}
    215 			firstName = newFirstName.replace(/ -/g,'-').trim();
    216 		}
    217 
    218 		return {firstName:firstName, lastName:lastName, creatorType:type};
    219 	},
    220 	
    221 	/**
    222 	 * Removes leading and trailing whitespace from a string
    223 	 * @type String
    224 	 */
    225 	"trim":function(/**String*/ s) {
    226 		if (typeof(s) != "string") {
    227 			throw "trim: argument must be a string";
    228 		}
    229 		
    230 		s = s.replace(/^\s+/, "");
    231 		return s.replace(/\s+$/, "");
    232 	},
    233 
    234 	/**
    235 	 * Cleans whitespace off a string and replaces multiple spaces with one
    236 	 * @type String
    237 	 */
    238 	"trimInternal":function(/**String*/ s) {
    239 		if (typeof(s) != "string") {
    240 			throw new Error("trimInternal: argument must be a string");
    241 		}
    242 		
    243 		s = s.replace(/[\xA0\r\n\s]+/g, " ");
    244 		return this.trim(s);
    245 	},
    246 
    247 	/**
    248 	 * Cleans any non-word non-parenthesis characters off the ends of a string
    249 	 * @type String
    250 	 */
    251 	"superCleanString":function(/**String*/ x) {
    252 		if(typeof(x) != "string") {
    253 			throw "superCleanString: argument must be a string";
    254 		}
    255 		
    256 		var x = x.replace(/^[\x00-\x27\x29-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F\s]+/, "");
    257 		return x.replace(/[\x00-\x28\x2A-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F\s]+$/, "");
    258 	},
    259 
    260 	/**
    261 	 * Cleans a http url string
    262 	 * @param url {String}
    263 	 * @params tryHttp {Boolean} Attempt prepending 'http://' to the url
    264 	 * @returns {String}
    265 	 */
    266 	cleanURL: function(url, tryHttp=false) {
    267 		url = url.trim();
    268 		if (!url) return false;
    269 		
    270 		var ios = Components.classes["@mozilla.org/network/io-service;1"]
    271 			.getService(Components.interfaces.nsIIOService);
    272 		try {
    273 			return ios.newURI(url, null, null).spec; // Valid URI if succeeds
    274 		} catch (e) {
    275 			if (e instanceof Components.Exception
    276 				&& e.result == Components.results.NS_ERROR_MALFORMED_URI
    277 			) {
    278 				if (tryHttp && /\w\.\w/.test(url)) {
    279 					// Assume it's a URL missing "http://" part
    280 					try {
    281 						return ios.newURI('http://' + url, null, null).spec;
    282 					} catch (e) {}
    283 				}
    284 				
    285 				Zotero.debug('cleanURL: Invalid URI: ' + url, 2);
    286 				return false;
    287 			}
    288 			throw e;
    289 		}
    290 	},
    291 	
    292 	/**
    293 	 * Eliminates HTML tags, replacing &lt;br&gt;s with newlines
    294 	 * @type String
    295 	 */
    296 	"cleanTags":function(/**String*/ x) {
    297 		if(typeof(x) != "string") {
    298 			throw "cleanTags: argument must be a string";
    299 		}
    300 		
    301 		x = x.replace(/<br[^>]*>/gi, "\n");
    302 		x = x.replace(/<\/p>/gi, "\n\n");
    303 		return x.replace(/<[^>]+>/g, "");
    304 	},
    305 
    306 	/**
    307 	 * Strip info:doi prefix and any suffixes from a DOI
    308 	 * @type String
    309 	 */
    310 	"cleanDOI":function(/**String**/ x) {
    311 		if(typeof(x) != "string") {
    312 			throw "cleanDOI: argument must be a string";
    313 		}
    314 
    315 		var doi = x.match(/10(?:\.[0-9]{4,})?\/[^\s]*[^\s\.,]/);
    316 		return doi ? doi[0] : null;
    317 	},
    318 
    319 	/**
    320 	 * Clean and validate ISBN.
    321 	 * Return isbn if valid, otherwise return false
    322 	 * @param {String} isbn
    323 	 * @param {Boolean} [dontValidate=false] Do not validate check digit
    324 	 * @return {String|Boolean} Valid ISBN or false
    325 	 */
    326 	"cleanISBN":function(isbnStr, dontValidate) {
    327 		isbnStr = isbnStr.toUpperCase()
    328 			.replace(/[\x2D\xAD\u2010-\u2015\u2043\u2212]+/g, ''); // Ignore dashes
    329 		var isbnRE = /\b(?:97[89]\s*(?:\d\s*){9}\d|(?:\d\s*){9}[\dX])\b/g,
    330 			isbnMatch;
    331 		while(isbnMatch = isbnRE.exec(isbnStr)) {
    332 			var isbn = isbnMatch[0].replace(/\s+/g, '');
    333 			
    334 			if (dontValidate) {
    335 				return isbn;
    336 			}
    337 			
    338 			if(isbn.length == 10) {
    339 				// Verify ISBN-10 checksum
    340 				var sum = 0;
    341 				for (var i = 0; i < 9; i++) {
    342 					sum += isbn[i] * (10-i);
    343 				}
    344 				//check digit might be 'X'
    345 				sum += (isbn[9] == 'X')? 10 : isbn[9]*1;
    346 	
    347 				if (sum % 11 == 0) return isbn;
    348 			} else {
    349 				// Verify ISBN 13 checksum
    350 				var sum = 0;
    351 				for (var i = 0; i < 12; i+=2) sum += isbn[i]*1;	//to make sure it's int
    352 				for (var i = 1; i < 12; i+=2) sum += isbn[i]*3;
    353 				sum += isbn[12]*1; //add the check digit
    354 	
    355 				if (sum % 10 == 0 ) return isbn;
    356 			}
    357 			
    358 			isbnRE.lastIndex = isbnMatch.index + 1; // Retry the same spot + 1
    359 		}
    360 		
    361 		return false;
    362 	},
    363 	
    364 	/*
    365 	 * Convert ISBN 10 to ISBN 13
    366 	 * @param {String} isbn ISBN 10 or ISBN 13
    367 	 *   cleanISBN
    368 	 * @return {String} ISBN-13
    369 	 */
    370 	"toISBN13": function(isbnStr) {
    371 		var isbn;
    372 		if (!(isbn = Zotero.Utilities.cleanISBN(isbnStr, true))) {
    373 			throw new Error('ISBN not found in "' + isbnStr + '"');
    374 		}
    375 		
    376 		if (isbn.length == 13) {
    377 			isbn = isbn.substr(0,12); // Strip off check digit and re-calculate it
    378 		} else {
    379 			isbn = '978' + isbn.substr(0,9);
    380 		}
    381 		
    382 		var sum = 0;
    383 		for (var i = 0; i < 12; i++) {
    384 			sum += isbn[i] * (i%2 ? 3 : 1);
    385 		}
    386 		
    387 		var checkDigit = 10 - (sum % 10);
    388 		if (checkDigit == 10) checkDigit = 0;
    389 		
    390 		return isbn + checkDigit;
    391 	},
    392 
    393 	/**
    394 	 * Clean and validate ISSN.
    395 	 * Return issn if valid, otherwise return false
    396 	 */
    397 	"cleanISSN":function(/**String*/ issnStr) {
    398 		issnStr = issnStr.toUpperCase()
    399 			.replace(/[\x2D\xAD\u2010-\u2015\u2043\u2212]+/g, ''); // Ignore dashes
    400 		var issnRE = /\b(?:\d\s*){7}[\dX]\b/g,
    401 			issnMatch;
    402 		while (issnMatch = issnRE.exec(issnStr)) {
    403 			var issn = issnMatch[0].replace(/\s+/g, '');
    404 			
    405 			// Verify ISSN checksum
    406 			var sum = 0;
    407 			for (var i = 0; i < 7; i++) {
    408 				sum += issn[i] * (8-i);
    409 			}
    410 			//check digit might be 'X'
    411 			sum += (issn[7] == 'X')? 10 : issn[7]*1;
    412 	
    413 			if (sum % 11 == 0) {
    414 				return issn.substring(0,4) + '-' + issn.substring(4);
    415 			}
    416 			
    417 			issnRE.lastIndex = issnMatch.index + 1; // Retry same spot + 1
    418 		}
    419 		
    420 		return false;
    421 	},
    422 	
    423 	/**
    424 	 * Convert plain text to HTML by replacing special characters and replacing newlines with BRs or
    425 	 * P tags
    426 	 * @param {String} str Plain text string
    427 	 * @param {Boolean} singleNewlineIsParagraph Whether single newlines should be considered as
    428 	 *     paragraphs. If true, each newline is replaced with a P tag. If false, double newlines
    429 	 *     are replaced with P tags, while single newlines are replaced with BR tags.
    430 	 * @type String
    431 	 */
    432 	"text2html":function (/**String**/ str, /**Boolean**/ singleNewlineIsParagraph) {
    433 		str = Zotero.Utilities.htmlSpecialChars(str);
    434 		
    435 		// \n => <p>
    436 		if (singleNewlineIsParagraph) {
    437 			str = '<p>'
    438 					+ str.replace(/\n/g, '</p><p>')
    439 						.replace(/  /g, '&nbsp; ')
    440 				+ '</p>';
    441 		}
    442 		// \n\n => <p>, \n => <br/>
    443 		else {
    444 			str = '<p>'
    445 					+ str.replace(/\n\n/g, '</p><p>')
    446 						.replace(/\n/g, '<br/>')
    447 						.replace(/  /g, '&nbsp; ')
    448 				+ '</p>';
    449 		}
    450 		return str.replace(/<p>\s*<\/p>/g, '<p>&nbsp;</p>');
    451 	},
    452 
    453 	/**
    454 	 * Encode special XML/HTML characters
    455 	 * Certain entities can be inserted manually:
    456 	 *   <ZOTEROBREAK/> => <br/>
    457 	 *   <ZOTEROHELLIP/> => &#8230;
    458 	 *
    459 	 * @param {String} str
    460 	 * @return {String}
    461 	 */
    462 	"htmlSpecialChars":function(str) {
    463 		if (str && typeof str != 'string') {
    464 			Zotero.debug('#htmlSpecialChars: non-string arguments are deprecated. Update your code',
    465 				1, undefined, true);
    466 			str = str.toString();
    467 		}
    468 		
    469 		if (!str) return '';
    470 		
    471 		return str
    472 			.replace(/&/g, '&amp;')
    473 			.replace(/"/g, '&quot;')
    474 			.replace(/'/g, '&apos;')
    475 			.replace(/</g, '&lt;')
    476 			.replace(/>/g, '&gt;')
    477 			.replace(/&lt;ZOTERO([^\/]+)\/&gt;/g, function (str, p1, offset, s) {
    478 			switch (p1) {
    479 				case 'BREAK':
    480 					return '<br/>';
    481 				case 'HELLIP':
    482 					return '&#8230;';
    483 				default:
    484 					return p1;
    485 			}
    486 		});
    487 	},
    488 
    489 	/**
    490 	 * Decodes HTML entities within a string, returning plain text
    491 	 * @type String
    492 	 */
    493 	"unescapeHTML":new function() {
    494 		var nsIScriptableUnescapeHTML, node;
    495 		
    496 		return function(/**String*/ str) {
    497 			// If no tags, no need to unescape
    498 			if(str.indexOf("<") === -1 && str.indexOf("&") === -1) return str;
    499 			
    500 			if(Zotero.isFx && !Zotero.isBookmarklet) {
    501 				// Create a node and use the textContent property to do unescaping where
    502 				// possible, because this approach preserves line endings in the HTML
    503 				if(node === undefined) {
    504 					node = Zotero.Utilities.Internal.getDOMDocument().createElement("div");
    505 				}
    506 				
    507 				node.innerHTML = str;
    508 				return node.textContent.replace(/ {2,}/g, " ");
    509 			} else if(Zotero.isNode) {
    510 				/*var doc = require('jsdom').jsdom(str, null, {
    511 					"features":{
    512 						"FetchExternalResources":false,
    513 						"ProcessExternalResources":false,
    514 						"MutationEvents":false,
    515 						"QuerySelector":false
    516 					}
    517 				});
    518 				if(!doc.documentElement) return str;
    519 				return doc.documentElement.textContent;*/
    520 				return Zotero.Utilities.cleanTags(str);
    521 			} else {
    522 				if(!node) node = document.createElement("div");
    523 				node.innerHTML = str;
    524 				return ("textContent" in node ? node.textContent : node.innerText).replace(/ {2,}/g, " ");
    525 			}
    526 		};
    527 	},
    528 	
    529 	/**
    530 	 * Converts text inside a DOM object to plain text preserving text formatting
    531 	 * appropriate for given field
    532 	 * 
    533 	 * @param {DOMNode} rootNode Node containing all the text that needs to be extracted
    534 	 * @param {String} targetField Zotero item field that the text is meant for
    535 	 *
    536 	 * @return {String} Zotero formatted string
    537 	 */
    538 	"dom2text": function(rootNode, targetField) {
    539 		// TODO: actually do this
    540 		return Zotero.Utilities.trimInternal(rootNode.textContent);
    541 	},
    542 	
    543 	/**
    544 	 * Wrap URLs and DOIs in <a href=""> links in plain text
    545 	 *
    546 	 * Ignore URLs preceded by '>', just in case there are already links
    547 	 * @type String
    548 	 */
    549 	"autoLink":function (/**String**/ str) {
    550 		// "http://www.google.com."
    551 		// "http://www.google.com. "
    552 		// "<http://www.google.com>" (and other characters, with or without a space after)
    553 		str = str.replace(/([^>])(https?:\/\/[^\s]+)([\."'>:\]\)](\s|$))/g, '$1<a href="$2">$2</a>$3');
    554 		// "http://www.google.com"
    555 		// "http://www.google.com "
    556 		str = str.replace(/([^">])(https?:\/\/[^\s]+)(\s|$)/g, '$1<a href="$2">$2</a>$3');
    557 		
    558 		// DOI
    559 		str = str.replace(/(doi:[ ]*)(10\.[^\s]+[0-9a-zA-Z])/g, '$1<a href="http://dx.doi.org/$2">$2</a>');
    560 		return str;
    561 	},
    562 	
    563 	/**
    564 	 * Parses a text string for HTML/XUL markup and returns an array of parts. Currently only finds
    565 	 * HTML links (&lt;a&gt; tags)
    566 	 *
    567 	 * @return {Array} An array of objects with the following form:<br>
    568 	 * <pre>   {
    569 	 *         type: 'text'|'link',
    570 	 *         text: "text content",
    571 	 *         [ attributes: { key1: val [ , key2: val, ...] }
    572 	 *    }</pre>
    573 	 */
    574 	"parseMarkup":function(/**String*/ str) {
    575 		var parts = [];
    576 		var splits = str.split(/(<a [^>]+>[^<]*<\/a>)/);
    577 		
    578 		for(var i=0; i<splits.length; i++) {
    579 			// Link
    580 			if (splits[i].indexOf('<a ') == 0) {
    581 				var matches = splits[i].match(/<a ([^>]+)>([^<]*)<\/a>/);
    582 				if (matches) {
    583 					// Attribute pairs
    584 					var attributes = {};
    585 					var pairs = matches[1].match(/([^ =]+)="([^"]+")/g);
    586 					for(var j=0; j<pairs.length; j++) {
    587 						var keyVal = pairs[j].split(/=/);
    588 						attributes[keyVal[0]] = keyVal[1].substr(1, keyVal[1].length - 2);
    589 					}
    590 					
    591 					parts.push({
    592 						type: 'link',
    593 						text: matches[2],
    594 						attributes: attributes
    595 					});
    596 					continue;
    597 				}
    598 			}
    599 			
    600 			parts.push({
    601 				type: 'text',
    602 				text: splits[i]
    603 			});
    604 		}
    605 		
    606 		return parts;
    607 	},
    608 	
    609 	/**
    610 	 * Calculates the Levenshtein distance between two strings
    611 	 * @type Number
    612 	 */
    613 	"levenshtein":function (/**String*/ a, /**String**/ b) {
    614 		var aLen = a.length;
    615 		var bLen = b.length;
    616 		
    617 		var arr = new Array(aLen+1);
    618 		var i, j, cost;
    619 		
    620 		for (i = 0; i <= aLen; i++) {
    621 			arr[i] = new Array(bLen);
    622 			arr[i][0] = i;
    623 		}
    624 		
    625 		for (j = 0; j <= bLen; j++) {
    626 			arr[0][j] = j;
    627 		}
    628 		
    629 		for (i = 1; i <= aLen; i++) {
    630 			for (j = 1; j <= bLen; j++) {
    631 				cost = (a[i-1] == b[j-1]) ? 0 : 1;
    632 				arr[i][j] = Math.min(arr[i-1][j] + 1, Math.min(arr[i][j-1] + 1, arr[i-1][j-1] + cost));
    633 			}
    634 		}
    635 		
    636 		return arr[aLen][bLen];
    637 	},
    638 	
    639 	/**
    640 	 * Test if an object is empty
    641 	 *
    642 	 * @param {Object} obj
    643 	 * @type Boolean
    644 	 */
    645 	"isEmpty":function (obj) {
    646 		for (var i in obj) {
    647 			return false;
    648 		}
    649 		return true;
    650 	},
    651 
    652 	/**
    653 	 * Compares an array with another and returns an array with
    654 	 *	the values from array1 that don't exist in array2
    655 	 *
    656 	 * @param	{Array}		array1
    657 	 * @param	{Array}		array2
    658 	 * @param	{Boolean}	useIndex		If true, return an array containing just
    659 	 *										the index of array2's elements;
    660 	 *										otherwise return the values
    661 	 */
    662 	"arrayDiff":function(array1, array2, useIndex) {
    663 		if (!Array.isArray(array1)) {
    664 			throw new Error("array1 is not an array (" + array1 + ")");
    665 		}
    666 		if (!Array.isArray(array2)) {
    667 			throw new Error("array2 is not an array (" + array2 + ")");
    668 		}
    669 		
    670 		var val, pos, vals = [];
    671 		for (var i=0; i<array1.length; i++) {
    672 			val = array1[i];
    673 			pos = array2.indexOf(val);
    674 			if (pos == -1) {
    675 				vals.push(useIndex ? pos : val);
    676 			}
    677 		}
    678 		return vals;
    679 	},
    680 	
    681 	
    682 	/**
    683 	 * Determine whether two arrays are identical
    684 	 *
    685 	 * Modified from http://stackoverflow.com/a/14853974
    686 	 *
    687 	 * @return {Boolean} 
    688 	 */
    689 	"arrayEquals": function (array1, array2) {
    690 		// If either array is a falsy value, return
    691 		if (!array1 || !array2)
    692 			return false;
    693 	
    694 		// Compare lengths - can save a lot of time
    695 		if (array1.length != array2.length)
    696 			return false;
    697 	
    698 		for (var i = 0, l=array1.length; i < l; i++) {
    699 			// Check if we have nested arrays
    700 			if (array1[i] instanceof Array && array2[i] instanceof Array) {
    701 				// Recurse into the nested arrays
    702 				if (!this.arrayEquals(array1[i], array2[i])) {
    703 					return false;
    704 				}
    705 			}
    706 			else if (array1[i] != array2[i]) {
    707 				// Warning - two different object instances will never be equal: {x:20} != {x:20}
    708 				return false;
    709 			}
    710 		}
    711 		return true;
    712 	},
    713 	
    714 	
    715 	/**
    716 	 * Return new array with values shuffled
    717 	 *
    718 	 * From http://stackoverflow.com/a/6274398
    719 	 *
    720 	 * @param {Array} arr
    721 	 * @return {Array}
    722 	 */
    723 	"arrayShuffle": function (array) {
    724 		var counter = array.length, temp, index;
    725 		
    726 		// While there are elements in the array
    727 		while (counter--) {
    728 			// Pick a random index
    729 			index = (Math.random() * counter) | 0;
    730 			
    731 			// And swap the last element with it
    732 			temp = array[counter];
    733 			array[counter] = array[index];
    734 			array[index] = temp;
    735 		}
    736 		
    737 		return array;
    738 	},
    739 	
    740 	
    741 	/**
    742 	 * Return new array with duplicate values removed
    743 	 *
    744 	 * @param	{Array}		array
    745 	 * @return	{Array}
    746 	 */
    747 	arrayUnique: function (arr) {
    748 		return [...new Set(arr)];
    749 	},
    750 	
    751 	/**
    752 	 * Run a function on chunks of a given size of an array's elements.
    753 	 *
    754 	 * @param {Array} arr
    755 	 * @param {Integer} chunkSize
    756 	 * @param {Function} func
    757 	 * @return {Array} The return values from the successive runs
    758 	 */
    759 	"forEachChunk":function(arr, chunkSize, func) {
    760 		var retValues = [];
    761 		var tmpArray = arr.concat();
    762 		var num = arr.length;
    763 		var done = 0;
    764 		
    765 		do {
    766 			var chunk = tmpArray.splice(0, chunkSize);
    767 			done += chunk.length;
    768 			retValues.push(func(chunk));
    769 		}
    770 		while (done < num);
    771 		
    772 		return retValues;
    773 	},
    774 	
    775 	/**
    776 	 * Assign properties to an object
    777 	 *
    778 	 * @param {Object} target
    779 	 * @param {Object} source
    780 	 * @param {String[]} [props] Properties to assign. Assign all otherwise
    781 	 */
    782 	"assignProps": function(target, source, props) {
    783 		if (!props) props = Object.keys(source);
    784 		
    785 		for (var i=0; i<props.length; i++) {
    786 			if (source[props[i]] === undefined) continue;
    787 			target[props[i]] = source[props[i]];
    788 		}
    789 	},
    790 	
    791 	/**
    792 	 * Generate a random integer between min and max inclusive
    793 	 *
    794 	 * @param	{Integer}	min
    795 	 * @param	{Integer}	max
    796 	 * @return	{Integer}
    797 	 */
    798 	"rand":function (min, max) {
    799 		return Math.floor(Math.random() * (max - min + 1)) + min;
    800 	},
    801 
    802 	/**
    803 	 * Parse a page range
    804 	 *
    805 	 * @param {String} Page range to parse
    806 	 * @return {Integer[]} Start and end pages
    807 	 */
    808 	"getPageRange":function(pages) {
    809 		const pageRangeRegexp = /^\s*([0-9]+) ?[-\u2013] ?([0-9]+)\s*$/
    810 		
    811 		var pageNumbers;
    812 		var m = pageRangeRegexp.exec(pages);
    813 		if(m) {
    814 			// A page range
    815 			pageNumbers = [m[1], m[2]];
    816 		} else {
    817 			// Assume start and end are the same
    818 			pageNumbers = [pages, pages];
    819 		}
    820 		return pageNumbers;
    821 	},
    822 
    823 	/**
    824 	 * Pads a number or other string with a given string on the left
    825 	 *
    826 	 * @param {String} string String to pad
    827 	 * @param {String} pad String to use as padding
    828 	 * @length {Integer} length Length of new padded string
    829 	 * @type String
    830 	 */
    831 	"lpad":function(string, pad, length) {
    832 		string = string ? string + '' : '';
    833 		while(string.length < length) {
    834 			string = pad + string;
    835 		}
    836 		return string;
    837 	},
    838 
    839 	/**
    840 	 * Shorten and add an ellipsis to a string if necessary
    841 	 *
    842 	 * @param {String}	str
    843 	 * @param {Integer}	len
    844 	 * @param {Boolean} [wordBoundary=false]
    845 	 * @param {Boolean} [countChars=false]
    846 	 */
    847 	ellipsize: function (str, len, wordBoundary = false, countChars) {
    848 		if (!len) {
    849 			throw ("Length not specified in Zotero.Utilities.ellipsize()");
    850 		}
    851 		if (str.length <= len) {
    852 			return str;
    853 		}
    854 		let radius = Math.min(len, 5);
    855 		if (wordBoundary) {
    856 			let min = len - radius;
    857 			// If next character is a space, include that so we stop at len
    858 			if (str.charAt(len).match(/\s/)) {
    859 				radius++;
    860 			}
    861 			// Remove trailing characters and spaces, up to radius
    862 			str = str.substr(0, min) + str.substr(min, radius).replace(/\W*\s\S*$/, "");
    863 		}
    864 		else {
    865 			str = str.substr(0, len)
    866 		}
    867 		return str + '\u2026' + (countChars ? ' (' + str.length + ' chars)' : '');
    868 	},
    869 	
    870 	
    871 	/**
    872 	 * Return the proper plural form of a string
    873 	 *
    874 	 * For now, this is only used for debug output in English.
    875 	 *
    876 	 * @param {Integer} num
    877 	 * @param {String[]|String} forms - If an array, an array of plural forms (e.g., ['object', 'objects']);
    878 	 *     currently only the two English forms are supported, for 1 and 0/many. If a single string,
    879 	 *     's' is added automatically for 0/many.
    880 	 * @return {String}
    881 	 */
    882 	pluralize: function (num, forms) {
    883 		if (typeof forms == 'string') {
    884 			forms = [forms, forms + 's'];
    885 		}
    886 		return num == 1 ? forms[0] : forms[1];
    887 	},
    888 	
    889 	
    890 	/**
    891 	  * Port of PHP's number_format()
    892 	  *
    893 	  * MIT Licensed
    894 	  *
    895 	  * From http://kevin.vanzonneveld.net
    896 	  * +   original by: Jonas Raoni Soares Silva (http://www.jsfromhell.com)
    897 	  * +   improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
    898 	  * +     bugfix by: Michael White (http://getsprink.com)
    899 	  * +     bugfix by: Benjamin Lupton
    900 	  * +     bugfix by: Allan Jensen (http://www.winternet.no)
    901 	  * +    revised by: Jonas Raoni Soares Silva (http://www.jsfromhell.com)
    902 	  * +     bugfix by: Howard Yeend
    903 	  * *     example 1: number_format(1234.5678, 2, '.', '');
    904 	  * *     returns 1: 1234.57
    905 	 */
    906 	"numberFormat":function (number, decimals, dec_point, thousands_sep) {
    907 		var n = number, c = isNaN(decimals = Math.abs(decimals)) ? 2 : decimals;
    908 		var d = dec_point == undefined ? "." : dec_point;
    909 		var t = thousands_sep == undefined ? "," : thousands_sep, s = n < 0 ? "-" : "";
    910 		var i = parseInt(n = Math.abs(+n || 0).toFixed(c)) + "", j = (j = i.length) > 3 ? j % 3 : 0;
    911 		
    912 		return s + (j ? i.substr(0, j) + t : "") + i.substr(j).replace(/(\d{3})(?=\d)/g, "$1" + t) + (c ? d + Math.abs(n - i).toFixed(c).slice(2) : "");
    913 	},
    914 
    915 	/**
    916 	 * Cleans a title, converting it to title case and replacing " :" with ":"
    917 	 *
    918 	 * @param {String} string
    919 	 * @param {Boolean} force Forces title case conversion, even if the capitalizeTitles pref is off
    920 	 * @type String
    921 	 */
    922 	"capitalizeTitle":function(string, force) {
    923 		const skipWords = ["but", "or", "yet", "so", "for", "and", "nor", "a", "an",
    924 			"the", "at", "by", "from", "in", "into", "of", "on", "to", "with", "up",
    925 			"down", "as"];
    926 		
    927 		// this may only match a single character
    928 		const delimiterRegexp = /([ \/\u002D\u00AD\u2010-\u2015\u2212\u2E3A\u2E3B])/;
    929 		
    930 		string = this.trimInternal(string);
    931 		string = string.replace(/ : /g, ": ");
    932 		if(force === false || (!Zotero.Prefs.get('capitalizeTitles') && !force)) return string;
    933 		if(!string) return "";
    934 		
    935 		// split words
    936 		var words = string.split(delimiterRegexp);
    937 		var isUpperCase = string.toUpperCase() == string;
    938 		
    939 		var newString = "";
    940 		var delimiterOffset = words[0].length;
    941 		var lastWordIndex = words.length-1;
    942 		var previousWordIndex = -1;
    943 		for(var i=0; i<=lastWordIndex; i++) {
    944 			// only do manipulation if not a delimiter character
    945 			if(words[i].length != 0 && (words[i].length != 1 || !delimiterRegexp.test(words[i]))) {
    946 				var upperCaseVariant = words[i].toUpperCase();
    947 				var lowerCaseVariant = words[i].toLowerCase();
    948 				
    949 				// only use if word does not already possess some capitalization
    950 				if(isUpperCase || words[i] == lowerCaseVariant) {
    951 					if(
    952 						// a skip word
    953 						skipWords.indexOf(lowerCaseVariant.replace(/[^a-zA-Z]+/, "")) != -1
    954 						// not first or last word
    955 						&& i != 0 && i != lastWordIndex
    956 						// does not follow a colon
    957 						&& (previousWordIndex == -1 || words[previousWordIndex][words[previousWordIndex].length-1].search(/[:\?!]/)==-1)
    958 					) {
    959 						words[i] = lowerCaseVariant;
    960 					} else {
    961 						// this is not a skip word or comes after a colon;
    962 						// we must capitalize
    963 						// handle punctuation in the beginning, including multiple, as in "¿Qué pasa?"		
    964 						var punct = words[i].match(/^[\'\"¡¿“‘„«\s]+/);
    965 						punct = punct ? punct[0].length+1 : 1;
    966 						words[i] = words[i].length ? words[i].substr(0, punct).toUpperCase() +
    967 							words[i].substr(punct).toLowerCase() : words[i];
    968 					}
    969 				}
    970 				
    971 				previousWordIndex = i;
    972 			}
    973 			
    974 			newString += words[i];
    975 		}
    976 		
    977 		return newString;
    978 	},
    979 	
    980 	"capitalize": function (str) {
    981 		if (typeof str != 'string') throw new Error("Argument must be a string");
    982 		if (!str) return str; // Empty string
    983 		return str[0].toUpperCase() + str.substr(1);
    984 	},
    985 	
    986 	/**
    987 	 * Replaces accented characters in a string with ASCII equivalents
    988 	 *
    989 	 * @param {String} str
    990 	 * @param {Boolean} [lowercaseOnly]  Limit conversions to lowercase characters
    991 	 *                                   (for improved performance on lowercase input)
    992 	 * @return {String}
    993 	 *
    994 	 * From http://lehelk.com/2011/05/06/script-to-remove-diacritics/
    995 	 */
    996 	"removeDiacritics": function (str, lowercaseOnly) {
    997 		// Short-circuit on the most basic input
    998 		if (/^[a-zA-Z0-9_-]*$/.test(str)) return str;
    999 
   1000 		var map = this._diacriticsRemovalMap.lowercase;
   1001 		for (var i=0, len=map.length; i<len; i++) {
   1002 			str = str.replace(map[i].letters, map[i].base);
   1003 		}
   1004 		
   1005 		if (!lowercaseOnly) {
   1006 			var map = this._diacriticsRemovalMap.uppercase;
   1007 			for (var i=0, len=map.length; i<len; i++) {
   1008 				str = str.replace(map[i].letters, map[i].base);
   1009 			}
   1010 		}
   1011 		
   1012 		return str;
   1013 	},
   1014 	
   1015 	"_diacriticsRemovalMap": {
   1016 		uppercase: [
   1017 			{'base':'A', 'letters':/[\u0041\u24B6\uFF21\u00C0\u00C1\u00C2\u1EA6\u1EA4\u1EAA\u1EA8\u00C3\u0100\u0102\u1EB0\u1EAE\u1EB4\u1EB2\u0226\u01E0\u00C4\u01DE\u1EA2\u00C5\u01FA\u01CD\u0200\u0202\u1EA0\u1EAC\u1EB6\u1E00\u0104\u023A\u2C6F]/g},
   1018 			{'base':'AA','letters':/[\uA732]/g},
   1019 			{'base':'AE','letters':/[\u00C6\u01FC\u01E2]/g},
   1020 			{'base':'AO','letters':/[\uA734]/g},
   1021 			{'base':'AU','letters':/[\uA736]/g},
   1022 			{'base':'AV','letters':/[\uA738\uA73A]/g},
   1023 			{'base':'AY','letters':/[\uA73C]/g},
   1024 			{'base':'B', 'letters':/[\u0042\u24B7\uFF22\u1E02\u1E04\u1E06\u0243\u0182\u0181]/g},
   1025 			{'base':'C', 'letters':/[\u0043\u24B8\uFF23\u0106\u0108\u010A\u010C\u00C7\u1E08\u0187\u023B\uA73E]/g},
   1026 			{'base':'D', 'letters':/[\u0044\u24B9\uFF24\u1E0A\u010E\u1E0C\u1E10\u1E12\u1E0E\u0110\u018B\u018A\u0189\uA779]/g},
   1027 			{'base':'DZ','letters':/[\u01F1\u01C4]/g},
   1028 			{'base':'Dz','letters':/[\u01F2\u01C5]/g},
   1029 			{'base':'E', 'letters':/[\u0045\u24BA\uFF25\u00C8\u00C9\u00CA\u1EC0\u1EBE\u1EC4\u1EC2\u1EBC\u0112\u1E14\u1E16\u0114\u0116\u00CB\u1EBA\u011A\u0204\u0206\u1EB8\u1EC6\u0228\u1E1C\u0118\u1E18\u1E1A\u0190\u018E]/g},
   1030 			{'base':'F', 'letters':/[\u0046\u24BB\uFF26\u1E1E\u0191\uA77B]/g},
   1031 			{'base':'G', 'letters':/[\u0047\u24BC\uFF27\u01F4\u011C\u1E20\u011E\u0120\u01E6\u0122\u01E4\u0193\uA7A0\uA77D\uA77E]/g},
   1032 			{'base':'H', 'letters':/[\u0048\u24BD\uFF28\u0124\u1E22\u1E26\u021E\u1E24\u1E28\u1E2A\u0126\u2C67\u2C75\uA78D]/g},
   1033 			{'base':'I', 'letters':/[\u0049\u24BE\uFF29\u00CC\u00CD\u00CE\u0128\u012A\u012C\u0130\u00CF\u1E2E\u1EC8\u01CF\u0208\u020A\u1ECA\u012E\u1E2C\u0197]/g},
   1034 			{'base':'J', 'letters':/[\u004A\u24BF\uFF2A\u0134\u0248]/g},
   1035 			{'base':'K', 'letters':/[\u004B\u24C0\uFF2B\u1E30\u01E8\u1E32\u0136\u1E34\u0198\u2C69\uA740\uA742\uA744\uA7A2]/g},
   1036 			{'base':'L', 'letters':/[\u004C\u24C1\uFF2C\u013F\u0139\u013D\u1E36\u1E38\u013B\u1E3C\u1E3A\u0141\u023D\u2C62\u2C60\uA748\uA746\uA780]/g},
   1037 			{'base':'LJ','letters':/[\u01C7]/g},
   1038 			{'base':'Lj','letters':/[\u01C8]/g},
   1039 			{'base':'M', 'letters':/[\u004D\u24C2\uFF2D\u1E3E\u1E40\u1E42\u2C6E\u019C]/g},
   1040 			{'base':'N', 'letters':/[\u004E\u24C3\uFF2E\u01F8\u0143\u00D1\u1E44\u0147\u1E46\u0145\u1E4A\u1E48\u0220\u019D\uA790\uA7A4]/g},
   1041 			{'base':'NJ','letters':/[\u01CA]/g},
   1042 			{'base':'Nj','letters':/[\u01CB]/g},
   1043 			{'base':'O', 'letters':/[\u004F\u24C4\uFF2F\u00D2\u00D3\u00D4\u1ED2\u1ED0\u1ED6\u1ED4\u00D5\u1E4C\u022C\u1E4E\u014C\u1E50\u1E52\u014E\u022E\u0230\u00D6\u022A\u1ECE\u0150\u01D1\u020C\u020E\u01A0\u1EDC\u1EDA\u1EE0\u1EDE\u1EE2\u1ECC\u1ED8\u01EA\u01EC\u00D8\u01FE\u0186\u019F\uA74A\uA74C]/g},
   1044 			{'base':'OE','letters':/[\u0152]/g},
   1045 			{'base':'OI','letters':/[\u01A2]/g},
   1046 			{'base':'OO','letters':/[\uA74E]/g},
   1047 			{'base':'OU','letters':/[\u0222]/g},
   1048 			{'base':'P', 'letters':/[\u0050\u24C5\uFF30\u1E54\u1E56\u01A4\u2C63\uA750\uA752\uA754]/g},
   1049 			{'base':'Q', 'letters':/[\u0051\u24C6\uFF31\uA756\uA758\u024A]/g},
   1050 			{'base':'R', 'letters':/[\u0052\u24C7\uFF32\u0154\u1E58\u0158\u0210\u0212\u1E5A\u1E5C\u0156\u1E5E\u024C\u2C64\uA75A\uA7A6\uA782]/g},
   1051 			{'base':'S', 'letters':/[\u0053\u24C8\uFF33\u1E9E\u015A\u1E64\u015C\u1E60\u0160\u1E66\u1E62\u1E68\u0218\u015E\u2C7E\uA7A8\uA784]/g},
   1052 			{'base':'T', 'letters':/[\u0054\u24C9\uFF34\u1E6A\u0164\u1E6C\u021A\u0162\u1E70\u1E6E\u0166\u01AC\u01AE\u023E\uA786]/g},
   1053 			{'base':'TZ','letters':/[\uA728]/g},
   1054 			{'base':'U', 'letters':/[\u0055\u24CA\uFF35\u00D9\u00DA\u00DB\u0168\u1E78\u016A\u1E7A\u016C\u00DC\u01DB\u01D7\u01D5\u01D9\u1EE6\u016E\u0170\u01D3\u0214\u0216\u01AF\u1EEA\u1EE8\u1EEE\u1EEC\u1EF0\u1EE4\u1E72\u0172\u1E76\u1E74\u0244]/g},
   1055 			{'base':'V', 'letters':/[\u0056\u24CB\uFF36\u1E7C\u1E7E\u01B2\uA75E\u0245]/g},
   1056 			{'base':'VY','letters':/[\uA760]/g},
   1057 			{'base':'W', 'letters':/[\u0057\u24CC\uFF37\u1E80\u1E82\u0174\u1E86\u1E84\u1E88\u2C72]/g},
   1058 			{'base':'X', 'letters':/[\u0058\u24CD\uFF38\u1E8A\u1E8C]/g},
   1059 			{'base':'Y', 'letters':/[\u0059\u24CE\uFF39\u1EF2\u00DD\u0176\u1EF8\u0232\u1E8E\u0178\u1EF6\u1EF4\u01B3\u024E\u1EFE]/g},
   1060 			{'base':'Z', 'letters':/[\u005A\u24CF\uFF3A\u0179\u1E90\u017B\u017D\u1E92\u1E94\u01B5\u0224\u2C7F\u2C6B\uA762]/g},
   1061 		],
   1062 		
   1063 		lowercase: [
   1064 			{'base':'a', 'letters':/[\u0061\u24D0\uFF41\u1E9A\u00E0\u00E1\u00E2\u1EA7\u1EA5\u1EAB\u1EA9\u00E3\u0101\u0103\u1EB1\u1EAF\u1EB5\u1EB3\u0227\u01E1\u00E4\u01DF\u1EA3\u00E5\u01FB\u01CE\u0201\u0203\u1EA1\u1EAD\u1EB7\u1E01\u0105\u2C65\u0250]/g},
   1065 			{'base':'aa','letters':/[\uA733]/g},
   1066 			{'base':'ae','letters':/[\u00E6\u01FD\u01E3]/g},
   1067 			{'base':'ao','letters':/[\uA735]/g},
   1068 			{'base':'au','letters':/[\uA737]/g},
   1069 			{'base':'av','letters':/[\uA739\uA73B]/g},
   1070 			{'base':'ay','letters':/[\uA73D]/g},
   1071 			{'base':'b', 'letters':/[\u0062\u24D1\uFF42\u1E03\u1E05\u1E07\u0180\u0183\u0253]/g},
   1072 			{'base':'c', 'letters':/[\u0063\u24D2\uFF43\u0107\u0109\u010B\u010D\u00E7\u1E09\u0188\u023C\uA73F\u2184]/g},
   1073 			{'base':'d', 'letters':/[\u0064\u24D3\uFF44\u1E0B\u010F\u1E0D\u1E11\u1E13\u1E0F\u0111\u018C\u0256\u0257\uA77A]/g},
   1074 			{'base':'dz','letters':/[\u01F3\u01C6]/g},
   1075 			{'base':'e', 'letters':/[\u0065\u24D4\uFF45\u00E8\u00E9\u00EA\u1EC1\u1EBF\u1EC5\u1EC3\u1EBD\u0113\u1E15\u1E17\u0115\u0117\u00EB\u1EBB\u011B\u0205\u0207\u1EB9\u1EC7\u0229\u1E1D\u0119\u1E19\u1E1B\u0247\u025B\u01DD]/g},
   1076 			{'base':'f', 'letters':/[\u0066\u24D5\uFF46\u1E1F\u0192\uA77C]/g},
   1077 			{'base':'g', 'letters':/[\u0067\u24D6\uFF47\u01F5\u011D\u1E21\u011F\u0121\u01E7\u0123\u01E5\u0260\uA7A1\u1D79\uA77F]/g},
   1078 			{'base':'h', 'letters':/[\u0068\u24D7\uFF48\u0125\u1E23\u1E27\u021F\u1E25\u1E29\u1E2B\u1E96\u0127\u2C68\u2C76\u0265]/g},
   1079 			{'base':'hv','letters':/[\u0195]/g},
   1080 			{'base':'i', 'letters':/[\u0069\u24D8\uFF49\u00EC\u00ED\u00EE\u0129\u012B\u012D\u00EF\u1E2F\u1EC9\u01D0\u0209\u020B\u1ECB\u012F\u1E2D\u0268\u0131]/g},
   1081 			{'base':'j', 'letters':/[\u006A\u24D9\uFF4A\u0135\u01F0\u0249]/g},
   1082 			{'base':'k', 'letters':/[\u006B\u24DA\uFF4B\u1E31\u01E9\u1E33\u0137\u1E35\u0199\u2C6A\uA741\uA743\uA745\uA7A3]/g},
   1083 			{'base':'l', 'letters':/[\u006C\u24DB\uFF4C\u0140\u013A\u013E\u1E37\u1E39\u013C\u1E3D\u1E3B\u017F\u0142\u019A\u026B\u2C61\uA749\uA781\uA747]/g},
   1084 			{'base':'lj','letters':/[\u01C9]/g},
   1085 			{'base':'m', 'letters':/[\u006D\u24DC\uFF4D\u1E3F\u1E41\u1E43\u0271\u026F]/g},
   1086 			{'base':'n', 'letters':/[\u006E\u24DD\uFF4E\u01F9\u0144\u00F1\u1E45\u0148\u1E47\u0146\u1E4B\u1E49\u019E\u0272\u0149\uA791\uA7A5]/g},
   1087 			{'base':'nj','letters':/[\u01CC]/g},
   1088 			{'base':'o', 'letters':/[\u006F\u24DE\uFF4F\u00F2\u00F3\u00F4\u1ED3\u1ED1\u1ED7\u1ED5\u00F5\u1E4D\u022D\u1E4F\u014D\u1E51\u1E53\u014F\u022F\u0231\u00F6\u022B\u1ECF\u0151\u01D2\u020D\u020F\u01A1\u1EDD\u1EDB\u1EE1\u1EDF\u1EE3\u1ECD\u1ED9\u01EB\u01ED\u00F8\u01FF\u0254\uA74B\uA74D\u0275]/g},
   1089 			{'base':'oe','letters':/[\u0153]/g},
   1090 			{'base':'oi','letters':/[\u01A3]/g},
   1091 			{'base':'ou','letters':/[\u0223]/g},
   1092 			{'base':'oo','letters':/[\uA74F]/g},
   1093 			{'base':'p','letters':/[\u0070\u24DF\uFF50\u1E55\u1E57\u01A5\u1D7D\uA751\uA753\uA755]/g},
   1094 			{'base':'q','letters':/[\u0071\u24E0\uFF51\u024B\uA757\uA759]/g},
   1095 			{'base':'r','letters':/[\u0072\u24E1\uFF52\u0155\u1E59\u0159\u0211\u0213\u1E5B\u1E5D\u0157\u1E5F\u024D\u027D\uA75B\uA7A7\uA783]/g},
   1096 			{'base':'s','letters':/[\u0073\u24E2\uFF53\u00DF\u015B\u1E65\u015D\u1E61\u0161\u1E67\u1E63\u1E69\u0219\u015F\u023F\uA7A9\uA785\u1E9B]/g},
   1097 			{'base':'t','letters':/[\u0074\u24E3\uFF54\u1E6B\u1E97\u0165\u1E6D\u021B\u0163\u1E71\u1E6F\u0167\u01AD\u0288\u2C66\uA787]/g},
   1098 			{'base':'tz','letters':/[\uA729]/g},
   1099 			{'base':'u','letters':/[\u0075\u24E4\uFF55\u00F9\u00FA\u00FB\u0169\u1E79\u016B\u1E7B\u016D\u00FC\u01DC\u01D8\u01D6\u01DA\u1EE7\u016F\u0171\u01D4\u0215\u0217\u01B0\u1EEB\u1EE9\u1EEF\u1EED\u1EF1\u1EE5\u1E73\u0173\u1E77\u1E75\u0289]/g},
   1100 			{'base':'v','letters':/[\u0076\u24E5\uFF56\u1E7D\u1E7F\u028B\uA75F\u028C]/g},
   1101 			{'base':'vy','letters':/[\uA761]/g},
   1102 			{'base':'w','letters':/[\u0077\u24E6\uFF57\u1E81\u1E83\u0175\u1E87\u1E85\u1E98\u1E89\u2C73]/g},
   1103 			{'base':'x','letters':/[\u0078\u24E7\uFF58\u1E8B\u1E8D]/g},
   1104 			{'base':'y','letters':/[\u0079\u24E8\uFF59\u1EF3\u00FD\u0177\u1EF9\u0233\u1E8F\u00FF\u1EF7\u1E99\u1EF5\u01B4\u024F\u1EFF]/g},
   1105 			{'base':'z','letters':/[\u007A\u24E9\uFF5A\u017A\u1E91\u017C\u017E\u1E93\u1E95\u01B6\u0225\u0240\u2C6C\uA763]/g}
   1106 		]
   1107 	},
   1108 	
   1109 	/**
   1110 	 * Run sets of data through multiple asynchronous callbacks
   1111 	 *
   1112 	 * Each callback is passed the current set and a callback to call when done
   1113 	 *
   1114 	 * @param	{Object[]}		sets			Sets of data
   1115 	 * @param	{Function[]}	callbacks
   1116 	 * @param	{Function}		onDone			Function to call when done
   1117 	 */
   1118 	 "processAsync":function (sets, callbacks, onDone) {
   1119 		if(sets.wrappedJSObject) sets = sets.wrappedJSObject;
   1120 		if(callbacks.wrappedJSObject) callbacks = callbacks.wrappedJSObject;
   1121 
   1122 		var currentSet;
   1123 		var index = 0;
   1124 		
   1125 		var nextSet = function () {
   1126 			if (!sets.length) {
   1127 				onDone();
   1128 				return;
   1129 			}
   1130 			index = 0;
   1131 			currentSet = sets.shift();
   1132 			callbacks[0](currentSet, nextCallback);
   1133 		};
   1134 		var nextCallback = function () {
   1135 			index++;
   1136 			callbacks[index](currentSet, nextCallback);
   1137 		};
   1138 		
   1139 		// Add a final callback to proceed to the next set
   1140 		callbacks[callbacks.length] = function () {
   1141 			nextSet();
   1142 		}
   1143 		nextSet();
   1144 	},
   1145 	
   1146 	/**
   1147 	 * Performs a deep copy of a JavaScript object
   1148 	 * @param {Object} obj
   1149 	 * @return {Object}
   1150 	 */
   1151 	"deepCopy":function(obj) {
   1152 		var obj2 = (obj instanceof Array ? [] : {});
   1153 		for(var i in obj) {
   1154 			if(!obj.hasOwnProperty(i)) continue;
   1155 			
   1156 			if(typeof obj[i] === "object" && obj[i] !== null) {
   1157 				obj2[i] = Zotero.Utilities.deepCopy(obj[i]);
   1158 			} else {
   1159 				obj2[i] = obj[i];
   1160 			}
   1161 		}
   1162 		return obj2;
   1163 	},
   1164 	
   1165 	/**
   1166 	 * Tests if an item type exists
   1167 	 *
   1168 	 * @param {String} type Item type
   1169 	 * @type Boolean
   1170 	 */
   1171 	"itemTypeExists":function(type) {
   1172 		if(Zotero.ItemTypes.getID(type)) {
   1173 			return true;
   1174 		} else {
   1175 			return false;
   1176 		}
   1177 	},
   1178 	
   1179 	/**
   1180 	 * Find valid creator types for a given item type
   1181 	 *
   1182 	 * @param {String} type Item type
   1183 	 * @return {String[]} Creator types
   1184 	 */
   1185 	"getCreatorsForType":function(type) {
   1186 		if(type === "attachment" || type === "note") return [];
   1187 		var types = Zotero.CreatorTypes.getTypesForItemType(Zotero.ItemTypes.getID(type));
   1188 		var cleanTypes = new Array();
   1189 		for(var i=0; i<types.length; i++) {
   1190 			cleanTypes.push(types[i].name);
   1191 		}
   1192 		return cleanTypes;
   1193 	},
   1194 	
   1195 	/**
   1196 	 * Determine whether a given field is valid for a given item type
   1197 	 *
   1198 	 * @param {String} field Field name
   1199 	 * @param {String} type Item type
   1200 	 * @type Boolean
   1201 	 */
   1202 	"fieldIsValidForType":function(field, type) {
   1203 		return Zotero.ItemFields.isValidForType(field, Zotero.ItemTypes.getID(type));
   1204 	},
   1205 	
   1206 	/**
   1207 	 * Gets a creator type name, localized to the current locale
   1208 	 *
   1209 	 * @param {String} type Creator type
   1210 	 * @param {String} Localized creator type
   1211 	 * @type Boolean
   1212 	 */
   1213 	"getLocalizedCreatorType":function(type) {
   1214 		try {
   1215 			return Zotero.CreatorTypes.getLocalizedString(type);
   1216 		} catch(e) {
   1217 			return false;
   1218 		}
   1219 	},
   1220 	
   1221 	/**
   1222 	 * Escapes metacharacters in a literal so that it may be used in a regular expression
   1223 	 */
   1224 	"quotemeta":function(literal) {
   1225 		if(typeof literal !== "string") {
   1226 			throw "Argument "+literal+" must be a string in Zotero.Utilities.quotemeta()";
   1227 		}
   1228 		const metaRegexp = /[-[\]{}()*+?.\\^$|,#\s]/g;
   1229 		return literal.replace(metaRegexp, "\\$&");
   1230 	},
   1231 	
   1232 	/**
   1233 	 * Evaluate an XPath
   1234 	 *
   1235 	 * @param {element|element[]} elements The element(s) to use as the context for the XPath
   1236 	 * @param {String} xpath The XPath expression
   1237 	 * @param {Object} [namespaces] An object whose keys represent namespace prefixes, and whose
   1238 	 *                              values represent their URIs
   1239 	 * @return {element[]} DOM elements matching XPath
   1240 	 */
   1241 	"xpath":function(elements, xpath, namespaces) {
   1242 		var nsResolver = null;
   1243 		if(namespaces) {
   1244 			nsResolver = function(prefix) {
   1245 				return namespaces[prefix] || null;
   1246 			};
   1247 		}
   1248 		
   1249 		if(!("length" in elements)) elements = [elements];
   1250 		
   1251 		var results = [];
   1252 		for(var i=0, n=elements.length; i<n; i++) {
   1253 			// For some reason, if elements is wrapped by an object
   1254 			// Xray, we won't be able to unwrap the DOMWrapper around
   1255 			// the element. So waive the object Xray.
   1256 			var maybeWrappedEl = elements.wrappedJSObject ? elements.wrappedJSObject[i] : elements[i];
   1257 			
   1258 			// Firefox 5 hack, so we will preserve Fx5DOMWrappers
   1259 			var isWrapped = Zotero.Translate.DOMWrapper && Zotero.Translate.DOMWrapper.isWrapped(maybeWrappedEl);
   1260 			var element = isWrapped ? Zotero.Translate.DOMWrapper.unwrap(maybeWrappedEl) : maybeWrappedEl;
   1261 
   1262 			// We waived the object Xray above, which will waive the
   1263 			// DOM Xray, so make sure we have a DOM Xray wrapper.
   1264 			if(Zotero.isFx) {
   1265 				element = new XPCNativeWrapper(element);
   1266 			}
   1267 			
   1268 			if(element.ownerDocument) {
   1269 				var rootDoc = element.ownerDocument;
   1270 			} else if(element.documentElement) {
   1271 				var rootDoc = element;
   1272 			} else if(Zotero.isIE && element.documentElement === null) {
   1273 				// IE: documentElement may be null if there is a parse error. In this
   1274 				// case, we don't match anything to mimic what would happen with DOMParser
   1275 				continue;
   1276 			} else {
   1277 				throw new Error("First argument must be either element(s) or document(s) in Zotero.Utilities.xpath(elements, '"+xpath+"')");
   1278 			}
   1279 			
   1280 			if(!Zotero.isIE || "evaluate" in rootDoc) {
   1281 				try {
   1282 					// This may result in a deprecation warning in the console due to
   1283 					// https://bugzilla.mozilla.org/show_bug.cgi?id=674437
   1284 					var xpathObject = rootDoc.evaluate(xpath, element, nsResolver, 5 /*ORDERED_NODE_ITERATOR_TYPE*/, null);
   1285 				} catch(e) {
   1286 					// rethrow so that we get a stack
   1287 					throw new Error(e.name+": "+e.message);
   1288 				}
   1289 				
   1290 				var newEl;
   1291 				while(newEl = xpathObject.iterateNext()) {
   1292 					// Firefox 5 hack
   1293 					results.push(isWrapped ? Zotero.Translate.DOMWrapper.wrapIn(newEl, maybeWrappedEl) : newEl);
   1294 				}
   1295 			} else if("selectNodes" in element) {
   1296 				// We use JavaScript-XPath in IE for HTML documents, but with an XML
   1297 				// document, we need to use selectNodes
   1298 				if(namespaces) {
   1299 					var ieNamespaces = [];
   1300 					for(var j in namespaces) {
   1301 						if(!j) continue;
   1302 						ieNamespaces.push('xmlns:'+j+'="'+Zotero.Utilities.htmlSpecialChars(namespaces[j])+'"');
   1303 					}
   1304 					rootDoc.setProperty("SelectionNamespaces", ieNamespaces.join(" "));
   1305 				}
   1306 				var nodes = element.selectNodes(xpath);
   1307 				for(var j=0; j<nodes.length; j++) {
   1308 					results.push(nodes[j]);
   1309 				}
   1310 			} else {
   1311 				throw new Error("XPath functionality not available");
   1312 			}
   1313 		}
   1314 		
   1315 		return results;
   1316 	},
   1317 	
   1318 	/**
   1319 	 * Generates a string from the content of nodes matching a given XPath
   1320 	 *
   1321 	 * @param {element} node The node representing the document and context
   1322 	 * @param {String} xpath The XPath expression
   1323 	 * @param {Object} [namespaces] An object whose keys represent namespace prefixes, and whose
   1324 	 *                              values represent their URIs
   1325 	 * @param {String} [delimiter] The string with which to join multiple matching nodes
   1326 	 * @return {String|null} DOM elements matching XPath, or null if no elements exist
   1327 	 */
   1328 	"xpathText":function(node, xpath, namespaces, delimiter) {
   1329 		var elements = Zotero.Utilities.xpath(node, xpath, namespaces);
   1330 		if(!elements.length) return null;
   1331 		
   1332 		var strings = new Array(elements.length);
   1333 		for(var i=0, n=elements.length; i<n; i++) {
   1334 			var el = elements[i];
   1335 			if(el.wrappedJSObject) el = el.wrappedJSObject;
   1336 			if(Zotero.Translate.DOMWrapper) el = Zotero.Translate.DOMWrapper.unwrap(el);
   1337 			strings[i] =
   1338 				(el.nodeType === 2 /*ATTRIBUTE_NODE*/ && "value" in el) ? el.value
   1339 				: "textContent" in el ? el.textContent
   1340 				: "innerText" in el ? el.innerText
   1341 				: "text" in el ? el.text
   1342 				: el.nodeValue;
   1343 		}
   1344 		
   1345 		return strings.join(delimiter !== undefined ? delimiter : ", ");
   1346 	},
   1347 	
   1348 	/**
   1349 	 * Generate a random string of length 'len' (defaults to 8)
   1350 	 **/
   1351 	"randomString":function(len, chars) {
   1352 		if (!chars) {
   1353 			chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
   1354 		}
   1355 		if (!len) {
   1356 			len = 8;
   1357 		}
   1358 		var randomstring = '';
   1359 		for (var i=0; i<len; i++) {
   1360 			var rnum = Math.floor(Math.random() * chars.length);
   1361 			randomstring += chars.substring(rnum,rnum+1);
   1362 		}
   1363 		return randomstring;
   1364 	},
   1365 	
   1366 	/**
   1367 	 * PHP var_dump equivalent for JS
   1368 	 *
   1369 	 * Adapted from http://binnyva.blogspot.com/2005/10/dump-function-javascript-equivalent-of.html
   1370 	 */
   1371 	"varDump": function(obj,level,maxLevel,parentObjects,path) {
   1372 		// Simple dump
   1373 		var type = typeof obj;
   1374 		if (type == 'number' || type == 'undefined' || type == 'boolean' || obj === null) {
   1375 			if (!level) {
   1376 				// When dumping these directly, make sure to distinguish them from regular
   1377 				// strings as output by Zotero.debug (i.e. no quotes)
   1378 				return '===>' + obj + '<=== (' + type + ')';
   1379 			}
   1380 			else {
   1381 				return '' + obj;
   1382 			}
   1383 		}
   1384 		else if (type == 'string') {
   1385 			return JSON.stringify(obj);
   1386 		}
   1387 		else if (type == 'function') {
   1388 			var funcStr = ('' + obj).trim();
   1389 			if (!level) {
   1390 				// Dump function contents as well if only dumping function
   1391 				return funcStr;
   1392 			}
   1393 			
   1394 			// Display [native code] label for native functions, but make it one line
   1395 			if (/^[^{]+{\s*\[native code\]\s*}$/i.test(funcStr)) {
   1396 				return funcStr.replace(/\s*(\[native code\])\s*/i, ' $1 ');
   1397 			}
   1398 			
   1399 			// For non-native functions, display an elipsis
   1400 			return ('' + obj).replace(/{[\s\S]*}/, '{...}');
   1401 		}
   1402 		else if (type != 'object') {
   1403 			return '<<Unknown type: ' + type + '>> ' + obj;
   1404 		}
   1405 		
   1406 		// Don't descend into global object cache for data objects
   1407 		if (Zotero.isClient && typeof obj == 'object' && obj instanceof Zotero.DataObject) {
   1408 			maxLevel = 1;
   1409 		}
   1410 		
   1411 		// More complex dump with indentation for objects
   1412 		if (level === undefined) {
   1413 			level = 0;
   1414 		}
   1415 		
   1416 		if (maxLevel === undefined) {
   1417 			maxLevel = 5;
   1418 		}
   1419 		
   1420 		var objType = Object.prototype.toString.call(obj);
   1421 		
   1422 		if (level > maxLevel) {
   1423 			return objType + " <<Maximum depth reached>>";
   1424 		}
   1425 		
   1426 		// The padding given at the beginning of the line.
   1427 		var level_padding = "";
   1428 		for (var j=0; j<level+1; j++) {
   1429 			level_padding += "    ";
   1430 		}
   1431 		
   1432 		//Special handling for Error or Exception
   1433 		var isException = Zotero.isFx && !Zotero.isBookmarklet && obj instanceof Components.interfaces.nsIException;
   1434 		var isError = obj instanceof Error;
   1435 		if (!isException && !isError && obj.message !== undefined && obj.stack !== undefined) {
   1436 			isError = true;
   1437 		}
   1438 		
   1439 		if (isError || isException) {
   1440 			var header = '';
   1441 			if (isError) {
   1442 				header = (obj.constructor && obj.constructor.name) ? obj.constructor.name : 'Error';
   1443 			} else {
   1444 				header = (obj.name ? obj.name + ' ' : '') + 'Exception';
   1445 			}
   1446 			
   1447 			let msg = (obj.message ? ('' + obj.message).replace(/^/gm, level_padding).trim() : '');
   1448 			if (obj.stack) {
   1449 				let stack = obj.stack.trim().replace(/^(?=.)/gm, level_padding);
   1450 				stack = Zotero.Utilities.Internal.filterStack(stack);
   1451 				
   1452 				msg += '\n\n';
   1453 				
   1454 				// At least with Zotero.HTTP.UnexpectedStatusException, the stack contains "Error:"
   1455 				// and the message in addition to the trace. I'm not sure what's causing that
   1456 				// (Bluebird?), but fix it here.
   1457 				if (stack.startsWith('Error:')) {
   1458 					msg += stack.replace('Error: ' + obj.message + '\n', '');
   1459 				}
   1460 				else {
   1461 					msg += stack;
   1462 				}
   1463 			}
   1464 			
   1465 			return header + ': ' + msg;
   1466 		}
   1467 		
   1468 		// Only dump single level for nsIDOMNode objects (including document)
   1469 		if (Zotero.isFx && !Zotero.isBookmarklet
   1470 			&& (obj instanceof Components.interfaces.nsIDOMNode
   1471 				|| obj instanceof Components.interfaces.nsIDOMWindow)
   1472 		) {
   1473 			level = maxLevel;
   1474 		}
   1475 		
   1476 		// Recursion checking
   1477 		if(!parentObjects) {
   1478 			parentObjects = [obj];
   1479 			path = ['ROOT'];
   1480 		}
   1481 		
   1482 		var isArray = objType == '[object Array]'
   1483 		if (isArray) {
   1484 			var dumpedText = '[';
   1485 		}
   1486 		else if (objType == '[object Object]') {
   1487 			var dumpedText = '{';
   1488 		}
   1489 		else {
   1490 			var dumpedText = objType + ' {';
   1491 		}
   1492 		for (var prop in obj) {
   1493 			dumpedText += '\n' + level_padding + JSON.stringify(prop) + ": ";
   1494 			
   1495 			try {
   1496 				var value = obj[prop];
   1497 			} catch(e) {
   1498 				dumpedText += "<<Access Denied>>";
   1499 				continue;
   1500 			}
   1501 			
   1502 			// Check for recursion
   1503 			if (typeof(value) == 'object') {
   1504 				var i = parentObjects.indexOf(value);
   1505 				if(i != -1) {
   1506 					var parentName = path.slice(0,i+1).join('->');
   1507 					dumpedText += "<<Reference to parent object " + parentName + " >>";
   1508 					continue;
   1509 				}
   1510 			}
   1511 			
   1512 			try {
   1513 				dumpedText += Zotero.Utilities.varDump(value,level+1,maxLevel,parentObjects.concat([value]),path.concat([prop]));
   1514 			} catch(e) {
   1515 				dumpedText += "<<Error processing property: " + e.message + " (" + value + ")>>";
   1516 			}
   1517 		}
   1518 		
   1519 		var lastChar = dumpedText.charAt(dumpedText.length - 1);
   1520 		if (lastChar != '[' && lastChar != '{') {
   1521 			dumpedText += '\n' + level_padding.substr(4);
   1522 		}
   1523 		dumpedText += isArray ? ']' : '}';
   1524 		
   1525 		return dumpedText;
   1526 	},
   1527 	
   1528 	/**
   1529 	 * Converts an item from toArray() format to citeproc-js JSON
   1530 	 * @param {Zotero.Item} zoteroItem
   1531 	 * @return {Object|Promise<Object>} A CSL item, or a promise for a CSL item if a Zotero.Item
   1532 	 *     is passed
   1533 	 */
   1534 	"itemToCSLJSON":function(zoteroItem) {
   1535 		// If a Zotero.Item was passed, convert it to the proper format (skipping child items) and
   1536 		// call this function again with that object
   1537 		//
   1538 		// (Zotero.Item won't be defined in translation-server)
   1539 		if (typeof Zotero.Item !== 'undefined' && zoteroItem instanceof Zotero.Item) {
   1540 			return this.itemToCSLJSON(
   1541 				Zotero.Utilities.Internal.itemToExportFormat(zoteroItem, false, true)
   1542 			);
   1543 		}
   1544 		
   1545 		var cslType = CSL_TYPE_MAPPINGS[zoteroItem.itemType];
   1546 		if (!cslType) {
   1547 			throw new Error('Unexpected Zotero Item type "' + zoteroItem.itemType + '"');
   1548 		}
   1549 		
   1550 		var itemTypeID = Zotero.ItemTypes.getID(zoteroItem.itemType);
   1551 		
   1552 		var cslItem = {
   1553 			'id':zoteroItem.uri,
   1554 			'type':cslType
   1555 		};
   1556 		
   1557 		// get all text variables (there must be a better way)
   1558 		for(var variable in CSL_TEXT_MAPPINGS) {
   1559 			var fields = CSL_TEXT_MAPPINGS[variable];
   1560 			for(var i=0, n=fields.length; i<n; i++) {
   1561 				var field = fields[i],
   1562 					value = null;
   1563 				
   1564 				if(field in zoteroItem) {
   1565 					value = zoteroItem[field];
   1566 				} else {
   1567 					if (field == 'versionNumber') field = 'version'; // Until https://github.com/zotero/zotero/issues/670
   1568 					var fieldID = Zotero.ItemFields.getID(field),
   1569 						typeFieldID;
   1570 					if(fieldID
   1571 						&& (typeFieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(itemTypeID, fieldID))
   1572 					) {
   1573 						value = zoteroItem[Zotero.ItemFields.getName(typeFieldID)];
   1574 					}
   1575 				}
   1576 				
   1577 				if (!value) continue;
   1578 				
   1579 				if (typeof value == 'string') {
   1580 					if (field == 'ISBN') {
   1581 						// Only use the first ISBN in CSL JSON
   1582 						var isbn = value.match(/^(?:97[89]-?)?(?:\d-?){9}[\dx](?!-)\b/i);
   1583 						if (isbn) value = isbn[0];
   1584 					}
   1585 					else if (field == 'extra') {
   1586 						value = Zotero.Cite.extraToCSL(value);
   1587 					}
   1588 					
   1589 					// Strip enclosing quotes
   1590 					if(value.charAt(0) == '"' && value.indexOf('"', 1) == value.length - 1) {
   1591 						value = value.substring(1, value.length-1);
   1592 					}
   1593 					cslItem[variable] = value;
   1594 					break;
   1595 				}
   1596 			}
   1597 		}
   1598 		
   1599 		// separate name variables
   1600 		if (zoteroItem.type != "attachment" && zoteroItem.type != "note") {
   1601 			var author = Zotero.CreatorTypes.getName(Zotero.CreatorTypes.getPrimaryIDForType(itemTypeID));
   1602 			var creators = zoteroItem.creators;
   1603 			for(var i=0; creators && i<creators.length; i++) {
   1604 				var creator = creators[i];
   1605 				var creatorType = creator.creatorType;
   1606 				if(creatorType == author) {
   1607 					creatorType = "author";
   1608 				}
   1609 				
   1610 				creatorType = CSL_NAMES_MAPPINGS[creatorType];
   1611 				if(!creatorType) continue;
   1612 				
   1613 				var nameObj;
   1614 				if (creator.lastName || creator.firstName) {
   1615 					nameObj = {
   1616 						family: creator.lastName || '',
   1617 						given: creator.firstName || ''
   1618 					};
   1619 					
   1620 					// Parse name particles
   1621 					// Replicate citeproc-js logic for what should be parsed so we don't
   1622 					// break current behavior.
   1623 					if (nameObj.family && nameObj.given) {
   1624 						// Don't parse if last name is quoted
   1625 						if (nameObj.family.length > 1
   1626 							&& nameObj.family.charAt(0) == '"'
   1627 							&& nameObj.family.charAt(nameObj.family.length - 1) == '"'
   1628 						) {
   1629 							nameObj.family = nameObj.family.substr(1, nameObj.family.length - 2);
   1630 						} else {
   1631 							Zotero.CiteProc.CSL.parseParticles(nameObj, true);
   1632 						}
   1633 					}
   1634 				} else if (creator.name) {
   1635 					nameObj = {'literal': creator.name};
   1636 				}
   1637 				
   1638 				if(cslItem[creatorType]) {
   1639 					cslItem[creatorType].push(nameObj);
   1640 				} else {
   1641 					cslItem[creatorType] = [nameObj];
   1642 				}
   1643 			}
   1644 		}
   1645 		
   1646 		// get date variables
   1647 		for(var variable in CSL_DATE_MAPPINGS) {
   1648 			var date = zoteroItem[CSL_DATE_MAPPINGS[variable]];
   1649 			if (!date) {
   1650 				var typeSpecificFieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(itemTypeID, CSL_DATE_MAPPINGS[variable]);
   1651 				if (typeSpecificFieldID) {
   1652 					date = zoteroItem[Zotero.ItemFields.getName(typeSpecificFieldID)];
   1653 				}
   1654 			}
   1655 			
   1656 			if(date) {
   1657 				var dateObj = Zotero.Date.strToDate(date);
   1658 				// otherwise, use date-parts
   1659 				var dateParts = [];
   1660 				if(dateObj.year) {
   1661 					// add year, month, and day, if they exist
   1662 					dateParts.push(dateObj.year);
   1663 					if(dateObj.month !== undefined) {
   1664 						// strToDate() returns a JS-style 0-indexed month, so we add 1 to it
   1665 						dateParts.push(dateObj.month+1);
   1666 						if(dateObj.day) {
   1667 							dateParts.push(dateObj.day);
   1668 						}
   1669 					}
   1670 					cslItem[variable] = {"date-parts":[dateParts]};
   1671 					
   1672 					// if no month, use season as month
   1673 					if(dateObj.part && dateObj.month === undefined) {
   1674 						cslItem[variable].season = dateObj.part;
   1675 					}
   1676 				} else {
   1677 					// if no year, pass date literally
   1678 					cslItem[variable] = {"literal":date};
   1679 				}
   1680 			}
   1681 		}
   1682 		
   1683 		// Special mapping for note title
   1684 		if (zoteroItem.itemType == 'note' && zoteroItem.note) {
   1685 			cslItem.title = Zotero.Notes.noteToTitle(zoteroItem.note);
   1686 		}
   1687 		
   1688 		//this._cache[zoteroItem.id] = cslItem;
   1689 		return cslItem;
   1690 	},
   1691 	
   1692 	/**
   1693 	 * Converts an item in CSL JSON format to a Zotero item
   1694 	 * @param {Zotero.Item} item
   1695 	 * @param {Object} cslItem
   1696 	 */
   1697 	"itemFromCSLJSON":function(item, cslItem) {
   1698 		var isZoteroItem = !!item.setType,
   1699 			zoteroType;
   1700 		
   1701 		// Some special cases to help us map item types correctly
   1702 		// This ensures that we don't lose data on import. The fields
   1703 		// we check are incompatible with the alternative item types
   1704 		if (cslItem.type == 'book') {
   1705 			zoteroType = 'book';
   1706 			if (cslItem.version) {
   1707 				zoteroType = 'computerProgram';
   1708 			}
   1709 		} else if (cslItem.type == 'bill') {
   1710 			zoteroType = 'bill';
   1711 			if (cslItem.publisher || cslItem['number-of-volumes']) {
   1712 				zoteroType = 'hearing';
   1713 			}
   1714 		} else if (cslItem.type == 'song') {
   1715 			zoteroType = 'audioRecording';
   1716 			if (cslItem.number) {
   1717 				zoteroType = 'podcast';
   1718 			}
   1719 		} else if (cslItem.type == 'motion_picture') {
   1720 			zoteroType = 'film';
   1721 			if (cslItem['collection-title'] || cslItem['publisher-place']
   1722 				|| cslItem['event-place'] || cslItem.volume
   1723 				|| cslItem['number-of-volumes'] || cslItem.ISBN
   1724 			) {
   1725 				zoteroType = 'videoRecording';
   1726 			}
   1727 		} else {
   1728 			for(var type in CSL_TYPE_MAPPINGS) {
   1729 				if(CSL_TYPE_MAPPINGS[type] == cslItem.type) {
   1730 					zoteroType = type;
   1731 					break;
   1732 				}
   1733 			}
   1734 		}
   1735 		
   1736 		if(!zoteroType) zoteroType = "document";
   1737 		
   1738 		var itemTypeID = Zotero.ItemTypes.getID(zoteroType);
   1739 		if(isZoteroItem) {
   1740 			item.setType(itemTypeID);
   1741 		} else {
   1742 			item.itemID = cslItem.id;
   1743 			item.itemType = zoteroType;
   1744 		}
   1745 		
   1746 		// map text fields
   1747 		for(var variable in CSL_TEXT_MAPPINGS) {
   1748 			if(variable in cslItem) {
   1749 				var textMappings = CSL_TEXT_MAPPINGS[variable];
   1750 				for(var i=0; i<textMappings.length; i++) {
   1751 					var field = textMappings[i];
   1752 					var fieldID = Zotero.ItemFields.getID(field);
   1753 
   1754 					if(Zotero.ItemFields.isBaseField(fieldID)) {
   1755 						var newFieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(itemTypeID, fieldID);
   1756 						if(newFieldID) fieldID = newFieldID;
   1757 					}
   1758 					
   1759 					if(Zotero.ItemFields.isValidForType(fieldID, itemTypeID)) {
   1760 						// TODO: Convert restrictive Extra cheater syntax ('original-date: 2018')
   1761 						// to nicer format we allow ('Original Date: 2018'), unless we've added
   1762 						// those fields before we get to that
   1763 						if(isZoteroItem) {
   1764 							item.setField(fieldID, cslItem[variable]);
   1765 						} else {
   1766 							item[field] = cslItem[variable];
   1767 						}
   1768 						
   1769 						break;
   1770 					}
   1771 				}
   1772 			}
   1773 		}
   1774 		
   1775 		// separate name variables
   1776 		for(var field in CSL_NAMES_MAPPINGS) {
   1777 			if(CSL_NAMES_MAPPINGS[field] in cslItem) {
   1778 				var creatorTypeID = Zotero.CreatorTypes.getID(field);
   1779 				if(!Zotero.CreatorTypes.isValidForItemType(creatorTypeID, itemTypeID)) {
   1780 					creatorTypeID = Zotero.CreatorTypes.getPrimaryIDForType(itemTypeID);
   1781 				}
   1782 				
   1783 				var nameMappings = cslItem[CSL_NAMES_MAPPINGS[field]];
   1784 				for(var i in nameMappings) {
   1785 					var cslAuthor = nameMappings[i];
   1786 					let creator = {};
   1787 					if(cslAuthor.family || cslAuthor.given) {
   1788 						creator.lastName = cslAuthor.family || '';
   1789 						creator.firstName = cslAuthor.given || '';
   1790 					} else if(cslAuthor.literal) {
   1791 						creator.lastName = cslAuthor.literal;
   1792 						creator.fieldMode = 1;
   1793 					} else {
   1794 						continue;
   1795 					}
   1796 					creator.creatorTypeID = creatorTypeID;
   1797 					
   1798 					if(isZoteroItem) {
   1799 						item.setCreator(item.getCreators().length, creator);
   1800 					} else {
   1801 						creator.creatorType = Zotero.CreatorTypes.getName(creatorTypeID);
   1802 						if (Zotero.isFx && !Zotero.isBookmarklet) {
   1803 							creator = Components.utils.cloneInto(creator, item);
   1804 						}
   1805 						item.creators.push(creator);
   1806 					}
   1807 				}
   1808 			}
   1809 		}
   1810 		
   1811 		// get date variables
   1812 		for(var variable in CSL_DATE_MAPPINGS) {
   1813 			if(variable in cslItem) {
   1814 				var field = CSL_DATE_MAPPINGS[variable],
   1815 					fieldID = Zotero.ItemFields.getID(field),
   1816 					cslDate = cslItem[variable];
   1817 				var fieldID = Zotero.ItemFields.getID(field);
   1818 				if(Zotero.ItemFields.isBaseField(fieldID)) {
   1819 					var newFieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(itemTypeID, fieldID);
   1820 					if(newFieldID) fieldID = newFieldID;
   1821 				}
   1822 				
   1823 				if(Zotero.ItemFields.isValidForType(fieldID, itemTypeID)) {
   1824 					var date = "";
   1825 					if(cslDate.literal || cslDate.raw) {
   1826 						date = cslDate.literal || cslDate.raw;
   1827 						if(variable === "accessed") {
   1828 							date = Zotero.Date.strToISO(date);
   1829 						}
   1830 					} else {
   1831 						var newDate = Zotero.Utilities.deepCopy(cslDate);
   1832 						if(cslDate["date-parts"] && typeof cslDate["date-parts"] === "object"
   1833 								&& cslDate["date-parts"] !== null
   1834 								&& typeof cslDate["date-parts"][0] === "object"
   1835 								&& cslDate["date-parts"][0] !== null) {
   1836 							if(cslDate["date-parts"][0][0]) newDate.year = cslDate["date-parts"][0][0];
   1837 							if(cslDate["date-parts"][0][1]) newDate.month = cslDate["date-parts"][0][1];
   1838 							if(cslDate["date-parts"][0][2]) newDate.day = cslDate["date-parts"][0][2];
   1839 						}
   1840 						
   1841 						if(newDate.year) {
   1842 							if(variable === "accessed") {
   1843 								// Need to convert to SQL
   1844 								var date = Zotero.Utilities.lpad(newDate.year, "0", 4);
   1845 								if(newDate.month) {
   1846 									date += "-"+Zotero.Utilities.lpad(newDate.month, "0", 2);
   1847 									if(newDate.day) {
   1848 										date += "-"+Zotero.Utilities.lpad(newDate.day, "0", 2);
   1849 									}
   1850 								}
   1851 							} else {
   1852 								if(newDate.month) newDate.month--;
   1853 								date = Zotero.Date.formatDate(newDate);
   1854 								if(newDate.season) {
   1855 									date = newDate.season+" "+date;
   1856 								}
   1857 							}
   1858 						}
   1859 					}
   1860 					
   1861 					if(isZoteroItem) {
   1862 						item.setField(fieldID, date);
   1863 					} else {
   1864 						item[field] = date;
   1865 					}
   1866 				}
   1867 			}
   1868 		}
   1869 	},
   1870 	
   1871 	/**
   1872 	 * Get the real target URL from an intermediate URL
   1873 	 */
   1874 	"resolveIntermediateURL":function(url) {
   1875 		var patterns = [
   1876 			// Google search results
   1877 			{
   1878 				regexp: /^https?:\/\/(www.)?google\.(com|(com?\.)?[a-z]{2})\/url\?/,
   1879 				variable: "url"
   1880 			}
   1881 		];
   1882 		
   1883 		for (var i=0, len=patterns.length; i<len; i++) {
   1884 			if (!url.match(patterns[i].regexp)) {
   1885 				continue;
   1886 			}
   1887 			var matches = url.match(new RegExp("&" + patterns[i].variable + "=(.+?)(&|$)"));
   1888 			if (!matches) {
   1889 				continue;
   1890 			}
   1891 			return decodeURIComponent(matches[1]);
   1892 		}
   1893 		
   1894 		return url;
   1895 	},
   1896 	
   1897 	/**
   1898 	 * Adds a string to a given array at a given offset, converted to UTF-8
   1899 	 * @param {String} string The string to convert to UTF-8
   1900 	 * @param {Array|Uint8Array} array The array to which to add the string
   1901 	 * @param {Integer} [offset] Offset at which to add the string
   1902 	 */
   1903 	"stringToUTF8Array":function(string, array, offset) {
   1904 		if(!offset) offset = 0;
   1905 		var n = string.length;
   1906 		for(var i=0; i<n; i++) {
   1907 			var val = string.charCodeAt(i);
   1908 			if(val >= 128) {
   1909 				if(val >= 2048) {
   1910 					array[offset] = (val >>> 12) | 224;
   1911 					array[offset+1] = ((val >>> 6) & 63) | 128;
   1912 					array[offset+2] = (val & 63) | 128;
   1913 					offset += 3;
   1914 				} else {
   1915 					array[offset] = ((val >>> 6) | 192);
   1916 					array[offset+1] = (val & 63) | 128;
   1917 					offset += 2;
   1918 				}
   1919 			} else {
   1920 				array[offset++] = val;
   1921 			}
   1922 		}
   1923 	},
   1924 	
   1925 	/**
   1926 	 * Gets the byte length of the UTF-8 representation of a given string
   1927 	 * @param {String} string
   1928 	 * @return {Integer}
   1929 	 */
   1930 	"getStringByteLength":function(string) {
   1931 		var length = 0, n = string.length;
   1932 		for(var i=0; i<n; i++) {
   1933 			var val = string.charCodeAt(i);
   1934 			if(val >= 128) {
   1935 				if(val >= 2048) {
   1936 					length += 3;
   1937 				} else {
   1938 					length += 2;
   1939 				}
   1940 			} else {
   1941 				length += 1;
   1942 			}
   1943 		}
   1944 		return length;
   1945 	},
   1946 	
   1947 	/**
   1948 	 * Gets the icon for a JSON-style attachment
   1949 	 */
   1950 	"determineAttachmentIcon":function(attachment) {
   1951 		if(attachment.linkMode === "linked_url") {
   1952 			return Zotero.ItemTypes.getImageSrc("attachment-web-link");
   1953 		}
   1954 		return Zotero.ItemTypes.getImageSrc(attachment.mimeType === "application/pdf"
   1955 							? "attachment-pdf" : "attachment-snapshot");
   1956 	},
   1957 	
   1958 	"allowedKeyChars": "23456789ABCDEFGHIJKLMNPQRSTUVWXYZ",
   1959 	
   1960 	/**
   1961 	 * Generates a valid object key for the server API
   1962 	 */
   1963 	"generateObjectKey":function generateObjectKey() {
   1964 		return Zotero.Utilities.randomString(8, Zotero.Utilities.allowedKeyChars);
   1965 	},
   1966 	
   1967 	/**
   1968 	 * Check if an object key is in a valid format
   1969 	 */
   1970 	"isValidObjectKey":function(key) {
   1971 		if (!Zotero.Utilities.objectKeyRegExp) {
   1972 			Zotero.Utilities.objectKeyRegExp = new RegExp('^[' + Zotero.Utilities.allowedKeyChars + ']{8}$');
   1973 		}
   1974 		return Zotero.Utilities.objectKeyRegExp.test(key);
   1975 	},
   1976 	
   1977 	/**
   1978 	 * Provides unicode support and other additional features for regular expressions
   1979 	 * See https://github.com/slevithan/xregexp for usage
   1980 	 */
   1981 	 "XRegExp": XRegExp
   1982 }