www

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

support.js (26716B)


      1 chai.use(chaiAsPromised);
      2 
      3 // Useful "constants"
      4 var sqlDateTimeRe = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
      5 var isoDateTimeRe = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
      6 var zoteroObjectKeyRe = /^[23456789ABCDEFGHIJKLMNPQRSTUVWXYZ]{8}$/; // based on Zotero.Utilities::generateObjectKey()
      7 
      8 /**
      9  * Waits for a DOM event on the specified node. Returns a promise
     10  * resolved with the event.
     11  */
     12 function waitForDOMEvent(target, event, capture) {
     13 	var deferred = Zotero.Promise.defer();
     14 	var func = function(ev) {
     15 		target.removeEventListener(event, func, capture);
     16 		deferred.resolve(ev);
     17 	}
     18 	target.addEventListener(event, func, capture);
     19 	return deferred.promise;
     20 }
     21 
     22 async function waitForRecognizer() {
     23 	var win = await waitForWindow('chrome://zotero/content/recognizePDFDialog.xul')
     24 	// Wait for status to show as complete
     25 	var completeStr = Zotero.getString("recognizePDF.complete.label");
     26 	while (win.document.getElementById("label").value != completeStr) {
     27 		await Zotero.Promise.delay(20);
     28 	}
     29 	return win;
     30 }
     31 
     32 /**
     33  * Open a chrome window and return a promise for the window
     34  *
     35  * @return {Promise<ChromeWindow>}
     36  */
     37 function loadWindow(winurl, argument) {
     38 	var win = window.openDialog(winurl, "_blank", "chrome", argument);
     39 	return waitForDOMEvent(win, "load").then(function() {
     40 		return win;
     41 	});
     42 }
     43 
     44 /**
     45  * Open a browser window and return a promise for the window
     46  *
     47  * @return {Promise<ChromeWindow>}
     48  */
     49 function loadBrowserWindow() {
     50 	var win = window.openDialog("chrome://browser/content/browser.xul", "", "all,height=700,width=1000");
     51 	return waitForDOMEvent(win, "load").then(function() {
     52 		return win;
     53 	});
     54 }
     55 
     56 /**
     57  * Opens the Zotero pane and selects My Library. Returns the containing window.
     58  *
     59  * @param {Window} [win] - Existing window to use; if not specified, a new window is opened
     60  */
     61 var loadZoteroPane = Zotero.Promise.coroutine(function* (win) {
     62 	if (!win) {
     63 		var win = yield loadBrowserWindow();
     64 	}
     65 	Zotero.Prefs.clear('lastViewedFolder');
     66 	win.ZoteroOverlay.toggleDisplay(true);
     67 	
     68 	yield waitForItemsLoad(win, 0);
     69 	
     70 	return win;
     71 });
     72 
     73 var loadPrefPane = Zotero.Promise.coroutine(function* (paneName) {
     74 	var id = 'zotero-prefpane-' + paneName;
     75 	var win = yield loadWindow("chrome://zotero/content/preferences/preferences.xul", {
     76 		pane: id
     77 	});
     78 	var doc = win.document;
     79 	var defer = Zotero.Promise.defer();
     80 	var pane = doc.getElementById(id);
     81 	if (!pane.loaded) {
     82 		pane.addEventListener('paneload', () => defer.resolve());
     83 		yield defer.promise;
     84 	}
     85 	return win;
     86 });
     87 
     88 
     89 /**
     90  * Waits for a window with a specific URL to open. Returns a promise for the window, and
     91  * optionally passes the window to a callback immediately for use with modal dialogs,
     92  * which prevent async code from continuing
     93  */
     94 function waitForWindow(uri, callback) {
     95 	var deferred = Zotero.Promise.defer();
     96 	var loadobserver = function(ev) {
     97 		ev.originalTarget.removeEventListener("load", loadobserver, false);
     98 		Zotero.debug("Window opened: " + ev.target.location.href);
     99 		
    100 		if (ev.target.location.href != uri) {
    101 			Zotero.debug(`Ignoring window ${uri} in waitForWindow()`);
    102 			return;
    103 		}
    104 		
    105 		Services.ww.unregisterNotification(winobserver);
    106 		var win = ev.target.docShell
    107 			.QueryInterface(Components.interfaces.nsIInterfaceRequestor)
    108 			.getInterface(Components.interfaces.nsIDOMWindow);
    109 		// Give window code time to run on load
    110 		 win.setTimeout(function () {
    111 			if (callback) {
    112 				try {
    113 					// If callback returns a promise, wait for it
    114 					let maybePromise = callback(win);
    115 					if (maybePromise && maybePromise.then) {
    116 						maybePromise.then(() => deferred.resolve(win)).catch(e => deferred.reject(e));
    117 						return;
    118 					}
    119 				}
    120 				catch (e) {
    121 					Zotero.logError(e);
    122 					win.close();
    123 					deferred.reject(e);
    124 					return;
    125 				}
    126 			}
    127 			deferred.resolve(win);
    128 		});
    129 	};
    130 	var winobserver = {"observe":function(subject, topic, data) {
    131 		if(topic != "domwindowopened") return;
    132 		var win = subject.QueryInterface(Components.interfaces.nsIDOMWindow);
    133 		win.addEventListener("load", loadobserver, false);
    134 	}};
    135 	Services.ww.registerNotification(winobserver);
    136 	return deferred.promise;
    137 }
    138 
    139 /**
    140  * Wait for an alert or confirmation dialog to pop up and then close it
    141  *
    142  * @param {Function} [onOpen] - Function that is passed the dialog once it is opened.
    143  *                              Can be used to make assertions on the dialog contents
    144  *                              (e.g., with dialog.document.documentElement.textContent)
    145  * @param {String} [button='accept'] - Button in dialog to press (e.g., 'cancel', 'extra1')
    146  * @return {Promise}
    147  */
    148 function waitForDialog(onOpen, button='accept', url) {
    149 	return waitForWindow(url || "chrome://global/content/commonDialog.xul", Zotero.Promise.method(function (dialog) {
    150 		var failure = false;
    151 		if (onOpen) {
    152 			try {
    153 				onOpen(dialog);
    154 			}
    155 			catch (e) {
    156 				failure = e;
    157 			}
    158 		}
    159 		if (button === false) {
    160 			if (failure) {
    161 				throw failure;
    162 			}
    163 		}
    164 		else if (button != 'cancel') {
    165 			let deferred = Zotero.Promise.defer();
    166 			function acceptWhenEnabled() {
    167 				// Handle delayed buttons
    168 				if (dialog.document.documentElement.getButton(button).disabled) {
    169 					dialog.setTimeout(function () {
    170 						acceptWhenEnabled();
    171 					}, 250);
    172 				}
    173 				else {
    174 					dialog.document.documentElement.getButton(button).click();
    175 					if (failure) {
    176 						deferred.reject(failure);
    177 					}
    178 					else {
    179 						deferred.resolve();
    180 					}
    181 				}
    182 			}
    183 			acceptWhenEnabled();
    184 			return deferred.promise;
    185 		}
    186 		else {
    187 			dialog.document.documentElement.getButton(button).click();
    188 			if (failure) {
    189 				throw failure;
    190 			}
    191 		}
    192 	}))
    193 }
    194 
    195 var selectLibrary = Zotero.Promise.coroutine(function* (win, libraryID) {
    196 	libraryID = libraryID || Zotero.Libraries.userLibraryID;
    197 	yield win.ZoteroPane.collectionsView.selectLibrary(libraryID);
    198 	yield waitForItemsLoad(win);
    199 });
    200 
    201 var waitForItemsLoad = Zotero.Promise.coroutine(function* (win, collectionRowToSelect) {
    202 	var zp = win.ZoteroPane;
    203 	var cv = zp.collectionsView;
    204 	
    205 	yield cv.waitForLoad();
    206 	if (collectionRowToSelect !== undefined) {
    207 		yield cv.selectWait(collectionRowToSelect);
    208 	}
    209 	yield zp.itemsView.waitForLoad();
    210 });
    211 
    212 var waitForTagSelector = function (win) {
    213 	var zp = win.ZoteroPane;
    214 	var deferred = Zotero.Promise.defer();
    215 	if (zp.tagSelectorShown()) {
    216 		var tagSelector = win.document.getElementById('zotero-tag-selector');
    217 		var onRefresh = () => {
    218 			tagSelector.removeEventListener('refresh', onRefresh);
    219 			deferred.resolve();
    220 		};
    221 		tagSelector.addEventListener('refresh', onRefresh);
    222 	}
    223 	else {
    224 		deferred.resolve();
    225 	}
    226 	return deferred.promise;
    227 };
    228 
    229 /**
    230  * Waits for a single item event. Returns a promise for the item ID(s).
    231  */
    232 function waitForItemEvent(event) {
    233 	return waitForNotifierEvent(event, 'item').then(x => x.ids);
    234 }
    235 
    236 /**
    237  * Wait for a single notifier event and return a promise for the data
    238  */
    239 function waitForNotifierEvent(event, type) {
    240 	if (!event) throw new Error("event not provided");
    241 	
    242 	var deferred = Zotero.Promise.defer();
    243 	var notifierID = Zotero.Notifier.registerObserver({notify:function(ev, type, ids, extraData) {
    244 		if(ev == event) {
    245 			Zotero.Notifier.unregisterObserver(notifierID);
    246 			deferred.resolve({
    247 				ids: ids,
    248 				extraData: extraData
    249 			});
    250 		}
    251 	}}, [type]);
    252 	return deferred.promise;
    253 }
    254 
    255 /**
    256  * Looks for windows with a specific URL.
    257  */
    258 function getWindows(uri) {
    259 	var enumerator = Services.wm.getEnumerator(null);
    260 	var wins = [];
    261 	while(enumerator.hasMoreElements()) {
    262 		var win = enumerator.getNext();
    263 		if(win.location == uri) {
    264 			wins.push(win);
    265 		}
    266 	}
    267 	return wins;
    268 }
    269 
    270 /**
    271  * Resolve a promise when a specified callback returns true. interval
    272  * specifies the interval between checks. timeout specifies when we
    273  * should assume failure.
    274  */
    275 function waitForCallback(cb, interval, timeout) {
    276 	var deferred = Zotero.Promise.defer();
    277 	if(interval === undefined) interval = 100;
    278 	if(timeout === undefined) timeout = 10000;
    279 	var start = Date.now();
    280 	var id = setInterval(function() {
    281 		var success = cb();
    282 		if(success) {
    283 			clearInterval(id);
    284 			deferred.resolve(success);
    285 		} else if(Date.now() - start > timeout*1000) {
    286 			clearInterval(id);
    287 			deferred.reject(new Error("Promise timed out"));
    288 		}
    289 	}, interval);
    290 	return deferred.promise;
    291 }
    292 
    293 
    294 function clickOnItemsRow(itemsView, row, button = 0) {
    295 	var x = {};
    296 	var y = {};
    297 	var width = {};
    298 	var height = {};
    299 	itemsView._treebox.getCoordsForCellItem(
    300 		row,
    301 		itemsView._treebox.columns.getNamedColumn('zotero-items-column-title'),
    302 		'text',
    303 		x, y, width, height
    304 	);
    305 	
    306 	// Select row to trigger multi-select
    307 	var tree = itemsView._treebox.treeBody;
    308 	var rect = tree.getBoundingClientRect();
    309 	var x = rect.left + x.value;
    310 	var y = rect.top + y.value;
    311 	tree.dispatchEvent(new MouseEvent("mousedown", {
    312 		clientX: x,
    313 		clientY: y,
    314 		button,
    315 		detail: 1
    316 	}));
    317 }
    318 
    319 
    320 /**
    321  * Synchronous inflate
    322  */
    323 function gunzip(gzdata) {
    324 	return pako.inflate(gzdata, { to: 'string' });
    325 }
    326 
    327 
    328 /**
    329  * Get a default group used by all tests that want one, creating one if necessary
    330  */
    331 var _defaultGroup;
    332 var getGroup = Zotero.Promise.method(function () {
    333 	// Cleared in resetDB()
    334 	if (_defaultGroup) {
    335 		return _defaultGroup;
    336 	}
    337 	return _defaultGroup = createGroup({
    338 		name: "My Group"
    339 	});
    340 });
    341 
    342 
    343 var createGroup = Zotero.Promise.coroutine(function* (props = {}) {
    344 	var group = new Zotero.Group;
    345 	group.id = props.id || Zotero.Utilities.rand(10000, 1000000);
    346 	group.name = props.name || "Test " + Zotero.Utilities.randomString();
    347 	group.description = props.description || "";
    348 	group.editable = props.editable === undefined ? true : props.editable;
    349 	group.filesEditable = props.filesEditable === undefined ? true : props.filesEditable;
    350 	group.version = props.version === undefined ? Zotero.Utilities.rand(1000, 10000) : props.version;
    351 	if (props.libraryVersion) {
    352 		group.libraryVersion = props.libraryVersion;
    353 	}
    354 	group.archived = props.archived === undefined ? false : props.archived;
    355 	yield group.saveTx();
    356 	return group;
    357 });
    358 
    359 var createFeed = Zotero.Promise.coroutine(function* (props = {}) {
    360 	var feed = new Zotero.Feed;
    361 	feed.name = props.name || "Test " + Zotero.Utilities.randomString();
    362 	feed.description = props.description || "";
    363 	feed.url = props.url || 'http://www.' + Zotero.Utilities.randomString() + '.com/feed.rss';
    364 	feed.refreshInterval = props.refreshInterval || 12;
    365 	feed.cleanupReadAfter = props.cleanupReadAfter || 2;
    366 	feed.cleanupUnreadAfter = props.cleanupUnreadAfter || 30;
    367 	yield feed.saveTx(props.saveOptions);
    368 	return feed;
    369 });
    370 
    371 var clearFeeds = Zotero.Promise.coroutine(function* () {
    372 	let feeds = Zotero.Feeds.getAll();
    373 	for (let i=0; i<feeds.length; i++) {
    374 		yield feeds[i].eraseTx();
    375 	}
    376 });
    377 
    378 //
    379 // Data objects
    380 //
    381 /**
    382  * @param {String} objectType - 'collection', 'item', 'search'
    383  * @param {Object} [params]
    384  * @param {Integer} [params.libraryID]
    385  * @param {String} [params.itemType] - Item type
    386  * @param {String} [params.title] - Item title
    387  * @param {Boolean} [params.setTitle] - Assign a random item title
    388  * @param {String} [params.name] - Collection/search name
    389  * @param {Integer} [params.parentID]
    390  * @param {String} [params.parentKey]
    391  * @param {Boolean} [params.synced]
    392  * @param {Integer} [params.version]
    393  * @param {Integer} [params.dateAdded] - Allowed for items
    394  * @param {Integer} [params.dateModified] - Allowed for items
    395  */
    396 function createUnsavedDataObject(objectType, params = {}) {
    397 	if (!objectType) {
    398 		throw new Error("Object type not provided");
    399 	}
    400 	
    401 	var allowedParams = ['libraryID', 'parentID', 'parentKey', 'synced', 'version'];
    402 	
    403 	var itemType;
    404 	if (objectType == 'item' || objectType == 'feedItem') {
    405 		itemType = params.itemType || 'book';
    406 		allowedParams.push('deleted', 'dateAdded', 'dateModified');
    407 	}
    408 	if (objectType == 'item') {
    409 		allowedParams.push('inPublications');
    410 	}
    411 	if (objectType == 'feedItem') {
    412 		params.guid = params.guid || Zotero.randomString();
    413 		allowedParams.push('guid');
    414 	}
    415 	
    416 	var obj = new Zotero[Zotero.Utilities.capitalize(objectType)](itemType);
    417 	if (params.libraryID) {
    418 		obj.libraryID = params.libraryID;
    419 	}
    420 	
    421 	switch (objectType) {
    422 	case 'item':
    423 	case 'feedItem':
    424 		if (params.parentItemID) {
    425 			params.parentID = params.parentItemID;
    426 			delete params.parentItemID;
    427 		}
    428 		if (params.title !== undefined || params.setTitle) {
    429 			obj.setField('title', params.title !== undefined ? params.title : Zotero.Utilities.randomString());
    430 		}
    431 		if (params.collections !== undefined) {
    432 			obj.setCollections(params.collections);
    433 		}
    434 		if (params.tags !== undefined) {
    435 			obj.setTags(params.tags);
    436 		}
    437 		if (params.note !== undefined) {
    438 			obj.setNote(params.note);
    439 		}
    440 		break;
    441 	
    442 	case 'collection':
    443 	case 'search':
    444 		obj.name = params.name !== undefined ? params.name : Zotero.Utilities.randomString();
    445 		break;
    446 	}
    447 	
    448 	if (objectType == 'search') {
    449 		obj.addCondition('title', 'contains', Zotero.Utilities.randomString());
    450 		obj.addCondition('title', 'isNot', Zotero.Utilities.randomString());
    451 	}
    452 	
    453 	Zotero.Utilities.assignProps(obj, params, allowedParams);
    454 	
    455 	return obj;
    456 }
    457 
    458 var createDataObject = Zotero.Promise.coroutine(function* (objectType, params = {}, saveOptions) {
    459 	var obj = createUnsavedDataObject(objectType, params);
    460 	yield obj.saveTx(saveOptions);
    461 	return obj;
    462 });
    463 
    464 function getNameProperty(objectType) {
    465 	return objectType == 'item' ? 'title' : 'name';
    466 }
    467 
    468 var modifyDataObject = function (obj, params = {}, saveOptions) {
    469 	switch (obj.objectType) {
    470 	case 'item':
    471 		obj.setField(
    472 			'title',
    473 			params.title !== undefined ? params.title : Zotero.Utilities.randomString()
    474 		);
    475 		break;
    476 	
    477 	default:
    478 		obj.name = params.name !== undefined ? params.name : Zotero.Utilities.randomString();
    479 	}
    480 	return obj.saveTx(saveOptions);
    481 };
    482 
    483 /**
    484  * Return a promise for the error thrown by a promise, or false if none
    485  */
    486 function getPromiseError(promise) {
    487 	return promise.thenReturn(false).catch(e => e);
    488 }
    489 
    490 /**
    491  * Init paths for PDF tools and data
    492  */
    493 function initPDFToolsPath() {
    494 	let pdfConvertedFileName = 'pdftotext';
    495 	let pdfInfoFileName = 'pdfinfo';
    496 	
    497 	if (Zotero.isWin) {
    498 		pdfConvertedFileName += '-win.exe';
    499 		pdfInfoFileName += '-win.exe';
    500 	}
    501 	else if (Zotero.isMac) {
    502 		pdfConvertedFileName += '-mac';
    503 		pdfInfoFileName += '-mac';
    504 	}
    505 	else {
    506 		let cpu = Zotero.platform.split(' ')[1];
    507 		pdfConvertedFileName += '-linux-' + cpu;
    508 		pdfInfoFileName += '-linux-' + cpu;
    509 	}
    510 	
    511 	let pdfToolsPath = OS.Path.join(Zotero.Profile.dir, 'pdftools');
    512 	let pdfConverterPath = OS.Path.join(pdfToolsPath, pdfConvertedFileName);
    513 	let pdfInfoPath = OS.Path.join(pdfToolsPath, pdfInfoFileName);
    514 	let pdfDataPath = OS.Path.join(pdfToolsPath, 'poppler-data');
    515 	
    516 	Zotero.FullText.setPDFConverterPath(pdfConverterPath);
    517 	Zotero.FullText.setPDFInfoPath(pdfInfoPath);
    518 	Zotero.FullText.setPDFDataPath(pdfDataPath);
    519 }
    520 
    521 /**
    522  * Returns the nsIFile corresponding to the test data directory
    523  * (i.e., test/tests/data)
    524  */
    525 function getTestDataDirectory() {
    526 	var resource = Services.io.getProtocolHandler("resource").
    527 	               QueryInterface(Components.interfaces.nsIResProtocolHandler),
    528 	    resURI = Services.io.newURI("resource://zotero-unit-tests/data", null, null);
    529 	return Services.io.newURI(resource.resolveURI(resURI), null, null).
    530 	       QueryInterface(Components.interfaces.nsIFileURL).file;
    531 }
    532 
    533 function getTestDataUrl(path) {
    534 	path = path.split('/');
    535 	if (path[0].length == 0) {
    536 		path.splice(0, 1);
    537 	}
    538 	return "resource://zotero-unit-tests/data/" + path.join('/');
    539 }
    540 
    541 /**
    542  * Returns an absolute path to an empty temporary directory
    543  */
    544 var getTempDirectory = Zotero.Promise.coroutine(function* getTempDirectory() {
    545 	Components.utils.import("resource://gre/modules/osfile.jsm");
    546 	let path,
    547 		attempts = 3,
    548 		zoteroTmpDirPath = Zotero.getTempDirectory().path;
    549 	while (attempts--) {
    550 		path = OS.Path.join(zoteroTmpDirPath, Zotero.Utilities.randomString());
    551 		try {
    552 			yield OS.File.makeDir(path, { ignoreExisting: false });
    553 			break;
    554 		} catch (e) {
    555 			if (!attempts) throw e; // Throw on last attempt
    556 		}
    557 	}
    558 	
    559 	return path;
    560 });
    561 
    562 var removeDir = Zotero.Promise.coroutine(function* (dir) {
    563 	// OS.File.DirectoryIterator, used by OS.File.removeDir(), isn't reliable on Travis,
    564 	// returning entry.isDir == false for subdirectories, so use nsIFile instead
    565 	//yield OS.File.removeDir(zipDir);
    566 	dir = Zotero.File.pathToFile(dir);
    567 	if (dir.exists()) {
    568 		dir.remove(true);
    569 	}
    570 });
    571 
    572 /**
    573  * Resets the Zotero DB and restarts Zotero. Returns a promise resolved
    574  * when this finishes.
    575  *
    576  * @param {Object} [options] - Initialization options, as passed to Zotero.init(), overriding
    577  *                             any that were set at startup
    578  */
    579 async function resetDB(options = {}) {
    580 	// Hack to avoid CustomizableUI warnings in console from icon.js
    581 	var toolbarIconAdded = Zotero.toolbarIconAdded;
    582 	resetPrefs();
    583 	
    584 	if (options.thisArg) {
    585 		options.thisArg.timeout(60000);
    586 	}
    587 	var db = Zotero.DataDirectory.getDatabase();
    588 	await Zotero.reinit(
    589 		Zotero.Promise.coroutine(function* () {
    590 			yield OS.File.remove(db);
    591 			_defaultGroup = null;
    592 		}),
    593 		false,
    594 		options
    595 	);
    596 	Zotero.toolbarIconAdded = toolbarIconAdded;
    597 	await Zotero.Schema.schemaUpdatePromise;
    598 	initPDFToolsPath();
    599 }
    600 
    601 /**
    602  * Equivalent to JSON.stringify, except that object properties are stringified
    603  * in a sorted order.
    604  */
    605 function stableStringify(obj) {
    606 	return JSON.stringify(obj, function(k, v) {
    607 		if (v && typeof v == "object" && !Array.isArray(v)) {
    608 			let o = {},
    609 			    keys = Object.keys(v).sort();
    610 			for (let i = 0; i < keys.length; i++) {
    611 				o[keys[i]] = v[keys[i]];
    612 			}
    613 			return o;
    614 		}
    615 		return v;
    616 	}, "\t");
    617 }
    618 
    619 /**
    620  * Loads specified sample data from file
    621  */
    622 function loadSampleData(dataName) {
    623 	let data = Zotero.File.getContentsFromURL('resource://zotero-unit-tests/data/' + dataName + '.js');
    624 	return JSON.parse(data);
    625 }
    626 
    627 /**
    628  * Generates sample item data that is stored in data/sampleItemData.js
    629  */
    630 function generateAllTypesAndFieldsData() {
    631 	let data = {};
    632 	let itemTypes = Zotero.ItemTypes.getTypes();
    633 	// For most fields, use the field name as the value, but this doesn't
    634 	// work well for some fields that expect values in certain formats
    635 	let specialValues = {
    636 		date: '1999-12-31',
    637 		filingDate: '2000-01-02',
    638 		accessDate: '1997-06-13T23:59:58Z',
    639 		number: 3,
    640 		numPages: 4,
    641 		issue: 5,
    642 		volume: 6,
    643 		numberOfVolumes: 7,
    644 		edition: 8,
    645 		seriesNumber: 9,
    646 		ISBN: '978-1-234-56789-7',
    647 		ISSN: '1234-5679',
    648 		url: 'http://www.example.com',
    649 		pages: '1-10',
    650 		DOI: '10.1234/example.doi',
    651 		runningTime: '1:22:33',
    652 		language: 'en-US'
    653 	};
    654 	
    655 	// Item types that should not be included in sample data
    656 	let excludeItemTypes = ['note', 'attachment'];
    657 	
    658 	for (let i = 0; i < itemTypes.length; i++) {
    659 		if (excludeItemTypes.indexOf(itemTypes[i].name) != -1) continue;
    660 		
    661 		let itemFields = data[itemTypes[i].name] = {
    662 			itemType: itemTypes[i].name
    663 		};
    664 		
    665 		let fields = Zotero.ItemFields.getItemTypeFields(itemTypes[i].id);
    666 		for (let j = 0; j < fields.length; j++) {
    667 			let field = fields[j];
    668 			field = Zotero.ItemFields.getBaseIDFromTypeAndField(itemTypes[i].id, field) || field;
    669 			
    670 			let name = Zotero.ItemFields.getName(field),
    671 				value;
    672 			
    673 			// Use field name as field value
    674 			if (specialValues[name]) {
    675 				value = specialValues[name];
    676 			} else {
    677 				value = name.charAt(0).toUpperCase() + name.substr(1);
    678 				// Make it look nice (sentence case)
    679 				value = value.replace(/([a-z])([A-Z])/g, '$1 $2')
    680 					.replace(/ [A-Z](?![A-Z])/g, m => m.toLowerCase()); // not all-caps words
    681 			}
    682 			
    683 			itemFields[name] = value;
    684 		}
    685 		
    686 		let creatorTypes = Zotero.CreatorTypes.getTypesForItemType(itemTypes[i].id),
    687 			creators = itemFields.creators = [];
    688 		for (let j = 0; j < creatorTypes.length; j++) {
    689 			let typeName = creatorTypes[j].name;
    690 			creators.push({
    691 				creatorType: typeName,
    692 				firstName: typeName + 'First',
    693 				lastName: typeName + 'Last'
    694 			});
    695 		}
    696 		
    697 		// Also add a single-field mode author, which is valid for all types
    698 		let primaryCreatorType = Zotero.CreatorTypes.getName(
    699 			Zotero.CreatorTypes.getPrimaryIDForType(itemTypes[i].id)
    700 		);
    701 		creators.push({
    702 			creatorType: primaryCreatorType,
    703 			lastName: 'Institutional Author',
    704 			fieldMode: 1
    705 		});
    706 	}
    707 	
    708 	return data;
    709 }
    710 
    711 /**
    712  * Populates the database with sample items
    713  * The field values should be in the form exactly as they would appear in Zotero
    714  */
    715 function populateDBWithSampleData(data) {
    716 	return Zotero.DB.executeTransaction(function* () {
    717 		for (let itemName in data) {
    718 			let item = data[itemName];
    719 			let zItem = new Zotero.Item;
    720 			zItem.fromJSON(item);
    721 			item.id = yield zItem.save();
    722 		}
    723 
    724 		return data;
    725 	});
    726 }
    727 
    728 var generateItemJSONData = Zotero.Promise.coroutine(function* generateItemJSONData(options, currentData) {
    729 	let items = yield populateDBWithSampleData(loadSampleData('allTypesAndFields')),
    730 		jsonData = {};
    731 	
    732 	for (let itemName in items) {
    733 		let zItem = yield Zotero.Items.getAsync(items[itemName].id);
    734 		jsonData[itemName] = zItem.toJSON(options);
    735 
    736 		// Don't replace some fields that _always_ change (e.g. item keys)
    737 		// as long as it follows expected format
    738 		// This makes it easier to generate more meaningful diffs
    739 		if (!currentData || !currentData[itemName]) continue;
    740 		
    741 		for (let field in jsonData[itemName]) {
    742 			let oldVal = currentData[itemName][field];
    743 			if (!oldVal) continue;
    744 			
    745 			let val = jsonData[itemName][field];
    746 			switch (field) {
    747 				case 'dateAdded':
    748 				case 'dateModified':
    749 					if (!isoDateTimeRe.test(oldVal) || !isoDateTimeRe.test(val)) continue;
    750 				break;
    751 				case 'key':
    752 					if (!zoteroObjectKeyRe.test(oldVal) || !zoteroObjectKeyRe.test(val)) continue;
    753 				break;
    754 				default:
    755 					continue;
    756 			}
    757 			
    758 			jsonData[itemName][field] = oldVal;
    759 		}
    760 	}
    761 	
    762 	return jsonData;
    763 });
    764 
    765 var generateCiteProcJSExportData = Zotero.Promise.coroutine(function* generateCiteProcJSExportData(currentData) {
    766 	let items = yield populateDBWithSampleData(loadSampleData('allTypesAndFields')),
    767 		cslExportData = {};
    768 	
    769 	for (let itemName in items) {
    770 		let zItem = yield Zotero.Items.getAsync(items[itemName].id);
    771 		cslExportData[itemName] = Zotero.Cite.System.prototype.retrieveItem(zItem);
    772 		
    773 		if (!currentData || !currentData[itemName]) continue;
    774 		
    775 		// Don't replace id as long as it follows expected format
    776 		if (Number.isInteger(currentData[itemName].id)
    777 			&& Number.isInteger(cslExportData[itemName].id)
    778 		) {
    779 			cslExportData[itemName].id = currentData[itemName].id;
    780 		}
    781 	}
    782 	
    783 	return cslExportData;
    784 });
    785 
    786 var generateTranslatorExportData = Zotero.Promise.coroutine(function* generateTranslatorExportData(legacy, currentData) {
    787 	let items = yield populateDBWithSampleData(loadSampleData('allTypesAndFields')),
    788 		translatorExportData = {};
    789 	
    790 	let itemGetter = new Zotero.Translate.ItemGetter();
    791 	itemGetter.legacy = !!legacy;
    792 	
    793 	for (let itemName in items) {
    794 		let zItem = yield Zotero.Items.getAsync(items[itemName].id);
    795 		itemGetter._itemsLeft = [zItem];
    796 		translatorExportData[itemName] = itemGetter.nextItem();
    797 		
    798 		// Don't replace some fields that _always_ change (e.g. item keys)
    799 		if (!currentData || !currentData[itemName]) continue;
    800 		
    801 		// For simplicity, be more lenient than for item key
    802 		let uriRe = /^http:\/\/zotero\.org\/users\/local\/\w{8}\/items\/\w{8}$/;
    803 		let itemIDRe = /^\d+$/;
    804 		for (let field in translatorExportData[itemName]) {
    805 			let oldVal = currentData[itemName][field];
    806 			if (!oldVal) continue;
    807 			
    808 			let val = translatorExportData[itemName][field];
    809 			switch (field) {
    810 				case 'uri':
    811 					if (!uriRe.test(oldVal) || !uriRe.test(val)) continue;
    812 				break;
    813 				case 'itemID':
    814 					if (!itemIDRe.test(oldVal) || !itemIDRe.test(val)) continue;
    815 				break;
    816 				case 'key':
    817 					if (!zoteroObjectKeyRe.test(oldVal) || !zoteroObjectKeyRe.test(val)) continue;
    818 				break;
    819 				case 'dateAdded':
    820 				case 'dateModified':
    821 					if (legacy) {
    822 						if (!sqlDateTimeRe.test(oldVal) || !sqlDateTimeRe.test(val)) continue;
    823 					} else {
    824 						if (!isoDateTimeRe.test(oldVal) || !isoDateTimeRe.test(val)) continue;
    825 					}
    826 				break;
    827 				default:
    828 					continue;
    829 			}
    830 			
    831 			translatorExportData[itemName][field] = oldVal;
    832 		}
    833 	}
    834 	
    835 	return translatorExportData;
    836 });
    837 
    838 
    839 /**
    840  * Build a dummy translator that can be passed to Zotero.Translate
    841  */
    842 function buildDummyTranslator(translatorType, code, info={}) {
    843 	const TRANSLATOR_TYPES = {"import":1, "export":2, "web":4, "search":8};
    844 	info = Object.assign({
    845 		"translatorID":"dummy-translator",
    846 		"translatorType": Number.isInteger(translatorType) ? translatorType : TRANSLATOR_TYPES[translatorType],
    847 		"label":"Dummy Translator",
    848 		"creator":"Simon Kornblith",
    849 		"target":"",
    850 		"priority":100,
    851 		"browserSupport":"g",
    852 		"inRepository":false,
    853 		"lastUpdated":"0000-00-00 00:00:00",
    854 	}, info);
    855 	let translator = new Zotero.Translator(info);
    856 	translator.code = JSON.stringify(info) + "\n" + code;
    857 	return translator;
    858 }
    859 
    860 
    861 /**
    862  * Imports an attachment from a test file.
    863  * @param {string} filename - The filename to import (in data directory)
    864  * @return {Promise<Zotero.Item>}
    865  */
    866 function importFileAttachment(filename, options = {}) {
    867 	let file = getTestDataDirectory();
    868 	filename.split('/').forEach((part) => file.append(part));
    869 	let importOptions = {
    870 		file,
    871 		parentItemID: options.parentID
    872 	};
    873 	Object.assign(importOptions, options);
    874 	return Zotero.Attachments.importFromFile(importOptions);
    875 }
    876 
    877 
    878 function importTextAttachment() {
    879 	return importFileAttachment('test.txt', { contentType: 'text/plain', charset: 'utf-8' });
    880 }
    881 
    882 
    883 function importHTMLAttachment() {
    884 	return importFileAttachment('test.html', { contentType: 'text/html', charset: 'utf-8' });
    885 }
    886 
    887 
    888 /**
    889  * Sets the fake XHR server to response to a given response
    890  *
    891  * @param {Object} server - Sinon FakeXMLHttpRequest server
    892  * @param {Object|String} response - Dot-separated path to predefined response in responses
    893  *                                   object (e.g., keyInfo.fullAccess) or a JSON object
    894  *                                   that defines the response
    895  * @param {Object} responses - Predefined responses
    896  */
    897 function setHTTPResponse(server, baseURL, response, responses) {
    898 	if (typeof response == 'string') {
    899 		let [topic, key] = response.split('.');
    900 		if (!responses[topic]) {
    901 			throw new Error("Invalid topic");
    902 		}
    903 		if (!responses[topic][key]) {
    904 			throw new Error("Invalid response key");
    905 		}
    906 		response = responses[topic][key];
    907 	}
    908 	
    909 	var responseArray = [response.status !== undefined ? response.status : 200, {}, ""];
    910 	if (response.json) {
    911 		responseArray[1]["Content-Type"] = "application/json";
    912 		responseArray[2] = JSON.stringify(response.json);
    913 	}
    914 	else {
    915 		responseArray[1]["Content-Type"] = "text/plain";
    916 		responseArray[2] = response.text || "";
    917 	}
    918 	
    919 	if (!response.headers) {
    920 		response.headers = {};
    921 	}
    922 	response.headers["Fake-Server-Match"] = 1;
    923 	for (let i in response.headers) {
    924 		responseArray[1][i] = response.headers[i];
    925 	}
    926 	
    927 	server.respondWith(response.method, baseURL + response.url, responseArray);
    928 }