www

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

translateTest.js (67354B)


      1 new function() {
      2 Components.utils.import("resource://gre/modules/osfile.jsm");
      3 
      4 /**
      5  * Create a new translator that saves the specified items
      6  * @param {String} translatorType - "import" or "web"
      7  * @param {Object} items - items as translator JSON
      8  */
      9 function saveItemsThroughTranslator(translatorType, items) {
     10 	let tyname;
     11 	if (translatorType == "web") {
     12 		tyname = "Web";
     13 	} else if (translatorType == "import") {
     14 		tyname = "Import";
     15 	} else {
     16 		throw new Error("invalid translator type "+translatorType);
     17 	}
     18 
     19 	let translate = new Zotero.Translate[tyname]();
     20 	let browser;
     21 	if (translatorType == "web") {
     22 		browser = Zotero.Browser.createHiddenBrowser();
     23 		translate.setDocument(browser.contentDocument);
     24 	} else if (translatorType == "import") {
     25 		translate.setString("");
     26 	}
     27 	translate.setTranslator(buildDummyTranslator(
     28 		translatorType,
     29 		"function detectWeb() {}\n"+
     30 		"function do"+tyname+"() {\n"+
     31 		"	var json = JSON.parse('"+JSON.stringify(items).replace(/['\\]/g, "\\$&")+"');\n"+
     32 		"	for (var i=0; i<json.length; i++) {"+
     33 		"		var item = new Zotero.Item;\n"+
     34 		"		for (var field in json[i]) { item[field] = json[i][field]; }\n"+
     35 		"		item.complete();\n"+
     36 		"	}\n"+
     37 		"}"));
     38 	return translate.translate().then(function(items) {
     39 		if (browser) Zotero.Browser.deleteHiddenBrowser(browser);
     40 		return items;
     41 	});
     42 }
     43 
     44 /**
     45  * Convert an array of items to an object in which they are indexed by
     46  * their display titles
     47  */
     48 function itemsArrayToObject(items) {
     49 	var obj = {};
     50 	for (let item of items) {
     51 		obj[item.getDisplayTitle()] = item;
     52 	}
     53 	return obj;
     54 }
     55 
     56 const TEST_TAGS = [
     57 	"manual tag as string",
     58 	{"tag":"manual tag as object"},
     59 	{"tag":"manual tag as object with type", "type":0},
     60 	{"tag":"automatic tag as object", "type":1},
     61 	{"name":"tag in name property"}
     62 ];
     63 
     64 /**
     65  * Check that tags match expected values, if TEST_TAGS is passed as test array
     66  */
     67 function checkTestTags(newItem, web) {
     68 	assert.equal(newItem.getTagType("manual tag as string"), web ? 1 : 0);
     69 	assert.equal(newItem.getTagType("manual tag as object"), web ? 1 : 0);
     70 	assert.equal(newItem.getTagType("manual tag as object with type"), web ? 1 : 0);
     71 	assert.equal(newItem.getTagType("automatic tag as object"), 1);
     72 	assert.equal(newItem.getTagType("tag in name property"), web ? 1 : 0);
     73 }
     74 
     75 /**
     76  * Get included test snapshot file
     77  * @returns {nsIFile}
     78  */
     79 function getTestSnapshot() {
     80 	let snapshot = getTestDataDirectory();
     81 	snapshot.append("snapshot");
     82 	snapshot.append("index.html");
     83 	return snapshot;
     84 }
     85 
     86 /**
     87  * Get included test snapshot file
     88  * @returns {nsIFile}
     89  */
     90 function getTestPDF() {
     91 	let testPDF = getTestDataDirectory();
     92 	testPDF.append("empty.pdf");
     93 	return testPDF;
     94 }
     95 
     96 /**
     97  * Set up endpoints for testing attachment saving
     98  * This must happen immediately before the test, since Zotero might get
     99  * restarted by resetDB(), which would erase our registered endpoints.
    100  */
    101 function setupAttachmentEndpoints() {
    102 	var SnapshotTest = function() {};
    103 	Zotero.Server.Endpoints["/test/translate/test.html"] = SnapshotTest;
    104 	SnapshotTest.prototype = {
    105 		"supportedMethods":["GET"],
    106 		"init":function(data, sendResponseCallback) {
    107 			Zotero.File.getBinaryContentsAsync(getTestSnapshot()).then(function (data) {
    108 				sendResponseCallback(200, "text/html", data);
    109 			});
    110 		}
    111 	}
    112 	var PDFTest = function() {};
    113 	Zotero.Server.Endpoints["/test/translate/test.pdf"] = PDFTest;
    114 	PDFTest.prototype = {
    115 		"supportedMethods":["GET"],
    116 		"init":function(data, sendResponseCallback) {
    117 			Zotero.File.getBinaryContentsAsync(getTestPDF()).then(function (data) {
    118 				sendResponseCallback(200, "application/pdf", data);
    119 			});
    120 		}
    121 	}
    122 	var NonExistentTest = function() {};
    123 	Zotero.Server.Endpoints["/test/translate/does_not_exist.html"] = NonExistentTest;
    124 	NonExistentTest.prototype = {
    125 		"supportedMethods":["GET"],
    126 		"init":function(data, sendResponseCallback) {
    127 			sendResponseCallback(404, "text/html", "File does not exist");
    128 		}
    129 	}
    130 }
    131 
    132 describe("Zotero.Translate", function() {
    133 	let win;
    134 	before(function* () {
    135 		// TEMP: Fix for slow translator initialization on Linux/Travis
    136 		this.timeout(20000);
    137 		yield Zotero.Translators.init();
    138 		
    139 		setupAttachmentEndpoints();
    140 		win = yield loadBrowserWindow();
    141 	});
    142 	after(function () {
    143 		win.close();
    144 	});
    145 
    146 	describe("Zotero.Item", function() {
    147 		it('should save ordinary fields and creators', function* () {
    148 			this.timeout(10000);
    149 			let data = loadSampleData('allTypesAndFields');
    150 			let trueItems = loadSampleData('itemJSON');
    151 			let saveItems = [];
    152 			for (let itemType in data) {
    153 				saveItems.push(data[itemType]);
    154 				let trueItem = trueItems[itemType];
    155 				delete trueItem.dateAdded;
    156 				delete trueItem.dateModified;
    157 				delete trueItem.key;
    158 			}
    159 
    160 			let newItems = yield saveItemsThroughTranslator("import", saveItems);
    161 			let savedItems = {};
    162 			for (let i=0; i<newItems.length; i++) {
    163 				let savedItem = newItems[i].toJSON();
    164 				savedItems[Zotero.ItemTypes.getName(newItems[i].itemTypeID)] = savedItem;
    165 				delete savedItem.dateAdded;
    166 				delete savedItem.dateModified;
    167 				delete savedItem.key;
    168 			}
    169 			assert.deepEqual(savedItems, trueItems, "saved items match inputs");
    170 		});
    171 
    172 		it('should accept deprecated SQL accessDates', function* () {
    173 			let myItem = {
    174 				"itemType":"webpage",
    175 				"title":"Test Item",
    176 				"accessDate":"2015-01-02 03:04:05"
    177 			}
    178 			let newItems = yield saveItemsThroughTranslator("import", [myItem]);
    179 			assert.equal(newItems[0].getField("accessDate"), "2015-01-02 03:04:05");
    180 		});
    181 
    182 		it('should save tags', function* () {
    183 			let myItem = {
    184 				"itemType":"book",
    185 				"title":"Test Item",
    186 				"tags":TEST_TAGS
    187 			};
    188 			checkTestTags((yield saveItemsThroughTranslator("import", [myItem]))[0]);
    189 		});
    190 
    191 		it('should save notes', function* () {
    192 			let myItems = [
    193 				{
    194 					"itemType":"book",
    195 					"title":"Test Item",
    196 					"notes":[
    197 						"1 note as string",
    198 							{
    199 								"note":"2 note as object",
    200 								"tags":TEST_TAGS
    201 							}
    202 					]
    203 				},
    204 				{
    205 					"itemType":"note",
    206 					"note":"standalone note",
    207 					"tags":TEST_TAGS
    208 				}
    209 			];
    210 
    211 			let newItems = itemsArrayToObject(yield saveItemsThroughTranslator("import", myItems));
    212 			let noteIDs = newItems["Test Item"].getNotes();
    213 			let note1 = yield Zotero.Items.getAsync(noteIDs[0]);
    214 			assert.equal(Zotero.ItemTypes.getName(note1.itemTypeID), "note");
    215 			assert.equal(note1.getNote(), "1 note as string");
    216 			let note2 = yield Zotero.Items.getAsync(noteIDs[1]);
    217 			assert.equal(Zotero.ItemTypes.getName(note2.itemTypeID), "note");
    218 			assert.equal(note2.getNote(), "2 note as object");
    219 			checkTestTags(note2);
    220 			let note3 = newItems["standalone note"];
    221 			assert.equal(note3.getNote(), "standalone note");
    222 			checkTestTags(note3);
    223 		});
    224 		
    225 		it('should save relations', async function () {
    226 			var item = await createDataObject('item');
    227 			var itemURI = Zotero.URI.getItemURI(item);
    228 			let myItem = {
    229 				itemType: "book",
    230 				title: "Test Item",
    231 				relations: {
    232 					"dc:relation": [itemURI]
    233 				}
    234 			};
    235 			let newItems = await saveItemsThroughTranslator("import", [myItem]);
    236 			var relations = newItems[0].getRelations();
    237 			assert.lengthOf(Object.keys(relations), 1);
    238 			assert.lengthOf(relations["dc:relation"], 1);
    239 			assert.equal(relations["dc:relation"][0], itemURI);
    240 		});
    241 		
    242 		it('should save collections', function* () {
    243 			let translate = new Zotero.Translate.Import();
    244 			translate.setString("");
    245 			translate.setTranslator(buildDummyTranslator(4,
    246 				'function detectWeb() {}\n'+
    247 				'function doImport() {\n'+
    248 				'	var item1 = new Zotero.Item("book");\n'+
    249 				'   item1.title = "Not in Collection";\n'+
    250 				'   item1.complete();\n'+
    251 				'	var item2 = new Zotero.Item("book");\n'+
    252 				'   item2.id = 1;\n'+
    253 				'   item2.title = "In Parent Collection";\n'+
    254 				'   item2.complete();\n'+
    255 				'	var item3 = new Zotero.Item("book");\n'+
    256 				'   item3.id = 2;\n'+
    257 				'   item3.title = "In Child Collection";\n'+
    258 				'   item3.complete();\n'+
    259 				'	var collection = new Zotero.Collection();\n'+
    260 				'	collection.name = "Parent Collection";\n'+
    261 				'	collection.children = [{"id":1}, {"type":"collection", "name":"Child Collection", "children":[{"id":2}]}];\n'+
    262 				'	collection.complete();\n'+
    263 				'}'));
    264 			let newItems = yield translate.translate();
    265 			assert.equal(newItems.length, 3);
    266 			newItems = itemsArrayToObject(newItems);
    267 			assert.equal(newItems["Not in Collection"].getCollections().length, 0);
    268 
    269 			let parentCollection = newItems["In Parent Collection"].getCollections();
    270 			assert.equal(parentCollection.length, 1);
    271 			parentCollection = (yield Zotero.Collections.getAsync(parentCollection))[0];
    272 			assert.equal(parentCollection.name, "Parent Collection");
    273 			assert.isTrue(parentCollection.hasChildCollections());
    274 
    275 			let childCollection = newItems["In Child Collection"].getCollections();
    276 			assert.equal(childCollection.length, 1);
    277 			childCollection = (yield Zotero.Collections.getAsync(childCollection[0]));
    278 			assert.equal(childCollection.name, "Child Collection");
    279 			let parentChildren = parentCollection.getChildCollections();
    280 			assert.equal(parentChildren.length, 1);
    281 			assert.equal(parentChildren[0], childCollection);
    282 		});
    283 
    284 		it('import translators should save attachments', function* () {
    285 			let emptyPDF = getTestPDF().path;
    286 			let snapshot = getTestSnapshot().path;
    287 			let myItems = [
    288 				{
    289 					"itemType":"attachment",
    290 					"path":emptyPDF,
    291 					"title":"Empty PDF",
    292 					"note":"attachment note",
    293 					"tags":TEST_TAGS
    294 				},
    295 				{
    296 					"itemType":"attachment",
    297 					"url":"http://www.zotero.org/",
    298 					"title":"Link to zotero.org",
    299 					"note":"attachment 2 note",
    300 					"tags":TEST_TAGS
    301 				}
    302 			];
    303 			let childAttachments = myItems.slice();
    304 			childAttachments.push({
    305 				"itemType":"attachment",
    306 				"path":snapshot,
    307 				"url":"http://www.example.com/",
    308 				"title":"Snapshot",
    309 				"note":"attachment 3 note",
    310 				"tags":TEST_TAGS
    311 			});
    312 			myItems.push({
    313 				"itemType":"book",
    314 				"title":"Container Item",
    315 				"attachments":childAttachments
    316 			});
    317 
    318 			let newItems = itemsArrayToObject(yield saveItemsThroughTranslator("import", myItems));
    319 			let containedAttachments = yield Zotero.Items.getAsync(newItems["Container Item"].getAttachments());
    320 			assert.equal(containedAttachments.length, 3);
    321 
    322 			for (let savedAttachments of [[newItems["Empty PDF"], newItems["Link to zotero.org"]],
    323 				                          [containedAttachments[0], containedAttachments[1]]]) {
    324 				assert.equal(savedAttachments[0].getField("title"), "Empty PDF");
    325 				assert.equal(savedAttachments[0].getNote(), "attachment note");
    326 				assert.equal(savedAttachments[0].attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_FILE);
    327 				checkTestTags(savedAttachments[0]);
    328 
    329 				assert.equal(savedAttachments[1].getField("title"), "Link to zotero.org");
    330 				assert.equal(savedAttachments[1].getField("url"), "http://www.zotero.org/");
    331 				assert.equal(savedAttachments[1].getNote(), "attachment 2 note");
    332 				assert.equal(savedAttachments[1].attachmentLinkMode, Zotero.Attachments.LINK_MODE_LINKED_URL);
    333 				checkTestTags(savedAttachments[1]);
    334 			}
    335 
    336 			assert.equal(containedAttachments[2].getField("title"), "Snapshot");
    337 			assert.equal(containedAttachments[2].getField("url"), "http://www.example.com/");
    338 			assert.equal(containedAttachments[2].getNote(), "attachment 3 note");
    339 			assert.equal(containedAttachments[2].attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL);
    340 			checkTestTags(containedAttachments[2]);
    341 		});
    342 
    343 		it('import translators should save missing snapshots as links', function* () {
    344 			let missingFile = getTestDataDirectory();
    345 			missingFile.append("missing");
    346 			assert.isFalse(missingFile.exists());
    347 			missingFile = missingFile.path;
    348 			let myItems = [
    349 				{
    350 					"itemType":"book",
    351 					"title":"Container Item",
    352 					"attachments":[
    353 						{
    354 							"itemType":"attachment",
    355 							"path":missingFile,
    356 							"url":"http://www.example.com/",
    357 							"title":"Snapshot with missing file",
    358 							"note":"attachment note",
    359 							"tags":TEST_TAGS
    360 						}
    361 					]
    362 				}
    363 			];
    364 
    365 			let newItems = yield saveItemsThroughTranslator("import", myItems);
    366 			assert.equal(newItems.length, 1);
    367 			assert.equal(newItems[0].getField("title"), "Container Item");
    368 			let containedAttachments = yield Zotero.Items.getAsync(newItems[0].getAttachments());
    369 			assert.equal(containedAttachments.length, 1);
    370 
    371 			assert.equal(containedAttachments[0].getField("title"), "Snapshot with missing file");
    372 			assert.equal(containedAttachments[0].getField("url"), "http://www.example.com/");
    373 			assert.equal(containedAttachments[0].getNote(), "attachment note");
    374 			assert.equal(containedAttachments[0].attachmentLinkMode, Zotero.Attachments.LINK_MODE_LINKED_URL);
    375 			checkTestTags(containedAttachments[0]);
    376 		});
    377 
    378 		it('import translators should ignore missing file attachments', function* () {
    379 			let missingFile = getTestDataDirectory();
    380 			missingFile.append("missing");
    381 			assert.isFalse(missingFile.exists());
    382 			missingFile = missingFile.path;
    383 			let myItems = [
    384 				{
    385 					"itemType":"attachment",
    386 					"path":missingFile,
    387 					"title":"Missing file"
    388 				},
    389 				{
    390 					"itemType":"book",
    391 					"title":"Container Item",
    392 					"attachments":[
    393 						{
    394 							"itemType":"attachment",
    395 							"path":missingFile,
    396 							"title":"Missing file"
    397 						}
    398 					]
    399 				}
    400 			];
    401 
    402 			let newItems = yield saveItemsThroughTranslator("import", myItems);
    403 			assert.equal(newItems.length, 1);
    404 			assert.equal(newItems[0].getField("title"), "Container Item");
    405 			assert.equal(newItems[0].getAttachments().length, 0);
    406 		});
    407 
    408 		it('web translators should set accessDate to current date', function* () {
    409 			let myItem = {
    410 				"itemType":"webpage",
    411 				"title":"Test Item",
    412 				"url":"http://www.zotero.org/"
    413 			};
    414 			let newItems = yield saveItemsThroughTranslator("web", [myItem]);
    415 			let currentDate = new Date();
    416 			let delta = currentDate - Zotero.Date.sqlToDate(newItems[0].getField("accessDate"), true);
    417 			assert.isAbove(delta, -500);
    418 			assert.isBelow(delta, 5000);
    419 		});
    420 		
    421 		it('web translators should set accessDate to current date for CURRENT_TIMESTAMP', function* () {
    422 			let myItem = {
    423 				itemType: "webpage",
    424 				title: "Test Item",
    425 				url: "https://www.zotero.org/",
    426 				accessDate: 'CURRENT_TIMESTAMP'
    427 			};
    428 			let newItems = yield saveItemsThroughTranslator("web", [myItem]);
    429 			let currentDate = new Date();
    430 			let delta = currentDate - Zotero.Date.sqlToDate(newItems[0].getField("accessDate"), true);
    431 			assert.isAbove(delta, -500);
    432 			assert.isBelow(delta, 5000);
    433 		});
    434 
    435 		it('web translators should save attachments', function* () {
    436 			let myItems = [
    437 				{
    438 					"itemType":"book",
    439 					"title":"Container Item",
    440 					"attachments":[
    441 						{
    442 							"url":"http://www.zotero.org/",
    443 							"title":"Link to zotero.org",
    444 							"note":"attachment note",
    445 							"tags":TEST_TAGS,
    446 							"snapshot":false
    447 						},
    448 						{
    449 							"url":"http://127.0.0.1:23119/test/translate/test.html",
    450 							"title":"Test Snapshot",
    451 							"note":"attachment 2 note",
    452 							"tags":TEST_TAGS
    453 						},
    454 						{
    455 							"url":"http://127.0.0.1:23119/test/translate/test.pdf",
    456 							"title":"Test PDF",
    457 							"note":"attachment 3 note",
    458 							"tags":TEST_TAGS
    459 						}
    460 					]
    461 				}
    462 			];
    463 
    464 			let newItems = yield saveItemsThroughTranslator("web", myItems);
    465 			assert.equal(newItems.length, 1);
    466 			let containedAttachments = itemsArrayToObject(yield Zotero.Items.getAsync(newItems[0].getAttachments()));
    467 
    468 			let link = containedAttachments["Link to zotero.org"];
    469 			assert.equal(link.getField("url"), "http://www.zotero.org/");
    470 			assert.equal(link.getNote(), "attachment note");
    471 			assert.equal(link.attachmentLinkMode, Zotero.Attachments.LINK_MODE_LINKED_URL);
    472 			checkTestTags(link, true);
    473 
    474 			let snapshot = containedAttachments["Test Snapshot"];
    475 			assert.equal(snapshot.getField("url"), "http://127.0.0.1:23119/test/translate/test.html");
    476 			assert.equal(snapshot.getNote(), "attachment 2 note");
    477 			assert.equal(snapshot.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL);
    478 			assert.equal(snapshot.attachmentContentType, "text/html");
    479 			checkTestTags(snapshot, true);
    480 
    481 			let pdf = containedAttachments["Test PDF"];
    482 			assert.equal(pdf.getField("url"), "http://127.0.0.1:23119/test/translate/test.pdf");
    483 			assert.equal(pdf.getNote(), "attachment 3 note");
    484 			assert.equal(pdf.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL);
    485 			assert.equal(pdf.attachmentContentType, "application/pdf");
    486 			checkTestTags(pdf, true);
    487 		});
    488 
    489 		it('web translators should save attachment from browser document', function* () {
    490 			let deferred = Zotero.Promise.defer();
    491 			let browser = Zotero.HTTP.loadDocuments(
    492 				"http://127.0.0.1:23119/test/translate/test.html",
    493 				doc => deferred.resolve(doc),
    494 				undefined,
    495 				undefined,
    496 				true
    497 			);
    498 			let doc = yield deferred.promise;
    499 
    500 			let translate = new Zotero.Translate.Web();
    501 			translate.setDocument(doc);
    502 			translate.setTranslator(buildDummyTranslator(4,
    503 				'function detectWeb() {}\n'+
    504 				'function doWeb(doc) {\n'+
    505 				'	var item = new Zotero.Item("book");\n'+
    506 				'	item.title = "Container Item";\n'+
    507 				'	item.attachments = [{\n'+
    508 				'		"document":doc,\n'+
    509 				'		"title":"Snapshot from Document",\n'+
    510 				'		"note":"attachment note",\n'+
    511 				'		"tags":'+JSON.stringify(TEST_TAGS)+'\n'+
    512 				'	}];\n'+
    513 				'	item.complete();\n'+
    514 				'}'));
    515 			let newItems = yield translate.translate();
    516 			assert.equal(newItems.length, 1);
    517 			let containedAttachments = Zotero.Items.get(newItems[0].getAttachments());
    518 			assert.equal(containedAttachments.length, 1);
    519 
    520 			let snapshot = containedAttachments[0];
    521 			assert.equal(snapshot.getField("url"), "http://127.0.0.1:23119/test/translate/test.html");
    522 			assert.equal(snapshot.getNote(), "attachment note");
    523 			assert.equal(snapshot.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL);
    524 			assert.equal(snapshot.attachmentContentType, "text/html");
    525 			checkTestTags(snapshot, true);
    526 
    527 			Zotero.Browser.deleteHiddenBrowser(browser);
    528 		});
    529 		
    530 		it('web translators should save attachment from non-browser document', function* () {
    531 			return Zotero.HTTP.processDocuments(
    532 				"http://127.0.0.1:23119/test/translate/test.html",
    533 				async function (doc) {
    534 					let translate = new Zotero.Translate.Web();
    535 					translate.setDocument(doc);
    536 					translate.setTranslator(buildDummyTranslator(4,
    537 						'function detectWeb() {}\n'+
    538 						'function doWeb(doc) {\n'+
    539 						'	var item = new Zotero.Item("book");\n'+
    540 						'	item.title = "Container Item";\n'+
    541 						'	item.attachments = [{\n'+
    542 						'		"document":doc,\n'+
    543 						'		"title":"Snapshot from Document",\n'+
    544 						'		"note":"attachment note",\n'+
    545 						'		"tags":'+JSON.stringify(TEST_TAGS)+'\n'+
    546 						'	}];\n'+
    547 						'	item.complete();\n'+
    548 						'}'));
    549 					let newItems = await translate.translate();
    550 					assert.equal(newItems.length, 1);
    551 					let containedAttachments = Zotero.Items.get(newItems[0].getAttachments());
    552 					assert.equal(containedAttachments.length, 1);
    553 		
    554 					let snapshot = containedAttachments[0];
    555 					assert.equal(snapshot.getField("url"), "http://127.0.0.1:23119/test/translate/test.html");
    556 					assert.equal(snapshot.getNote(), "attachment note");
    557 					assert.equal(snapshot.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL);
    558 					assert.equal(snapshot.attachmentContentType, "text/html");
    559 					checkTestTags(snapshot, true);
    560 				}
    561 			);
    562 		});
    563 
    564 		it('web translators should ignore attachments that return error codes', function* () {
    565 			this.timeout(60000);
    566 			let myItems = [
    567 				{
    568 					"itemType":"book",
    569 					"title":"Container Item",
    570 					"attachments":[
    571 						{
    572 							"url":"http://127.0.0.1:23119/test/translate/does_not_exist.html",
    573 							"title":"Non-Existent HTML"
    574 						},
    575 						{
    576 							"url":"http://127.0.0.1:23119/test/translate/does_not_exist.pdf",
    577 							"title":"Non-Existent PDF"
    578 						}
    579 					]
    580 				}
    581 			];
    582 
    583 			let newItems = yield saveItemsThroughTranslator("web", myItems);
    584 			assert.equal(newItems.length, 1);
    585 			let containedAttachments = yield Zotero.Items.getAsync(newItems[0].getAttachments());
    586 			assert.equal(containedAttachments.length, 0);
    587 		});
    588 
    589 		it('web translators should save PDFs only if the content type matches', function* () {
    590 			this.timeout(60000);
    591 			let myItems = [
    592 				{
    593 					"itemType":"book",
    594 					"title":"Container Item",
    595 					"attachments":[
    596 						{
    597 							"url":"http://127.0.0.1:23119/test/translate/test.html",
    598 							"mimeType":"application/pdf",
    599 							"title":"Test PDF with wrong mime type"
    600 						},
    601 						{
    602 							"url":"http://127.0.0.1:23119/test/translate/test.pdf",
    603 							"mimeType":"application/pdf",
    604 							"title":"Test PDF",
    605 							"note":"attachment note",
    606 							"tags":TEST_TAGS
    607 						}
    608 					]
    609 				}
    610 			];
    611 
    612 			let newItems = yield saveItemsThroughTranslator("web", myItems);
    613 			assert.equal(newItems.length, 1);
    614 			let containedAttachments = yield Zotero.Items.getAsync(newItems[0].getAttachments());
    615 			assert.equal(containedAttachments.length, 1);
    616 
    617 			let pdf = containedAttachments[0];
    618 			assert.equal(pdf.getField("title"), "Test PDF");
    619 			assert.equal(pdf.getField("url"), "http://127.0.0.1:23119/test/translate/test.pdf");
    620 			assert.equal(pdf.getNote(), "attachment note");
    621 			assert.equal(pdf.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL);
    622 			checkTestTags(pdf, true);
    623 		});
    624 		
    625 		it('should not convert tags to canonical form in child translators', function* () {
    626 			var childTranslator = buildDummyTranslator(1, 
    627 				`function detectWeb() {}
    628 				function doImport() {
    629 					var item = new Zotero.Item;
    630 					item.itemType = "book";
    631 					item.title = "The Definitive Guide of Owls";
    632 					item.tags = ['owl', 'tag'];
    633 					item.complete();
    634 				}`, {translatorID: 'child-dummy-translator'}
    635 			);
    636 			sinon.stub(Zotero.Translators, 'get').withArgs('child-dummy-translator').returns(childTranslator);
    637 			
    638 			var parentTranslator = buildDummyTranslator(1,
    639 				`function detectWeb() {}
    640 				function doImport() {
    641 					var translator = Zotero.loadTranslator("import");
    642 					translator.setTranslator('child-dummy-translator');
    643 					translator.setHandler('itemDone', Zotero.childItemDone);
    644 					translator.translate();
    645 				}`
    646 			);
    647 			
    648 			function childItemDone(obj, item) {
    649 				// Non-canonical tags after child translator is done
    650 				assert.deepEqual(['owl', 'tag'], item.tags);
    651 				item.complete();
    652 			}
    653 			
    654 			var translate = new Zotero.Translate.Import();
    655 			translate.setTranslator(parentTranslator);
    656 			translate.setString("");
    657 			yield translate._loadTranslator(parentTranslator);
    658 			translate._sandboxManager.importObject({childItemDone});
    659 			
    660 			var items = yield translate.translate();
    661 			
    662 			// Canonicalized tags after parent translator
    663 			assert.deepEqual([{tag: 'owl'}, {tag: 'tag'}], items[0].getTags());
    664 			
    665 			Zotero.Translators.get.restore();
    666 		});
    667 	});
    668 	
    669 	
    670 	describe("#processDocuments()", function () {
    671 		var url = "http://127.0.0.1:23119/test/translate/test.html";
    672 		var doc;
    673 		
    674 		beforeEach(function* () {
    675 			// This is the main processDocuments, not the translation sandbox one being tested
    676 			doc = (yield Zotero.HTTP.processDocuments(url, doc => doc))[0];
    677 		});
    678 		
    679 		it("should provide document object", async function () {
    680 			var translate = new Zotero.Translate.Web();
    681 			translate.setDocument(doc);
    682 			translate.setTranslator(
    683 				buildDummyTranslator(
    684 					4,
    685 					`function detectWeb() {}
    686 					function doWeb(doc) {
    687 						ZU.processDocuments(
    688 							doc.location.href + '?t',
    689 							function (doc) {
    690 								var item = new Zotero.Item("book");
    691 								item.title = "Container Item";
    692 								// document.location
    693 								item.url = doc.location.href;
    694 								// document.evaluate()
    695 								item.extra = doc
    696 									.evaluate('//p', doc, null, XPathResult.ANY_TYPE, null)
    697 									.iterateNext()
    698 									.textContent;
    699 								item.attachments = [{
    700 									document: doc,
    701 									title: "Snapshot from Document",
    702 									note: "attachment note",
    703 									tags: ${JSON.stringify(TEST_TAGS)}
    704 								}];
    705 								item.complete();
    706 							}
    707 						);
    708 					}`
    709 				)
    710 			);
    711 			var newItems = await translate.translate();
    712 			assert.equal(newItems.length, 1);
    713 			
    714 			var item = newItems[0];
    715 			assert.equal(item.getField('url'), url + '?t');
    716 			assert.include(item.getField('extra'), 'your research sources');
    717 			
    718 			var containedAttachments = Zotero.Items.get(newItems[0].getAttachments());
    719 			assert.equal(containedAttachments.length, 1);
    720 			
    721 			var snapshot = containedAttachments[0];
    722 			assert.equal(snapshot.getField("url"), url + '?t');
    723 			assert.equal(snapshot.getNote(), "attachment note");
    724 			assert.equal(snapshot.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL);
    725 			assert.equal(snapshot.attachmentContentType, "text/html");
    726 			checkTestTags(snapshot, true);
    727 		});
    728 		
    729 		it("should use loaded document instead of reloading if possible", function* () {
    730 			var translate = new Zotero.Translate.Web();
    731 			translate.setDocument(doc);
    732 			translate.setTranslator(
    733 				buildDummyTranslator(
    734 					4,
    735 					`function detectWeb() {}
    736 					function doWeb(doc) {
    737 						ZU.processDocuments(
    738 							doc.location.href,
    739 							function (doc) {
    740 								var item = new Zotero.Item("book");
    741 								item.title = "Container Item";
    742 								// document.location
    743 								item.url = doc.location.href;
    744 								// document.evaluate()
    745 								item.extra = doc
    746 									.evaluate('//p', doc, null, XPathResult.ANY_TYPE, null)
    747 									.iterateNext()
    748 									.textContent;
    749 								item.attachments = [{
    750 									document: doc,
    751 									title: "Snapshot from Document",
    752 									note: "attachment note",
    753 									tags: ${JSON.stringify(TEST_TAGS)}
    754 								}];
    755 								item.complete();
    756 							}
    757 						);
    758 					}`
    759 				)
    760 			);
    761 			var newItems = yield translate.translate();
    762 			assert.equal(newItems.length, 1);
    763 			
    764 			var item = newItems[0];
    765 			assert.equal(item.getField('url'), url);
    766 			assert.include(item.getField('extra'), 'your research sources');
    767 			
    768 			var containedAttachments = Zotero.Items.get(newItems[0].getAttachments());
    769 			assert.equal(containedAttachments.length, 1);
    770 			
    771 			var snapshot = containedAttachments[0];
    772 			assert.equal(snapshot.getField("url"), url);
    773 			assert.equal(snapshot.getNote(), "attachment note");
    774 			assert.equal(snapshot.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL);
    775 			assert.equal(snapshot.attachmentContentType, "text/html");
    776 			checkTestTags(snapshot, true);
    777 		});
    778 	});
    779 	
    780 	
    781 	describe("Translators", function () {
    782 		it("should round-trip child attachment via BibTeX", function* () {
    783 			var item = yield createDataObject('item');
    784 			yield importFileAttachment('test.png', { parentItemID: item.id });
    785 			
    786 			var translation = new Zotero.Translate.Export();
    787 			var tmpDir = yield getTempDirectory();
    788 			var exportDir = OS.Path.join(tmpDir, 'export');
    789 			translation.setLocation(Zotero.File.pathToFile(exportDir));
    790 			translation.setItems([item]);
    791 			translation.setTranslator("9cb70025-a888-4a29-a210-93ec52da40d4");
    792 			translation.setDisplayOptions({
    793 				exportFileData: true
    794 			});
    795 			yield translation.translate();
    796 			
    797 			var exportFile = OS.Path.join(exportDir, 'export.bib');
    798 			assert.isTrue(yield OS.File.exists(exportFile));
    799 			
    800 			var translation = new Zotero.Translate.Import();
    801 			translation.setLocation(Zotero.File.pathToFile(exportFile));
    802 			var translators = yield translation.getTranslators();
    803 			translation.setTranslator(translators[0]);
    804 			var importCollection = yield createDataObject('collection');
    805 			var items = yield translation.translate({
    806 				libraryID: Zotero.Libraries.userLibraryID,
    807 				collections: [importCollection.id]
    808 			});
    809 			
    810 			assert.lengthOf(items, 1);
    811 			var attachments = items[0].getAttachments();
    812 			assert.lengthOf(attachments, 1);
    813 			var attachment = Zotero.Items.get(attachments[0]);
    814 			assert.isTrue(yield attachment.fileExists());
    815 		});
    816 	});
    817 	
    818 	
    819 	describe("ItemSaver", function () {
    820 		describe("#saveCollections()", function () {
    821 			it("should add top-level collections to specified collection", function* () {
    822 				var collection = yield createDataObject('collection');
    823 				var collections = [
    824 					{
    825 						name: "Collection",
    826 						type: "collection",
    827 						children: []
    828 					}
    829 				];
    830 				var items = [
    831 					{
    832 						itemType: "book",
    833 						title: "Test"
    834 					}
    835 				];
    836 				
    837 				var translation = new Zotero.Translate.Import();
    838 				translation.setString("");
    839 				translation.setTranslator(buildDummyTranslator(
    840 					"import",
    841 					"function detectImport() {}\n"
    842 					+ "function doImport() {\n"
    843 					+ "	var json = JSON.parse('" + JSON.stringify(collections).replace(/['\\]/g, "\\$&") + "');\n"
    844 					+ "	for (let o of json) {"
    845 					+ "		var collection = new Zotero.Collection;\n"
    846 					+ "		for (let field in o) { collection[field] = o[field]; }\n"
    847 					+ "		collection.complete();\n"
    848 					+ "	}\n"
    849 					+ "	json = JSON.parse('" + JSON.stringify(items).replace(/['\\]/g, "\\$&") + "');\n"
    850 					+ "	for (let o of json) {"
    851 					+ "		var item = new Zotero.Item;\n"
    852 					+ "		for (let field in o) { item[field] = o[field]; }\n"
    853 					+ "		item.complete();\n"
    854 					+ "	}\n"
    855 					+ "}"
    856 				));
    857 				yield translation.translate({
    858 					collections: [collection.id]
    859 				});
    860 				assert.lengthOf(translation.newCollections, 1);
    861 				assert.isNumber(translation.newCollections[0].id);
    862 				assert.lengthOf(translation.newItems, 1);
    863 				assert.isNumber(translation.newItems[0].id);
    864 				var childCollections = Array.from(collection.getChildCollections(true));
    865 				assert.sameMembers(childCollections, translation.newCollections.map(c => c.id));
    866 			});
    867 		});
    868 		
    869 		describe("#_saveAttachment()", function () {
    870 			it("should save standalone attachment to collection", function* () {
    871 				var collection = yield createDataObject('collection');
    872 				var items = [
    873 					{
    874 						itemType: "attachment",
    875 						title: "Test",
    876 						mimeType: "text/html",
    877 						url: "http://example.com"
    878 					}
    879 				];
    880 				
    881 				var translation = new Zotero.Translate.Import();
    882 				translation.setString("");
    883 				translation.setTranslator(buildDummyTranslator(
    884 					"import",
    885 					"function detectImport() {}\n"
    886 					+ "function doImport() {\n"
    887 					+ "	var json = JSON.parse('" + JSON.stringify(items).replace(/['\\]/g, "\\$&") + "');\n"
    888 					+ "	for (var i=0; i<json.length; i++) {"
    889 					+ "		var item = new Zotero.Item;\n"
    890 					+ "		for (var field in json[i]) { item[field] = json[i][field]; }\n"
    891 					+ "		item.complete();\n"
    892 					+ "	}\n"
    893 					+ "}"
    894 				));
    895 				yield translation.translate({
    896 					collections: [collection.id]
    897 				});
    898 				assert.lengthOf(translation.newItems, 1);
    899 				assert.isNumber(translation.newItems[0].id);
    900 				assert.ok(collection.hasItem(translation.newItems[0].id));
    901 			});
    902 
    903 		});
    904 		describe('#saveItems', function() {
    905 			it("should deproxify item and attachment urls when proxy provided", function* (){
    906 				var itemID;
    907 				var item = loadSampleData('journalArticle');
    908 				item = item.journalArticle;
    909 				item.url = 'https://www-example-com.proxy.example.com/';
    910 				item.attachments = [{
    911 					url: 'https://www-example-com.proxy.example.com/pdf.pdf',
    912 					mimeType: 'application/pdf',
    913 					title: 'Example PDF'}];
    914 				var itemSaver = new Zotero.Translate.ItemSaver({
    915 					libraryID: Zotero.Libraries.userLibraryID,
    916 					attachmentMode: Zotero.Translate.ItemSaver.ATTACHMENT_MODE_FILE,
    917 					proxy: new Zotero.Proxy({scheme: 'https://%h.proxy.example.com/%p', dotsToHyphens: true})
    918 				});
    919 				var itemDeferred = Zotero.Promise.defer();
    920 				var attachmentDeferred = Zotero.Promise.defer();
    921 				itemSaver.saveItems([item], Zotero.Promise.coroutine(function* (attachment, progressPercentage) {
    922 					// ItemSaver returns immediately without waiting for attachments, so we use the callback
    923 					// to test attachments
    924 					if (progressPercentage != 100) return;
    925 					try {
    926 						yield itemDeferred.promise;
    927 						let item = Zotero.Items.get(itemID);
    928 						attachment = Zotero.Items.get(item.getAttachments()[0]);
    929 						assert.equal(attachment.getField('url'), 'https://www.example.com/pdf.pdf');
    930 						attachmentDeferred.resolve();
    931 					} catch (e) {
    932 						attachmentDeferred.reject(e);
    933 					}
    934 				})).then(function(items) {
    935 					try {
    936 						assert.equal(items[0].getField('url'), 'https://www.example.com/');
    937 						itemID = items[0].id;
    938 						itemDeferred.resolve();
    939 					} catch (e) {
    940 						itemDeferred.reject(e);
    941 					}
    942 				});
    943 				yield Zotero.Promise.all([itemDeferred.promise, attachmentDeferred.promise]);
    944 			});
    945 		});
    946 	});
    947 	
    948 	
    949 	describe("Error Handling", function () {
    950 		it("should propagate saveItems() errors from synchronous doImport()", function* () {
    951 			var items = [
    952 				{
    953 					// Invalid object
    954 				},
    955 				{
    956 					itemType: "book",
    957 					title: "B"
    958 				}
    959 			];
    960 			
    961 			var added = 0;
    962 			var notifierID = Zotero.Notifier.registerObserver({
    963 				notify: function (event, type, ids, extraData) {
    964 					added++;
    965 				}
    966 			}, ['item']);
    967 			
    968 			var translation = new Zotero.Translate.Import();
    969 			translation.setString("");
    970 			translation.setTranslator(buildDummyTranslator(
    971 				"import",
    972 				"function detectImport() {}"
    973 				+ "function doImport() {"
    974 				+ "	var json = JSON.parse('" + JSON.stringify(items).replace(/['\\]/g, "\\$&") + "');"
    975 				+ "	for (let o of json) {"
    976 				+ "		let item = new Zotero.Item;"
    977 				+ "		for (let field in o) { item[field] = o[field]; }"
    978 				+ "		item.complete();"
    979 				+ "	}"
    980 				+ "}"
    981 			));
    982 			var e = yield getPromiseError(translation.translate());
    983 			Zotero.Notifier.unregisterObserver(notifierID);
    984 			assert.ok(e);
    985 			
    986 			// Saving should be stopped without any saved items
    987 			assert.equal(added, 0);
    988 			assert.equal(translation._savingItems, 0);
    989 			assert.equal(translation._runningAsyncProcesses, 0);
    990 			assert.isNull(translation._currentState);
    991 		});
    992 		
    993 		it("should propagate saveItems() errors from asynchronous doImport()", function* () {
    994 			var items = [
    995 				{
    996 					// Invalid object
    997 				},
    998 				{
    999 					itemType: "book",
   1000 					title: "B"
   1001 				}
   1002 			];
   1003 			
   1004 			var added = 0;
   1005 			var notifierID = Zotero.Notifier.registerObserver({
   1006 				notify: function (event, type, ids, extraData) {
   1007 					added++;
   1008 				}
   1009 			}, ['item']);
   1010 			
   1011 			var translation = new Zotero.Translate.Import();
   1012 			translation.setString("");
   1013 			translation.setTranslator(buildDummyTranslator(
   1014 				"import",
   1015 				"function detectImport() {}"
   1016 					+ "function doImport() {"
   1017 					+ "	var json = JSON.parse('" + JSON.stringify(items).replace(/['\\]/g, "\\$&") + "');"
   1018 					+ "	return new Promise(function (resolve, reject) {"
   1019 					+ "		function next() {"
   1020 					+ "			var data = json.shift();"
   1021 					+ "			if (!data) {"
   1022 					+ "				resolve();"
   1023 					+ "				return;"
   1024 					+ "			}"
   1025 					+ "			var item = new Zotero.Item;"
   1026 					+ "			for (let field in data) { item[field] = data[field]; }"
   1027 					+ "			item.complete().then(next).catch(reject);"
   1028 					+ "		}"
   1029 					+ "		next();"
   1030 					+ "	});"
   1031 					+ "}",
   1032 				{
   1033 					configOptions: {
   1034 						async: true
   1035 					}
   1036 				}
   1037 			));
   1038 			var e = yield getPromiseError(translation.translate());
   1039 			Zotero.Notifier.unregisterObserver(notifierID);
   1040 			assert.ok(e);
   1041 			
   1042 			// Saving should be stopped without any saved items
   1043 			assert.equal(added, 0);
   1044 			assert.equal(translation._savingItems, 0);
   1045 			assert.equal(translation._runningAsyncProcesses, 0);
   1046 			assert.isNull(translation._currentState);
   1047 		});
   1048 		
   1049 		it("should propagate errors from saveItems with synchronous doSearch()", function* () {
   1050 			var stub = sinon.stub(Zotero.Translate.ItemSaver.prototype, "saveItems");
   1051 			stub.returns(Zotero.Promise.reject(new Error("Save error")));
   1052 			
   1053 			var translation = new Zotero.Translate.Search();
   1054 			translation.setTranslator(buildDummyTranslator(
   1055 				"search",
   1056 				"function detectSearch() {}"
   1057 					+ "function doSearch() {"
   1058 					+ "	var item = new Zotero.Item('journalArticle');"
   1059 					+ "	item.itemType = 'book';"
   1060 					+ "	item.title = 'A';"
   1061 					+ "	item.complete();"
   1062 					+ "}"
   1063 			));
   1064 			translation.setSearch({ itemType: "journalArticle", DOI: "10.111/Test"});
   1065 			var e = yield getPromiseError(translation.translate({
   1066 				libraryID: Zotero.Libraries.userLibraryID,
   1067 				saveAttachments: false
   1068 			}));
   1069 			assert.ok(e);
   1070 			
   1071 			stub.restore();
   1072 		});
   1073 		
   1074 		it("should propagate errors from saveItems() with asynchronous doSearch()", function* () {
   1075 			var stub = sinon.stub(Zotero.Translate.ItemSaver.prototype, "saveItems");
   1076 			stub.returns(Zotero.Promise.reject(new Error("Save error")));
   1077 			
   1078 			var translation = new Zotero.Translate.Search();
   1079 			translation.setTranslator(buildDummyTranslator(
   1080 				"search",
   1081 				"function detectSearch() {}"
   1082 					+ "function doSearch() {"
   1083 					+ "	var item = new Zotero.Item('journalArticle');"
   1084 					+ "	item.itemType = 'book';"
   1085 					+ "	item.title = 'A';"
   1086 					+ "	return new Promise(function (resolve, reject) {"
   1087 					+ "		item.complete().then(next).catch(reject);"
   1088 					+ "	});"
   1089 					+ "}",
   1090 				{
   1091 					configOptions: {
   1092 						async: true
   1093 					}
   1094 				}
   1095 			));
   1096 			translation.setSearch({ itemType: "journalArticle", DOI: "10.111/Test"});
   1097 			var e = yield getPromiseError(translation.translate({
   1098 				libraryID: Zotero.Libraries.userLibraryID,
   1099 				saveAttachments: false
   1100 			}));
   1101 			assert.ok(e);
   1102 			
   1103 			stub.restore();
   1104 		});
   1105 	});
   1106 });
   1107 
   1108 describe("Zotero.Translate.ItemGetter", function() {
   1109 	describe("nextItem", function() {
   1110 		it('should return false for an empty database', Zotero.Promise.coroutine(function* () {
   1111 			let getter = new Zotero.Translate.ItemGetter();
   1112 			assert.isFalse(getter.nextItem());
   1113 		}));
   1114 		it('should return items in order they are supplied', Zotero.Promise.coroutine(function* () {
   1115 			let getter = new Zotero.Translate.ItemGetter();
   1116 			let items, itemIDs, itemURIs;
   1117 
   1118 			yield Zotero.DB.executeTransaction(function* () {
   1119 				items = [
   1120 					yield new Zotero.Item('journalArticle'),
   1121 					yield new Zotero.Item('book')
   1122 				];
   1123 				
   1124 				itemIDs = [ yield items[0].save(), yield items[1].save() ];
   1125 				itemURIs = items.map(i => Zotero.URI.getItemURI(i));
   1126 			});
   1127 			
   1128 			getter._itemsLeft = items;
   1129 			
   1130 			assert.equal((getter.nextItem()).uri, itemURIs[0], 'first item comes out first');
   1131 			assert.equal((getter.nextItem()).uri, itemURIs[1], 'second item comes out second');
   1132 			assert.isFalse((getter.nextItem()), 'end of item queue');
   1133 		}));
   1134 		it('should return items with tags in expected format', Zotero.Promise.coroutine(function* () {
   1135 			let getter = new Zotero.Translate.ItemGetter();
   1136 			let itemWithAutomaticTag, itemWithManualTag, itemWithMultipleTags
   1137 			
   1138 			yield Zotero.DB.executeTransaction(function* () {
   1139 				itemWithAutomaticTag = new Zotero.Item('journalArticle');
   1140 				itemWithAutomaticTag.addTag('automatic tag', 0);
   1141 				yield itemWithAutomaticTag.save();
   1142 				
   1143 				itemWithManualTag = new Zotero.Item('journalArticle');
   1144 				itemWithManualTag.addTag('manual tag', 1);
   1145 				yield itemWithManualTag.save();
   1146 				
   1147 				itemWithMultipleTags = new Zotero.Item('journalArticle');
   1148 				itemWithMultipleTags.addTag('tag1', 0);
   1149 				itemWithMultipleTags.addTag('tag2', 1);
   1150 				yield itemWithMultipleTags.save();
   1151 			});
   1152 			
   1153 			let legacyMode = [false, true];
   1154 			for (let i=0; i<legacyMode.length; i++) {
   1155 				getter._itemsLeft = [itemWithAutomaticTag, itemWithManualTag, itemWithMultipleTags];
   1156 				getter.legacy = legacyMode[i];
   1157 				let suffix = legacyMode[i] ? ' in legacy mode' : '';
   1158 				
   1159 				// itemWithAutomaticTag
   1160 				let translatorItem = getter.nextItem();
   1161 				assert.isArray(translatorItem.tags, 'item contains automatic tags in an array' + suffix);
   1162 				assert.isObject(translatorItem.tags[0], 'automatic tag is an object' + suffix);
   1163 				assert.equal(translatorItem.tags[0].tag, 'automatic tag', 'automatic tag name provided as "tag" property' + suffix);
   1164 				if (legacyMode[i]) {
   1165 					assert.equal(translatorItem.tags[0].type, 0, 'automatic tag "type" is 0' + suffix);
   1166 				} else {
   1167 					assert.isUndefined(translatorItem.tags[0].type, '"type" is undefined for automatic tag' + suffix);
   1168 				}
   1169 				
   1170 				// itemWithManualTag
   1171 				translatorItem = getter.nextItem();
   1172 				assert.isArray(translatorItem.tags, 'item contains manual tags in an array' + suffix);
   1173 				assert.isObject(translatorItem.tags[0], 'manual tag is an object' + suffix);
   1174 				assert.equal(translatorItem.tags[0].tag, 'manual tag', 'manual tag name provided as "tag" property' + suffix);
   1175 				assert.equal(translatorItem.tags[0].type, 1, 'manual tag "type" is 1' + suffix);
   1176 				
   1177 				// itemWithMultipleTags
   1178 				translatorItem = getter.nextItem();
   1179 				assert.isArray(translatorItem.tags, 'item contains multiple tags in an array' + suffix);
   1180 				assert.lengthOf(translatorItem.tags, 2, 'expected number of tags returned' + suffix);
   1181 			}
   1182 		}));
   1183 		it('should return item collections in expected format', Zotero.Promise.coroutine(function* () {
   1184 			let getter = new Zotero.Translate.ItemGetter();
   1185 			let items, collections;
   1186 			
   1187 			yield Zotero.DB.executeTransaction(function* () {
   1188 				items = getter._itemsLeft = [
   1189 					new Zotero.Item('journalArticle'), // Not in collection
   1190 					new Zotero.Item('journalArticle'), // In a single collection
   1191 					new Zotero.Item('journalArticle'), //In two collections
   1192 					new Zotero.Item('journalArticle') // In a nested collection
   1193 				];
   1194 				yield Zotero.Promise.all(items.map(item => item.save()));
   1195 				
   1196 				collections = [
   1197 					new Zotero.Collection,
   1198 					new Zotero.Collection,
   1199 					new Zotero.Collection,
   1200 					new Zotero.Collection
   1201 				];
   1202 				collections[0].name = "test1";
   1203 				collections[1].name = "test2";
   1204 				collections[2].name = "subTest1";
   1205 				collections[3].name = "subTest2";
   1206 				yield collections[0].save();
   1207 				yield collections[1].save();
   1208 				collections[2].parentID = collections[0].id;
   1209 				collections[3].parentID = collections[1].id;
   1210 				yield collections[2].save();
   1211 				yield collections[3].save();
   1212 				
   1213 				yield collections[0].addItems([items[1].id, items[2].id]);
   1214 				yield collections[1].addItem(items[2].id);
   1215 				yield collections[2].addItem(items[3].id);
   1216 			});
   1217 			
   1218 			let translatorItem = getter.nextItem();
   1219 			assert.isArray(translatorItem.collections, 'item in library root has a collections array');
   1220 			assert.equal(translatorItem.collections.length, 0, 'item in library root does not list any collections');
   1221 			
   1222 			translatorItem = getter.nextItem();
   1223 			assert.isArray(translatorItem.collections, 'item in a single collection has a collections array');
   1224 			assert.equal(translatorItem.collections.length, 1, 'item in a single collection lists one collection');
   1225 			assert.equal(translatorItem.collections[0], collections[0].key, 'item in a single collection identifies correct collection');
   1226 			
   1227 			translatorItem = getter.nextItem();
   1228 			assert.isArray(translatorItem.collections, 'item in two collections has a collections array');
   1229 			assert.equal(translatorItem.collections.length, 2, 'item in two collections lists two collections');
   1230 			assert.deepEqual(
   1231 				translatorItem.collections.sort(),
   1232 				[collections[0].key, collections[1].key].sort(),
   1233 				'item in two collections identifies correct collections'
   1234 			);
   1235 			
   1236 			translatorItem = getter.nextItem();
   1237 			assert.isArray(translatorItem.collections, 'item in a nested collection has a collections array');
   1238 			assert.equal(translatorItem.collections.length, 1, 'item in a single nested collection lists one collection');
   1239 			assert.equal(translatorItem.collections[0], collections[2].key, 'item in a single collection identifies correct collection');
   1240 		}));
   1241 		
   1242 		it('should return item relations in expected format', Zotero.Promise.coroutine(function* () {
   1243 			let getter = new Zotero.Translate.ItemGetter();
   1244 			let items;
   1245 			
   1246 			yield Zotero.DB.executeTransaction(function* () {
   1247 					items = [
   1248 						new Zotero.Item('journalArticle'), // Item with no relations
   1249 						
   1250 						new Zotero.Item('journalArticle'), // Bidirectional relations
   1251 						new Zotero.Item('journalArticle'), // between these items
   1252 						
   1253 						new Zotero.Item('journalArticle'), // This item is related to two items below
   1254 						new Zotero.Item('journalArticle'), // But this item is not related to the item below
   1255 						new Zotero.Item('journalArticle')
   1256 					];
   1257 					yield Zotero.Promise.all(items.map(item => item.save()));
   1258 					
   1259 					yield items[1].addRelatedItem(items[2]);
   1260 					yield items[2].addRelatedItem(items[1]);
   1261 					
   1262 					yield items[3].addRelatedItem(items[4]);
   1263 					yield items[4].addRelatedItem(items[3]);
   1264 					yield items[3].addRelatedItem(items[5]);
   1265 					yield items[5].addRelatedItem(items[3]);
   1266 			});
   1267 			
   1268 			getter._itemsLeft = items.slice();
   1269 			
   1270 			let translatorItem = getter.nextItem();
   1271 			assert.isObject(translatorItem.relations, 'item with no relations has a relations object');
   1272 			assert.equal(Object.keys(translatorItem.relations).length, 0, 'item with no relations does not list any relations');
   1273 			
   1274 			translatorItem = getter.nextItem();
   1275 			
   1276 			assert.isObject(translatorItem.relations, 'item that is the subject of a single relation has a relations object');
   1277 			assert.equal(Object.keys(translatorItem.relations).length, 1, 'item that is the subject of a single relation lists one relations predicate');
   1278 			assert.lengthOf(translatorItem.relations['dc:relation'], 1, 'item that is the subject of a single relation lists one "dc:relation" object');
   1279 			assert.equal(translatorItem.relations['dc:relation'][0], Zotero.URI.getItemURI(items[2]), 'item that is the subject of a single relation identifies correct object URI');
   1280 			
   1281 			// We currently assign these bidirectionally above, so this is a bit redundant
   1282 			translatorItem = getter.nextItem();
   1283 			assert.isObject(translatorItem.relations, 'item that is the object of a single relation has a relations object');
   1284 			assert.equal(Object.keys(translatorItem.relations).length, 1, 'item that is the object of a single relation list one relations predicate');
   1285 			assert.lengthOf(translatorItem.relations['dc:relation'], 1, 'item that is the object of a single relation lists one "dc:relation" object');
   1286 			assert.equal(translatorItem.relations['dc:relation'][0], Zotero.URI.getItemURI(items[1]), 'item that is the object of a single relation identifies correct subject URI');
   1287 			
   1288 			translatorItem = getter.nextItem();
   1289 			assert.isObject(translatorItem.relations, 'item that is the subject of two relations has a relations object');
   1290 			assert.equal(Object.keys(translatorItem.relations).length, 1, 'item that is the subject of two relations list one relations predicate');
   1291 			assert.isDefined(translatorItem.relations['dc:relation'], 'item that is the subject of two relations uses "dc:relation" as the predicate');
   1292 			assert.isArray(translatorItem.relations['dc:relation'], 'item that is the subject of two relations lists "dc:relation" object as an array');
   1293 			assert.equal(translatorItem.relations['dc:relation'].length, 2, 'item that is the subject of two relations lists two relations in the "dc:relation" array');
   1294 			assert.deepEqual(
   1295 				translatorItem.relations['dc:relation'].sort(),
   1296 				[Zotero.URI.getItemURI(items[4]), Zotero.URI.getItemURI(items[5])].sort(),
   1297 				'item that is the subject of two relations identifies correct object URIs'
   1298 			);
   1299 			
   1300 			translatorItem = getter.nextItem();
   1301 			assert.isObject(translatorItem.relations, 'item that is the object of one relation from item with two relations has a relations object');
   1302 			assert.equal(Object.keys(translatorItem.relations).length, 1, 'item that is the object of one relation from item with two relations list one relations predicate');
   1303 			assert.isDefined(translatorItem.relations['dc:relation'], 'item that is the object of one relation from item with two relations uses "dc:relation" as the predicate');
   1304 			assert.lengthOf(translatorItem.relations['dc:relation'], 1, 'item that is the object of one relation from item with two relations lists one "dc:relation" object');
   1305 			assert.equal(translatorItem.relations['dc:relation'][0], Zotero.URI.getItemURI(items[3]), 'item that is the object of one relation from item with two relations identifies correct subject URI');
   1306 		}));
   1307 		
   1308 		it('should return standalone note in expected format', Zotero.Promise.coroutine(function* () {
   1309 			let relatedItem, note, collection;
   1310 			
   1311 			yield Zotero.DB.executeTransaction(function* () {
   1312 				relatedItem = new Zotero.Item('journalArticle');
   1313 				yield relatedItem.save();
   1314 
   1315 				note = new Zotero.Item('note');
   1316 				note.setNote('Note');
   1317 				note.addTag('automaticTag', 0);
   1318 				note.addTag('manualTag', 1);
   1319 				note.addRelatedItem(relatedItem);
   1320 				yield note.save();
   1321 				
   1322 				relatedItem.addRelatedItem(note);
   1323 				yield relatedItem.save();
   1324 				
   1325 				collection = new Zotero.Collection;
   1326 				collection.name = 'test';
   1327 				yield collection.save();
   1328 				yield collection.addItem(note.id);
   1329 			});
   1330 			
   1331 			let legacyMode = [false, true];
   1332 			for (let i=0; i<legacyMode.length; i++) {
   1333 				let getter = new Zotero.Translate.ItemGetter();
   1334 				getter._itemsLeft = [note];
   1335 				let legacy = getter.legacy = legacyMode[i];
   1336 				let suffix = legacy ? ' in legacy mode' : '';
   1337 				
   1338 				let translatorNote = getter.nextItem();
   1339 				assert.isDefined(translatorNote, 'returns standalone note' + suffix);
   1340 				assert.equal(translatorNote.itemType, 'note', 'itemType is correct' + suffix);
   1341 				assert.equal(translatorNote.note, 'Note', 'note is correct' + suffix);
   1342 				
   1343 				assert.isString(translatorNote.dateAdded, 'dateAdded is string' + suffix);
   1344 				assert.isString(translatorNote.dateModified, 'dateModified is string' + suffix);
   1345 				
   1346 				if (legacy) {
   1347 					assert.isTrue(sqlDateTimeRe.test(translatorNote.dateAdded), 'dateAdded is in correct format' + suffix);
   1348 					assert.isTrue(sqlDateTimeRe.test(translatorNote.dateModified), 'dateModified is in correct format' + suffix);
   1349 					
   1350 					assert.isNumber(translatorNote.itemID, 'itemID is set' + suffix);
   1351 					assert.isString(translatorNote.key, 'key is set' + suffix);
   1352 				} else {
   1353 					assert.isTrue(isoDateTimeRe.test(translatorNote.dateAdded), 'dateAdded is in correct format' + suffix);
   1354 					assert.isTrue(isoDateTimeRe.test(translatorNote.dateModified), 'dateModified is in correct format' + suffix);
   1355 				}
   1356 				
   1357 				// Tags
   1358 				assert.isArray(translatorNote.tags, 'contains tags as array' + suffix);
   1359 				assert.equal(translatorNote.tags.length, 2, 'contains correct number of tags' + suffix);
   1360 				let possibleTags = [
   1361 					{ tag: 'automaticTag', type: 0 },
   1362 					{ tag: 'manualTag', type: 1 }
   1363 				];
   1364 				for (let i=0; i<possibleTags.length; i++) {
   1365 					let match = false;
   1366 					for (let j=0; j<translatorNote.tags.length; j++) {
   1367 						if (possibleTags[i].tag == translatorNote.tags[j].tag) {
   1368 							let type = possibleTags[i].type;
   1369 							if (!legacy && type == 0) type = undefined;
   1370 							
   1371 							assert.equal(translatorNote.tags[j].type, type, possibleTags[i].tag + ' tag is correct' + suffix);
   1372 							match = true;
   1373 							break;
   1374 						}
   1375 					}
   1376 					assert.isTrue(match, 'has ' + possibleTags[i].tag + ' tag ' + suffix);
   1377 				}
   1378 				
   1379 				// Relations
   1380 				assert.isObject(translatorNote.relations, 'has relations as object' + suffix);
   1381 				assert.lengthOf(translatorNote.relations['dc:relation'], 1, 'has one relation' + suffix);
   1382 				assert.equal(translatorNote.relations['dc:relation'][0], Zotero.URI.getItemURI(relatedItem), 'relation is correct' + suffix);
   1383 				
   1384 				if (!legacy) {
   1385 					// Collections
   1386 					assert.isArray(translatorNote.collections, 'has a collections array' + suffix);
   1387 					assert.equal(translatorNote.collections.length, 1, 'lists one collection' + suffix);
   1388 					assert.equal(translatorNote.collections[0], collection.key, 'identifies correct collection' + suffix);
   1389 				}
   1390 			}
   1391 		}));
   1392 		it('should return attached note in expected format', Zotero.Promise.coroutine(function* () {
   1393 			let relatedItem, items, collection, note;
   1394 			yield Zotero.DB.executeTransaction(function* () {
   1395 				relatedItem = new Zotero.Item('journalArticle');
   1396 				yield relatedItem.save();
   1397 				
   1398 				items = [
   1399 					new Zotero.Item('journalArticle'),
   1400 					new Zotero.Item('journalArticle')
   1401 				];
   1402 				yield Zotero.Promise.all(items.map(item => item.save()));
   1403 				
   1404 				collection = new Zotero.Collection;
   1405 				collection.name = 'test';
   1406 				yield collection.save();
   1407 				yield collection.addItem(items[0].id);
   1408 				yield collection.addItem(items[1].id);
   1409 				
   1410 				note = new Zotero.Item('note');
   1411 				note.setNote('Note');
   1412 				note.addTag('automaticTag', 0);
   1413 				note.addTag('manualTag', 1);
   1414 				yield note.save();
   1415 				
   1416 				note.addRelatedItem(relatedItem);
   1417 				relatedItem.addRelatedItem(note);
   1418 				yield note.save();
   1419 				yield relatedItem.save();
   1420 			});
   1421 			
   1422 			let legacyMode = [false, true];
   1423 			for (let i=0; i<legacyMode.length; i++) {
   1424 				let item = items[i];
   1425 				
   1426 				let getter = new Zotero.Translate.ItemGetter();
   1427 				getter._itemsLeft = [item];
   1428 				let legacy = getter.legacy = legacyMode[i];
   1429 				let suffix = legacy ? ' in legacy mode' : '';
   1430 				
   1431 				let translatorItem = getter.nextItem();
   1432 				assert.isArray(translatorItem.notes, 'item with no notes contains notes array' + suffix);
   1433 				assert.equal(translatorItem.notes.length, 0, 'item with no notes contains empty notes array' + suffix);
   1434 				
   1435 				note.parentID = item.id;
   1436 				yield note.saveTx();
   1437 				
   1438 				getter = new Zotero.Translate.ItemGetter();
   1439 				getter._itemsLeft = [item];
   1440 				getter.legacy = legacy;
   1441 				
   1442 				translatorItem = getter.nextItem();
   1443 				assert.isArray(translatorItem.notes, 'item with no notes contains notes array' + suffix);
   1444 				assert.equal(translatorItem.notes.length, 1, 'item with one note contains array with one note' + suffix);
   1445 				
   1446 				let translatorNote = translatorItem.notes[0];
   1447 				assert.equal(translatorNote.itemType, 'note', 'itemType is correct' + suffix);
   1448 				assert.equal(translatorNote.note, 'Note', 'note is correct' + suffix);
   1449 				
   1450 				assert.isString(translatorNote.dateAdded, 'dateAdded is string' + suffix);
   1451 				assert.isString(translatorNote.dateModified, 'dateModified is string' + suffix);
   1452 				
   1453 				if (legacy) {
   1454 					assert.isTrue(sqlDateTimeRe.test(translatorNote.dateAdded), 'dateAdded is in correct format' + suffix);
   1455 					assert.isTrue(sqlDateTimeRe.test(translatorNote.dateModified), 'dateModified is in correct format' + suffix);
   1456 					
   1457 					assert.isNumber(translatorNote.itemID, 'itemID is set' + suffix);
   1458 					assert.isString(translatorNote.key, 'key is set' + suffix);
   1459 				} else {
   1460 					assert.isTrue(isoDateTimeRe.test(translatorNote.dateAdded), 'dateAdded is in correct format' + suffix);
   1461 					assert.isTrue(isoDateTimeRe.test(translatorNote.dateModified), 'dateModified is in correct format' + suffix);
   1462 				}
   1463 				
   1464 				// Tags
   1465 				assert.isArray(translatorNote.tags, 'contains tags as array' + suffix);
   1466 				assert.equal(translatorNote.tags.length, 2, 'contains correct number of tags' + suffix);
   1467 				let possibleTags = [
   1468 					{ tag: 'automaticTag', type: 0 },
   1469 					{ tag: 'manualTag', type: 1 }
   1470 				];
   1471 				for (let i=0; i<possibleTags.length; i++) {
   1472 					let match = false;
   1473 					for (let j=0; j<translatorNote.tags.length; j++) {
   1474 						if (possibleTags[i].tag == translatorNote.tags[j].tag) {
   1475 							let type = possibleTags[i].type;
   1476 							if (!legacy && type == 0) type = undefined;
   1477 							
   1478 							assert.equal(translatorNote.tags[j].type, type, possibleTags[i].tag + ' tag is correct' + suffix);
   1479 							match = true;
   1480 							break;
   1481 						}
   1482 					}
   1483 					assert.isTrue(match, 'has ' + possibleTags[i].tag + ' tag ' + suffix);
   1484 				}
   1485 				
   1486 				// Relations
   1487 				assert.isObject(translatorNote.relations, 'has relations as object' + suffix);
   1488 				assert.lengthOf(translatorNote.relations['dc:relation'], 1, 'has one relation' + suffix);
   1489 				assert.equal(translatorNote.relations['dc:relation'][0], Zotero.URI.getItemURI(relatedItem), 'relation is correct' + suffix);
   1490 				
   1491 				if (!legacy) {
   1492 					// Collections
   1493 					assert.isUndefined(translatorNote.collections, 'has no collections array' + suffix);
   1494 				}
   1495 			}
   1496 		}));
   1497 		
   1498 		it('should return stored/linked file and URI attachments in expected format', Zotero.Promise.coroutine(function* () {
   1499 			this.timeout(60000);
   1500 			let file = getTestPDF();
   1501 			let item, relatedItem;
   1502 			
   1503 			yield Zotero.DB.executeTransaction(function* () {
   1504 				item = new Zotero.Item('journalArticle');
   1505 				yield item.save();
   1506 				relatedItem = new Zotero.Item('journalArticle');
   1507 				yield relatedItem.save();
   1508 			});
   1509 
   1510 			// Attachment items
   1511 			let attachments = [
   1512 				yield Zotero.Attachments.importFromFile({"file":file}), // Standalone stored file
   1513 				yield Zotero.Attachments.linkFromFile({"file":file}), // Standalone link to file
   1514 				yield Zotero.Attachments.importFromFile({"file":file, "parentItemID":item.id}), // Attached stored file
   1515 				yield Zotero.Attachments.linkFromFile({"file":file, "parentItemID":item.id}), // Attached link to file
   1516 				yield Zotero.Attachments.linkFromURL({"url":'http://example.com', "parentItemID":item.id, "contentType":'application/pdf', "title":'empty.pdf'}) // Attached link to URL
   1517 			];
   1518 			
   1519 			yield Zotero.DB.executeTransaction(function* () {
   1520 				// Make sure all fields are populated
   1521 				for (let i=0; i<attachments.length; i++) {
   1522 					let attachment = attachments[i];
   1523 					attachment.setField('accessDate', '2001-02-03 12:13:14');
   1524 					attachment.attachmentCharset = 'utf-8';
   1525 					attachment.setField('url', 'http://example.com');
   1526 					attachment.setNote('note');
   1527 				
   1528 					attachment.addTag('automaticTag', 0);
   1529 					attachment.addTag('manualTag', 1);
   1530 					
   1531 					attachment.addRelatedItem(relatedItem);
   1532 					
   1533 					yield attachment.save();
   1534 					
   1535 					relatedItem.addRelatedItem(attachment);
   1536 				}
   1537 				
   1538 				yield relatedItem.save();
   1539 			});
   1540 			
   1541 			let items = [ attachments[0], attachments[1], item ]; // Standalone attachments and item with child attachments
   1542 			
   1543 			// Run tests
   1544 			let legacyMode = [false, true];
   1545 			for (let i=0; i<legacyMode.length; i++) {
   1546 				let getter = new Zotero.Translate.ItemGetter();
   1547 				getter._itemsLeft = items.slice();
   1548 				
   1549 				let exportDir = yield getTempDirectory();
   1550 				getter._exportFileDirectory = Components.classes["@mozilla.org/file/local;1"]
   1551 					.createInstance(Components.interfaces.nsILocalFile);
   1552 				getter._exportFileDirectory.initWithPath(exportDir);
   1553 				
   1554 				let legacy = getter.legacy = legacyMode[i];
   1555 				let suffix = legacy ? ' in legacy mode' : '';
   1556 				
   1557 				// Gather all standalone and child attachments into a single array,
   1558 				// since tests are mostly the same
   1559 				let translatorAttachments = [], translatorItem;
   1560 				let itemsLeft = items.length, attachmentsLeft = attachments.length;
   1561 				while (translatorItem = getter.nextItem()) {
   1562 					assert.isString(translatorItem.itemType, 'itemType is set' + suffix);
   1563 					
   1564 					// Standalone attachments
   1565 					if (translatorItem.itemType == 'attachment') {
   1566 						translatorAttachments.push({
   1567 							child: false,
   1568 							attachment: translatorItem
   1569 						});
   1570 						attachmentsLeft--;
   1571 					
   1572 					// Child attachments
   1573 					} else if (translatorItem.itemType == 'journalArticle') {
   1574 						assert.isArray(translatorItem.attachments, 'item contains attachment array' + suffix);
   1575 						assert.equal(translatorItem.attachments.length, 3, 'attachment array contains all items' + suffix);
   1576 						
   1577 						for (let i=0; i<translatorItem.attachments.length; i++) {
   1578 							let attachment = translatorItem.attachments[i];
   1579 							assert.equal(attachment.itemType, 'attachment', 'item attachment is of itemType "attachment"' + suffix);
   1580 							
   1581 							translatorAttachments.push({
   1582 								child: true,
   1583 								attachment: attachment
   1584 							});
   1585 							
   1586 							attachmentsLeft--;
   1587 						}
   1588 					
   1589 					// Unexpected
   1590 					} else {
   1591 						assert.fail(translatorItem.itemType, 'attachment or journalArticle', 'expected itemType returned');
   1592 					}
   1593 					
   1594 					itemsLeft--;
   1595 				}
   1596 				
   1597 				assert.equal(itemsLeft, 0, 'all items returned by getter');
   1598 				assert.equal(attachmentsLeft, 0, 'all attachments returned by getter');
   1599 				
   1600 				// Since we make no guarantees on the order of child attachments,
   1601 				// we have to rely on URI as the identifier
   1602 				let uriMap = {};
   1603 				for (let i=0; i<attachments.length; i++) {
   1604 					uriMap[Zotero.URI.getItemURI(attachments[i])] = attachments[i];
   1605 				}
   1606 				
   1607 				for (let j=0; j<translatorAttachments.length; j++) {
   1608 					let childAttachment = translatorAttachments[j].child;
   1609 					let attachment = translatorAttachments[j].attachment;
   1610 					assert.isString(attachment.uri, 'uri is set' + suffix);
   1611 					
   1612 					let zoteroItem = uriMap[attachment.uri];
   1613 					assert.isDefined(zoteroItem, 'uri is correct' + suffix);
   1614 					delete uriMap[attachment.uri];
   1615 					
   1616 					let storedFile = zoteroItem.attachmentLinkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE
   1617 						|| zoteroItem.attachmentLinkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL;
   1618 					let linkToURL = zoteroItem.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL;
   1619 					
   1620 					let prefix = (childAttachment ? 'attached ' : '')
   1621 						+ (storedFile ? 'stored ' : 'link to ')
   1622 						+ (linkToURL ? 'URL ' : 'file ');
   1623 					
   1624 					// Set fields
   1625 					assert.equal(attachment.itemType, 'attachment', prefix + 'itemType is correct' + suffix);
   1626 					assert.equal(attachment.title, 'empty.pdf', prefix + 'title is correct' + suffix);
   1627 					assert.equal(attachment.url, 'http://example.com', prefix + 'url is correct' + suffix);
   1628 					assert.equal(attachment.note, 'note', prefix + 'note is correct' + suffix);
   1629 					
   1630 					// Automatically set fields
   1631 					assert.isString(attachment.dateAdded, prefix + 'dateAdded is set' + suffix);
   1632 					assert.isString(attachment.dateModified, prefix + 'dateModified is set' + suffix);
   1633 					
   1634 					// Legacy mode fields
   1635 					if (legacy) {
   1636 						assert.isNumber(attachment.itemID, prefix + 'itemID is set' + suffix);
   1637 						assert.isString(attachment.key, prefix + 'key is set' + suffix);
   1638 						assert.equal(attachment.mimeType, 'application/pdf', prefix + 'mimeType is correct' + suffix);
   1639 						
   1640 						assert.equal(attachment.accessDate, '2001-02-03 12:13:14', prefix + 'accessDate is correct' + suffix);
   1641 						
   1642 						assert.isTrue(sqlDateTimeRe.test(attachment.dateAdded), prefix + 'dateAdded matches SQL format' + suffix);
   1643 						assert.isTrue(sqlDateTimeRe.test(attachment.dateModified), prefix + 'dateModified matches SQL format' + suffix);
   1644 					} else {
   1645 						assert.equal(attachment.contentType, 'application/pdf', prefix + 'contentType is correct' + suffix);
   1646 						
   1647 						assert.equal(attachment.accessDate, '2001-02-03T12:13:14Z', prefix + 'accessDate is correct' + suffix);
   1648 						
   1649 						assert.isTrue(isoDateTimeRe.test(attachment.dateAdded), prefix + 'dateAdded matches ISO-8601 format' + suffix);
   1650 						assert.isTrue(isoDateTimeRe.test(attachment.dateModified), prefix + 'dateModified matches ISO-8601 format' + suffix);
   1651 					}
   1652 					
   1653 					if (!linkToURL) {
   1654 						// localPath
   1655 						assert.isString(attachment.localPath, prefix + 'localPath is set' + suffix);
   1656 						let attachmentFile = Components.classes["@mozilla.org/file/local;1"]
   1657 							.createInstance(Components.interfaces.nsILocalFile);
   1658 						attachmentFile.initWithPath(attachment.localPath);
   1659 						assert.isTrue(attachmentFile.exists(), prefix + 'localPath points to a file' + suffix);
   1660 						assert.isTrue(attachmentFile.equals(attachments[j].getFile()), prefix + 'localPath points to the correct file' + suffix);
   1661 						
   1662 						assert.equal(attachment.filename, 'empty.pdf', prefix + 'filename is correct' + suffix);
   1663 						assert.equal(attachment.defaultPath, 'files/' + attachments[j].id + '/' + attachment.filename, prefix + 'defaultPath is correct' + suffix);
   1664 						
   1665 						// saveFile function
   1666 						assert.isFunction(attachment.saveFile, prefix + 'has saveFile function' + suffix);
   1667 						attachment.saveFile(attachment.defaultPath);
   1668 						assert.equal(attachment.path, OS.Path.join(exportDir, OS.Path.normalize(attachment.defaultPath)), prefix + 'path is set correctly after saveFile call' + suffix);
   1669 						
   1670 						let fileExists = yield OS.File.exists(attachment.path);
   1671 						assert.isTrue(fileExists, prefix + 'file was copied to the correct path by saveFile function' + suffix);
   1672 						fileExists = yield OS.File.exists(attachment.localPath);
   1673 						assert.isTrue(fileExists, prefix + 'file was not removed from original location' + suffix);
   1674 						
   1675 						assert.throws(attachment.saveFile.bind(attachment, attachment.defaultPath), /^ERROR_FILE_EXISTS /, prefix + 'saveFile does not overwrite existing file by default' + suffix);
   1676 						assert.throws(attachment.saveFile.bind(attachment, 'file/../../'), /./, prefix + 'saveFile does not allow exporting outside export directory' + suffix);
   1677 						/** TODO: check if overwriting existing file works **/
   1678 					}
   1679 					
   1680 					// Tags
   1681 					assert.isArray(attachment.tags, prefix + 'contains tags as array' + suffix);
   1682 					assert.equal(attachment.tags.length, 2, prefix + 'contains correct number of tags' + suffix);
   1683 					let possibleTags = [
   1684 						{ tag: 'automaticTag', type: 0 },
   1685 						{ tag: 'manualTag', type: 1 }
   1686 					];
   1687 					for (let i=0; i<possibleTags.length; i++) {
   1688 						let match = false;
   1689 						for (let j=0; j<attachment.tags.length; j++) {
   1690 							if (possibleTags[i].tag == attachment.tags[j].tag) {
   1691 								let type = possibleTags[i].type;
   1692 								if (!legacy && type == 0) type = undefined;
   1693 								
   1694 								assert.equal(attachment.tags[j].type, type, prefix + possibleTags[i].tag + ' tag is correct' + suffix);
   1695 								match = true;
   1696 								break;
   1697 							}
   1698 						}
   1699 						assert.isTrue(match, prefix + ' has ' + possibleTags[i].tag + ' tag ' + suffix);
   1700 					}
   1701 					
   1702 					// Relations
   1703 					assert.isObject(attachment.relations, prefix + 'has relations as object' + suffix);
   1704 					assert.lengthOf(attachment.relations['dc:relation'], 1, prefix + 'has one relation' + suffix);
   1705 					assert.equal(attachment.relations['dc:relation'][0], Zotero.URI.getItemURI(relatedItem), prefix + 'relation is correct' + suffix);
   1706 					/** TODO: test other relations and multiple relations per predicate (should be an array) **/
   1707 				}
   1708 			}
   1709 		}));
   1710 	});
   1711 	
   1712 	describe("#setCollection()", function () {
   1713 		it("should add collection items", function* () {
   1714 			var col = yield createDataObject('collection');
   1715 			var item1 = yield createDataObject('item', { collections: [col.id] });
   1716 			var item2 = yield createDataObject('item', { collections: [col.id] });
   1717 			var item3 = yield createDataObject('item');
   1718 			
   1719 			let getter = new Zotero.Translate.ItemGetter();
   1720 			getter.setCollection(col);
   1721 			
   1722 			assert.equal(getter.numItems, 2);
   1723 		});
   1724 	});
   1725 	
   1726 	describe("#_attachmentToArray()", function () {
   1727 		it("should handle missing attachment files", function* () {
   1728 			var item = yield importFileAttachment('test.png');
   1729 			var path = item.getFilePath();
   1730 			// Delete attachment file
   1731 			yield OS.File.remove(path);
   1732 			
   1733 			var translation = new Zotero.Translate.Export();
   1734 			var tmpDir = yield getTempDirectory();
   1735 			var exportDir = OS.Path.join(tmpDir, 'export');
   1736 			translation.setLocation(Zotero.File.pathToFile(exportDir));
   1737 			translation.setItems([item]);
   1738 			translation.setTranslator('14763d24-8ba0-45df-8f52-b8d1108e7ac9'); // Zotero RDF
   1739 			translation.setDisplayOptions({
   1740 				exportFileData: true
   1741 			});
   1742 			yield translation.translate();
   1743 			
   1744 			var exportFile = OS.Path.join(exportDir, 'export.rdf');
   1745 			assert.isAbove((yield OS.File.stat(exportFile)).size, 0);
   1746 		});
   1747 		
   1748 		it("should handle empty attachment path", function* () {
   1749 			var item = yield importFileAttachment('test.png');
   1750 			item._attachmentPath = '';
   1751 			assert.equal(item.attachmentPath, '');
   1752 			
   1753 			var translation = new Zotero.Translate.Export();
   1754 			var tmpDir = yield getTempDirectory();
   1755 			var exportDir = OS.Path.join(tmpDir, 'export');
   1756 			translation.setLocation(Zotero.File.pathToFile(exportDir));
   1757 			translation.setItems([item]);
   1758 			translation.setTranslator('14763d24-8ba0-45df-8f52-b8d1108e7ac9'); // Zotero RDF
   1759 			translation.setDisplayOptions({
   1760 				exportFileData: true
   1761 			});
   1762 			yield translation.translate();
   1763 			
   1764 			var exportFile = OS.Path.join(exportDir, 'export.rdf');
   1765 			assert.isAbove((yield OS.File.stat(exportFile)).size, 0);
   1766 		});
   1767 	});
   1768 });
   1769 }