www

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

mendeleyImport.js (34261B)


      1 var EXPORTED_SYMBOLS = ["Zotero_Import_Mendeley"];
      2 
      3 Components.utils.import("resource://gre/modules/Services.jsm");
      4 Components.utils.import("resource://gre/modules/osfile.jsm");
      5 Services.scriptloader.loadSubScript("chrome://zotero/content/include.js");
      6 
      7 var Zotero_Import_Mendeley = function () {
      8 	this.createNewCollection = null;
      9 	this.newItems = [];
     10 	
     11 	this._db;
     12 	this._file;
     13 	this._itemDone;
     14 	this._progress = 0;
     15 	this._progressMax;
     16 };
     17 
     18 Zotero_Import_Mendeley.prototype.setLocation = function (file) {
     19 	this._file = file.path || file;
     20 };
     21 
     22 Zotero_Import_Mendeley.prototype.setHandler = function (name, handler) {
     23 	switch (name) {
     24 	case 'itemDone':
     25 		this._itemDone = handler;
     26 		break;
     27 	}
     28 };
     29 
     30 Zotero_Import_Mendeley.prototype.getProgress = function () {
     31 	return this._progress / this._progressMax * 100;
     32 };
     33 
     34 Zotero_Import_Mendeley.prototype.getTranslators = async function () {
     35 	return [{
     36 		label: Zotero.getString('fileInterface.appDatabase', 'Mendeley')
     37 	}];
     38 };
     39 
     40 Zotero_Import_Mendeley.prototype.setTranslator = function () {};
     41 
     42 Zotero_Import_Mendeley.prototype.translate = async function (options) {
     43 	if (true) {
     44 		Services.scriptloader.loadSubScript("chrome://zotero/content/import/mendeley/mendeleySchemaMap.js");
     45 	}
     46 	// TEMP: Load uncached from ~/zotero-client for development
     47 	else {
     48 		Components.utils.import("resource://gre/modules/FileUtils.jsm");
     49 		let file = FileUtils.getDir("Home", []);
     50 		file = OS.Path.join(file.path, 'zotero-client', 'chrome', 'content', 'zotero', 'import', 'mendeley', 'mendeleySchemaMap.js');
     51 		let fileURI = OS.Path.toFileURI(file);
     52 		let xmlhttp = await Zotero.HTTP.request(
     53 			'GET',
     54 			fileURI,
     55 			{
     56 				dontCache: true,
     57 				responseType: 'text'
     58 			}
     59 		);
     60 		eval(xmlhttp.response);
     61 	}
     62 	
     63 	const libraryID = options.libraryID || Zotero.Libraries.userLibraryID;
     64 	const { key: rootCollectionKey } = options.collections
     65 		? Zotero.Collections.getLibraryAndKeyFromID(options.collections[0])
     66 		: {};
     67 	
     68 	// TODO: Get appropriate version based on schema version
     69 	const mapVersion = 83;
     70 	map = map[mapVersion];
     71 	
     72 	const mendeleyGroupID = 0;
     73 	
     74 	// Disable syncing while we're importing
     75 	var resumeSync = Zotero.Sync.Runner.delayIndefinite();
     76 	
     77 	this._db = new Zotero.DBConnection(this._file);
     78 	
     79 	try {
     80 		if (!await this._isValidDatabase()) {
     81 			throw new Error("Not a valid Mendeley database");
     82 		}
     83 		
     84 		// Collections
     85 		let folders = await this._getFolders(mendeleyGroupID);
     86 		let collectionJSON = this._foldersToAPIJSON(folders, rootCollectionKey);
     87 		let folderKeys = this._getFolderKeys(collectionJSON);
     88 		await this._saveCollections(libraryID, collectionJSON, folderKeys);
     89 		
     90 		//
     91 		// Items
     92 		//
     93 		let documents = await this._getDocuments(mendeleyGroupID);
     94 		this._progressMax = documents.length;
     95 		// Get various attributes mapped to document ids
     96 		let urls = await this._getDocumentURLs(mendeleyGroupID);
     97 		let creators = await this._getDocumentCreators(mendeleyGroupID, map.creatorTypes);
     98 		let tags = await this._getDocumentTags(mendeleyGroupID);
     99 		let collections = await this._getDocumentCollections(
    100 			mendeleyGroupID,
    101 			documents,
    102 			rootCollectionKey,
    103 			folderKeys
    104 		);
    105 		let files = await this._getDocumentFiles(mendeleyGroupID);
    106 		let annotations = await this._getDocumentAnnotations(mendeleyGroupID);
    107 		for (let document of documents) {
    108 			let docURLs = urls.get(document.id);
    109 			let docFiles = files.get(document.id);
    110 			
    111 			// If there's a single PDF file and a single PDF URL and the file exists, make an
    112 			// imported_url attachment instead of separate file and linked_url attachments
    113 			if (docURLs && docFiles) {
    114 				let pdfFiles = docFiles.filter(x => x.fileURL.endsWith('.pdf'));
    115 				let pdfURLs = docURLs.filter(x => x.includes('pdf'));
    116 				if (pdfFiles.length == 1
    117 						&& pdfURLs.length == 1
    118 						&& await this._getRealFilePath(OS.Path.fromFileURI(pdfFiles[0].fileURL))) {
    119 					// Add URL to PDF attachment
    120 					docFiles.forEach((x) => {
    121 						if (x.fileURL.endsWith('.pdf')) {
    122 							x.title = 'PDF';
    123 							x.url = pdfURLs[0];
    124 							x.contentType = 'application/pdf';
    125 						}
    126 					});
    127 					// Remove PDF URL from URLs array
    128 					docURLs = docURLs.filter(x => !x.includes('pdf'));
    129 				}
    130 			}
    131 			
    132 			// Save each document with its attributes
    133 			let itemJSON = await this._documentToAPIJSON(
    134 				map,
    135 				document,
    136 				docURLs,
    137 				creators.get(document.id),
    138 				tags.get(document.id),
    139 				collections.get(document.id),
    140 				annotations.get(document.id)
    141 			);
    142 			let documentIDMap = await this._saveItems(libraryID, itemJSON);
    143 			// Save the document's attachments and extracted annotations for any of them
    144 			if (docFiles) {
    145 				await this._saveFilesAndAnnotations(
    146 					docFiles,
    147 					libraryID,
    148 					documentIDMap.get(document.id),
    149 					annotations.get(document.id)
    150 				);
    151 			}
    152 			this.newItems.push(Zotero.Items.get(documentIDMap.get(document.id)));
    153 			this._progress++;
    154 			if (this._itemDone) {
    155 				this._itemDone();
    156 			}
    157 		}
    158 	}
    159 	finally {
    160 		try {
    161 			await this._db.closeDatabase();
    162 		}
    163 		catch (e) {
    164 			Zotero.logError(e);
    165 		}
    166 		
    167 		resumeSync();
    168 	}
    169 };
    170 
    171 Zotero_Import_Mendeley.prototype._isValidDatabase = async function () {
    172 	var tables = [
    173 		'DocumentContributors',
    174 		'DocumentFiles',
    175 		'DocumentFolders',
    176 		'DocumentKeywords',
    177 		'DocumentTags',
    178 		'DocumentUrls',
    179 		'Documents',
    180 		'Files',
    181 		'Folders',
    182 		'RemoteDocuments',
    183 		'RemoteFolders'
    184 	];
    185 	for (let table of tables) {
    186 		if (!await this._db.tableExists(table)) {
    187 			return false;
    188 		}
    189 	}
    190 	return true;
    191 };
    192 
    193 //
    194 // Collections
    195 //
    196 Zotero_Import_Mendeley.prototype._getFolders = async function (groupID) {
    197 	return this._db.queryAsync(
    198 		`SELECT F.*, RF.remoteUuid FROM Folders F `
    199 			+ `JOIN RemoteFolders RF ON (F.id=RF.folderId) `
    200 			+ `WHERE groupId=?`,
    201 		groupID
    202 	);
    203 };
    204 
    205 /**
    206  * Get flat array of collection API JSON with parentCollection set
    207  *
    208  * The returned objects include an extra 'id' property for matching collections to documents.
    209  */
    210 Zotero_Import_Mendeley.prototype._foldersToAPIJSON = function (folderRows, parentKey) {
    211 	var maxDepth = 50;
    212 	return this._getFolderDescendents(-1, parentKey, folderRows, maxDepth);
    213 };
    214 
    215 Zotero_Import_Mendeley.prototype._getFolderDescendents = function (folderID, folderKey, folderRows, maxDepth) {
    216 	if (maxDepth == 0) return []
    217 	var descendents = [];
    218 	var children = folderRows
    219 		.filter(f => f.parentId == folderID)
    220 		.map(f => {
    221 			let c = {
    222 				folderID: f.id,
    223 				remoteUUID: f.remoteUuid,
    224 				key: Zotero.DataObjectUtilities.generateKey(),
    225 				name: f.name,
    226 				parentCollection: folderKey
    227 			};
    228 			if (f.remoteUuid) {
    229 				c.relations = {
    230 					'mendeleyDB:remoteFolderUUID': f.remoteUuid
    231 				};
    232 			}
    233 			return c;
    234 		});
    235 	
    236 	for (let child of children) {
    237 		descendents.push(
    238 			child,
    239 			...this._getFolderDescendents(child.folderID, child.key, folderRows, maxDepth - 1)
    240 		);
    241 	}
    242 	return descendents;
    243 };
    244 
    245 Zotero_Import_Mendeley.prototype._getFolderKeys = function (collections) {
    246 	var map = new Map();
    247 	for (let collection of collections) {
    248 		map.set(collection.folderID, collection.key);
    249 	}
    250 	return map;
    251 };
    252 
    253 /**
    254  * @param {Integer} libraryID
    255  * @param {Object[]} json
    256  */
    257 Zotero_Import_Mendeley.prototype._saveCollections = async function (libraryID, json, folderKeys) {
    258 	var keyMap = new Map();
    259 	for (let i = 0; i < json.length; i++) {
    260 		let collectionJSON = json[i];
    261 		
    262 		// Check if the collection was previously imported
    263 		let collection = this._findExistingCollection(
    264 			libraryID,
    265 			collectionJSON,
    266 			collectionJSON.parentCollection ? keyMap.get(collectionJSON.parentCollection) : null
    267 		);
    268 		if (collection) {
    269 			// Update any child collections to point to the existing collection's key instead of
    270 			// the new generated one
    271 			this._updateParentKeys('collection', json, i + 1, collectionJSON.key, collection.key);
    272 			// And update the map of Mendeley folderIDs to Zotero collection keys
    273 			folderKeys.set(collectionJSON.folderID, collection.key);
    274 		}
    275 		else {
    276 			collection = new Zotero.Collection;
    277 			collection.libraryID = libraryID;
    278 			if (collectionJSON.key) {
    279 				collection.key = collectionJSON.key;
    280 				await collection.loadPrimaryData();
    281 			}
    282 		}
    283 		
    284 		// Remove external ids before saving
    285 		let toSave = Object.assign({}, collectionJSON);
    286 		delete toSave.folderID;
    287 		delete toSave.remoteUUID;
    288 		
    289 		collection.fromJSON(toSave);
    290 		await collection.saveTx({
    291 			skipSelect: true
    292 		});
    293 	}
    294 };
    295 
    296 
    297 Zotero_Import_Mendeley.prototype._findExistingCollection = function (libraryID, collectionJSON, parentCollection) {
    298 	// Don't use existing collections if the import is creating a top-level collection
    299 	if (this.createNewCollection || !collectionJSON.relations) {
    300 		return false;
    301 	}
    302 	
    303 	var predicate = 'mendeleyDB:remoteFolderUUID';
    304 	var uuid = collectionJSON.relations[predicate];
    305 	
    306 	var collections = Zotero.Relations.getByPredicateAndObject('collection', predicate, uuid)
    307 		.filter((c) => {
    308 			if (c.libraryID != libraryID) {
    309 				return false;
    310 			}
    311 			// If there's a parent collection it has to be the one we've already used
    312 			return parentCollection ? c.parentID == parentCollection.id : true;
    313 		});
    314 	if (!collections.length) {
    315 		return false;
    316 	}
    317 	
    318 	Zotero.debug(`Found existing collection ${collections[0].libraryKey} for `
    319 		+ `${predicate} ${collectionJSON.relations[predicate]}`);
    320 	return collections[0];
    321 }
    322 
    323 
    324 //
    325 // Items
    326 //
    327 Zotero_Import_Mendeley.prototype._getDocuments = async function (groupID) {
    328 	return this._db.queryAsync(
    329 		`SELECT D.*, RD.remoteUuid FROM Documents D `
    330 			+ `JOIN RemoteDocuments RD ON (D.id=RD.documentId) `
    331 			+ `WHERE groupId=? AND inTrash='false'`,
    332 		groupID
    333 	);
    334 };
    335 
    336 /**
    337  * Get a Map of document ids to arrays of URLs
    338  *
    339  * @return {Map<Number,String[]>}
    340  */
    341 Zotero_Import_Mendeley.prototype._getDocumentURLs = async function (groupID) {
    342 	var rows = await this._db.queryAsync(
    343 		`SELECT documentId, CAST(url AS TEXT) AS url FROM DocumentUrls DU `
    344 			+ `JOIN RemoteDocuments USING (documentId) `
    345 			+ `WHERE groupId=? ORDER BY position`,
    346 		groupID
    347 	);
    348 	var map = new Map();
    349 	for (let row of rows) {
    350 		let docURLs = map.get(row.documentId);
    351 		if (!docURLs) docURLs = [];
    352 		docURLs.push(row.url);
    353 		map.set(row.documentId, docURLs);
    354 	}
    355 	return map;
    356 };
    357 
    358 /**
    359  * Get a Map of document ids to arrays of creator API JSON
    360  *
    361  * @param {Integer} groupID
    362  * @param {Object} creatorTypeMap - Mapping of Mendeley creator types to Zotero creator types
    363  */
    364 Zotero_Import_Mendeley.prototype._getDocumentCreators = async function (groupID, creatorTypeMap) {
    365 	var rows = await this._db.queryAsync(
    366 		`SELECT * FROM DocumentContributors `
    367 			+ `JOIN RemoteDocuments USING (documentId) `
    368 			+ `WHERE groupId=?`,
    369 		groupID
    370 	);
    371 	var map = new Map();
    372 	for (let row of rows) {
    373 		let docCreators = map.get(row.documentId);
    374 		if (!docCreators) docCreators = [];
    375 		docCreators.push(this._makeCreator(
    376 			creatorTypeMap[row.contribution] || 'author',
    377 			row.firstNames,
    378 			row.lastName
    379 		));
    380 		map.set(row.documentId, docCreators);
    381 	}
    382 	return map;
    383 };
    384 
    385 /**
    386  * Get a Map of document ids to arrays of tag API JSON
    387  */
    388 Zotero_Import_Mendeley.prototype._getDocumentTags = async function (groupID) {
    389 	var rows = await this._db.queryAsync(
    390 		// Manual tags
    391 		`SELECT documentId, tag, 0 AS type FROM DocumentTags `
    392 			+ `JOIN RemoteDocuments USING (documentId) `
    393 			+ `WHERE groupId=? `
    394 			+ `UNION `
    395 			// Automatic tags
    396 			+ `SELECT documentId, keyword AS tag, 1 AS type FROM DocumentKeywords `
    397 			+ `JOIN RemoteDocuments USING (documentId) `
    398 			+ `WHERE groupId=?`,
    399 		[groupID, groupID]
    400 	);
    401 	var map = new Map();
    402 	for (let row of rows) {
    403 		let docTags = map.get(row.documentId);
    404 		if (!docTags) docTags = [];
    405 		docTags.push({
    406 			tag: row.tag,
    407 			type: row.type
    408 		});
    409 		map.set(row.documentId, docTags);
    410 	}
    411 	return map;
    412 };
    413 
    414 /**
    415  * Get a Map of document ids to arrays of collection keys
    416  */
    417 Zotero_Import_Mendeley.prototype._getDocumentCollections = async function (groupID, documents, rootCollectionKey, folderKeys) {
    418 	var rows = await this._db.queryAsync(
    419 		`SELECT documentId, folderId FROM DocumentFolders DF `
    420 			+ `JOIN RemoteDocuments USING (documentId) `
    421 			+ `WHERE groupId=?`,
    422 		groupID
    423 	);
    424 	var map = new Map(
    425 		// Add all documents to root collection if specified
    426 		documents.map(d => [d.id, rootCollectionKey ? [rootCollectionKey] : []])
    427 	);
    428 	for (let row of rows) {
    429 		let keys = map.get(row.documentId);
    430 		if (!keys) keys = [];
    431 		let key = folderKeys.get(row.folderId);
    432 		if (!key) {
    433 			Zotero.debug(`Document folder ${row.folderId} not found -- skipping`, 2);
    434 			continue;
    435 		}
    436 		keys.push(key);
    437 		map.set(row.documentId, keys);
    438 	}
    439 	return map;
    440 };
    441 
    442 /**
    443  * Get a Map of document ids to arrays of file metadata
    444  *
    445  * @return {Map<Number,Object[]>}
    446  */
    447 Zotero_Import_Mendeley.prototype._getDocumentFiles = async function (groupID) {
    448 	var rows = await this._db.queryAsync(
    449 		`SELECT documentId, hash, localUrl FROM DocumentFiles `
    450 			+ `JOIN Files USING (hash) `
    451 			+ `JOIN RemoteDocuments USING (documentId) `
    452 			+ `WHERE groupId=?`,
    453 		groupID
    454 	);
    455 	var map = new Map();
    456 	for (let row of rows) {
    457 		let docFiles = map.get(row.documentId);
    458 		if (!docFiles) docFiles = [];
    459 		docFiles.push({
    460 			hash: row.hash,
    461 			fileURL: row.localUrl
    462 		});
    463 		map.set(row.documentId, docFiles);
    464 	}
    465 	return map;
    466 };
    467 
    468 /**
    469  * Get a Map of document ids to arrays of annotations
    470  */
    471 Zotero_Import_Mendeley.prototype._getDocumentAnnotations = async function (groupID) {
    472 	var rows = await this._db.queryAsync(
    473 		`SELECT documentId, uuid, fileHash, page, note, color `
    474 			+ `FROM FileNotes `
    475 			+ `JOIN RemoteDocuments USING (documentId) `
    476 			+ `WHERE groupId=? `
    477 			+ `ORDER BY page, y, x`,
    478 		groupID
    479 	);
    480 	var map = new Map();
    481 	for (let row of rows) {
    482 		let docAnnotations = map.get(row.documentId);
    483 		if (!docAnnotations) docAnnotations = [];
    484 		docAnnotations.push({
    485 			uuid: row.uuid,
    486 			hash: row.fileHash,
    487 			note: row.note,
    488 			page: row.page,
    489 			color: row.color
    490 		});
    491 		map.set(row.documentId, docAnnotations);
    492 	}
    493 	return map;
    494 };
    495 
    496 /**
    497  * Create API JSON array with item and any child attachments or notes
    498  */
    499 Zotero_Import_Mendeley.prototype._documentToAPIJSON = async function (map, documentRow, urls, creators, tags, collections, annotations) {
    500 	var parent = {
    501 		key: Zotero.DataObjectUtilities.generateKey()
    502 	};
    503 	var children = [];
    504 	
    505 	parent.itemType = map.itemTypes[documentRow.type];
    506 	if (!parent.itemType) {
    507 		Zotero.warn(`Unmapped item type ${documentRow.type}`);
    508 	}
    509 	if (!parent.itemType || parent.itemType == 'document') {
    510 		parent.itemType = this._guessItemType(documentRow);
    511 		Zotero.debug(`Guessing type ${parent.itemType}`);
    512 	}
    513 	var itemTypeID = Zotero.ItemTypes.getID(parent.itemType);
    514 	
    515 	for (let [mField, zField] of Object.entries(map.fields)) {
    516 		// If not mapped, skip
    517 		if (!zField) {
    518 			continue;
    519 		}
    520 		let val = documentRow[mField];
    521 		// If no value, skip
    522 		if (!val) {
    523 			continue;
    524 		}
    525 		
    526 		if (typeof zField == 'string') {
    527 			this._processField(parent, children, zField, val);
    528 		}
    529 		// Function embedded in map file
    530 		else if (typeof zField == 'function') {
    531 			let [field, val] = zField(documentRow[mField], parent);
    532 			this._processField(parent, children, field, val);
    533 		}
    534 	}
    535 	
    536 	// URLs
    537 	if (urls) {
    538 		for (let i = 0; i < urls.length; i++) {
    539 			let url = urls[i];
    540 			let isPDF = url.includes('pdf');
    541 			if (i == 0 && !isPDF) {
    542 				parent.url = url;
    543 			}
    544 			else {
    545 				children.push({
    546 					itemType: 'attachment',
    547 					parentItem: parent.key,
    548 					linkMode: 'linked_url',
    549 					url,
    550 					title: isPDF ? 'PDF' : '',
    551 					contentType: isPDF ? 'application/pdf' : ''
    552 				});
    553 			}
    554 		}
    555 	}
    556 	
    557 	// Combine date parts if present
    558 	if (documentRow.year) {
    559 		parent.date = documentRow.year.toString().substr(0, 4).padStart(4, '0');
    560 		if (documentRow.month) {
    561 			parent.date += '-' + documentRow.month.toString().substr(0, 2).padStart(2, '0');
    562 			if (documentRow.day) {
    563 				parent.date += '-' + documentRow.day.toString().substr(0, 2).padStart(2, '0');
    564 			}
    565 		}
    566 	}
    567 	
    568 	for (let field in parent) {
    569 		switch (field) {
    570 		case 'itemType':
    571 		case 'key':
    572 		case 'parentItem':
    573 		case 'note':
    574 		case 'creators':
    575 		case 'dateAdded':
    576 		case 'dateModified':
    577 			continue;
    578 		}
    579 		
    580 		// Move unknown/invalid fields to Extra
    581 		let fieldID = Zotero.ItemFields.getID(field)
    582 			&& Zotero.ItemFields.getFieldIDFromTypeAndBase(parent.itemType, field);
    583 		if (!fieldID) {
    584 			Zotero.warn(`Moving '${field}' to Extra for type ${parent.itemType}`);
    585 			parent.extra = this._addExtraField(parent.extra, field, parent[field]);
    586 			delete parent[field];
    587 			continue;
    588 		}
    589 		let newField = Zotero.ItemFields.getName(fieldID);
    590 		if (field != newField) {
    591 			parent[newField] = parent[field];
    592 			delete parent[field];
    593 		}
    594 	}
    595 	
    596 	if (!parent.dateModified) {
    597 		parent.dateModified = parent.dateAdded;
    598 	}
    599 	
    600 	if (creators) {
    601 		// Add main creators before any added by fields (e.g., seriesEditor)
    602 		parent.creators = [...creators, ...(parent.creators || [])];
    603 		
    604 		// If item type has a different primary type, use that for author to prevent a warning
    605 		let primaryCreatorType = Zotero.CreatorTypes.getName(
    606 			Zotero.CreatorTypes.getPrimaryIDForType(itemTypeID)
    607 		);
    608 		if (primaryCreatorType != 'author') {
    609 			for (let creator of parent.creators) {
    610 				if (creator.creatorType == 'author') {
    611 					creator.creatorType = primaryCreatorType;
    612 				}
    613 			}
    614 		}
    615 		
    616 		for (let creator of parent.creators) {
    617 			// seriesEditor isn't valid on some item types (e.g., book)
    618 			if (creator.creatorType == 'seriesEditor'
    619 					&& !Zotero.CreatorTypes.isValidForItemType(
    620 							Zotero.CreatorTypes.getID('seriesEditor'), itemTypeID)) {
    621 				creator.creatorType = 'editor';
    622 			}
    623 		}
    624 	}
    625 	parent.tags = [];
    626 	// Add star tag for favorites
    627 	if (documentRow.favourite == 'true') {
    628 		parent.tags.push('\u2605');
    629 	}
    630 	if (tags) {
    631 		parent.tags.push(...tags);
    632 	}
    633 	if (collections) parent.collections = collections;
    634 	
    635 	// Copy date added/modified to child item
    636 	var parentDateAdded = parent.dateAdded;
    637 	var parentDateModified = parent.dateModified;
    638 	for (let child of children) {
    639 		child.dateAdded = parentDateAdded;
    640 		child.dateModified = parentDateModified;
    641 	}
    642 	
    643 	// Don't set an explicit key if no children
    644 	if (!children.length) {
    645 		delete parent.key;
    646 	}
    647 	
    648 	var documentUUID = documentRow.uuid.replace(/^\{/, '').replace(/\}$/, '');
    649 	parent.relations = {
    650 		'mendeleyDB:documentUUID': documentUUID
    651 	};
    652 	if (documentRow.remoteUuid) {
    653 		parent.relations['mendeleyDB:remoteDocumentUUID'] = documentRow.remoteUuid;
    654 	}
    655 	
    656 	for (let child of children) {
    657 		// Add relation to child note
    658 		if (child.itemType == 'note') {
    659 			child.relations = {
    660 				'mendeleyDB:relatedDocumentUUID': documentUUID
    661 			};
    662 			if (documentRow.remoteUuid) {
    663 				child.relations['mendeleyDB:relatedRemoteDocumentUUID'] = documentRow.remoteUuid;
    664 			}
    665 			break;
    666 		}
    667 	}
    668 	
    669 	parent.documentID = documentRow.id;
    670 	
    671 	var json = [parent, ...children];
    672 	//Zotero.debug(json);
    673 	return json;
    674 };
    675 
    676 /**
    677  * Try to figure out item type based on available fields
    678  */
    679 Zotero_Import_Mendeley.prototype._guessItemType = function (documentRow) {
    680 	if (documentRow.issn || documentRow.issue) {
    681 		return 'journalArticle';
    682 	}
    683 	if (documentRow.isbn) {
    684 		return 'book';
    685 	}
    686 	return 'document';
    687 };
    688 
    689 Zotero_Import_Mendeley.prototype._extractSubfield = function (field) {
    690 	var sub = field.match(/([a-z]+)\[([^\]]+)]/);
    691 	return sub ? { field: sub[1], subfield: sub[2] } : { field };
    692 };
    693 
    694 Zotero_Import_Mendeley.prototype._processField = function (parent, children, zField, val) {
    695 	var { field, subfield } = this._extractSubfield(zField);
    696 	if (subfield) {
    697 		// Combine 'city' and 'country' into 'place'
    698 		if (field == 'place') {
    699 			if (subfield == 'city') {
    700 				parent.place = val + (parent.place ? ', ' + parent.place : '');
    701 			}
    702 			else if (subfield == 'country') {
    703 				parent.place = (parent.place ? ', ' + parent.place : '') + val;
    704 			}
    705 		}
    706 		// Convert some item fields as creators
    707 		else if (field == 'creator') {
    708 			if (!parent.creators) {
    709 				parent.creators = [];
    710 			}
    711 			parent.creators.push(this._makeCreator(subfield, null, val));
    712 		}
    713 		else if (field == 'extra') {
    714 			parent.extra = this._addExtraField(parent.extra, subfield, val);
    715 		}
    716 		// Functions
    717 		else if (field == 'func') {
    718 			// Convert unix timestamps to ISO dates
    719 			if (subfield.startsWith('fromUnixtime')) {
    720 				let [, zField] = subfield.split(':');
    721 				parent[zField] = Zotero.Date.dateToISO(new Date(val));
    722 			}
    723 			// If 'pages' isn't valid for itemType, use 'numPages' instead
    724 			else if (subfield == 'pages') {
    725 				let itemTypeID = Zotero.ItemTypes.getID(parent.itemType);
    726 				if (!Zotero.ItemFields.isValidForType('pages', itemTypeID)
    727 						&& Zotero.ItemFields.isValidForType('numPages', itemTypeID)) {
    728 					zField = 'numPages';
    729 				}
    730 				else {
    731 					zField = 'pages';
    732 				}
    733 				parent[zField] = val;
    734 			}
    735 			// Notes become child items
    736 			else if (subfield == 'note') {
    737 				children.push({
    738 					parentItem: parent.key,
    739 					itemType: 'note',
    740 					note: this._convertNote(val)
    741 				});
    742 			}
    743 			else {
    744 				Zotero.warn(`Unknown function subfield: ${subfield}`);
    745 				return;
    746 			}
    747 		}
    748 		else {
    749 			Zotero.warn(`Unknown field: ${field}[${subfield}]`);
    750 		}
    751 	}
    752 	else {
    753 		// These are added separately so that they're available for notes
    754 		if (zField == 'dateAdded' || zField == 'dateModified') {
    755 			return;
    756 		}
    757 		parent[zField] = val;
    758 	}
    759 };
    760 
    761 Zotero_Import_Mendeley.prototype._makeCreator = function (creatorType, firstName, lastName) {
    762 	var creator = { creatorType };
    763 	if (firstName) {
    764 		creator.firstName = firstName;
    765 		creator.lastName = lastName;
    766 	}
    767 	else {
    768 		creator.name = lastName;
    769 	}
    770 	return creator;
    771 };
    772 
    773 Zotero_Import_Mendeley.prototype._addExtraField = function (extra, field, val) {
    774 	// Strip the field if it appears at the beginning of the value (to avoid "DOI: DOI: 10...")
    775 	if (typeof val == 'string') {
    776 		val = val.replace(new RegExp(`^${field}:\s*`, 'i'), "");
    777 	}
    778 	extra = extra ? extra + '\n' : '';
    779 	if (field != 'arXiv') {
    780 		field = field[0].toUpperCase() + field.substr(1);
    781 		field = field.replace(/([a-z])([A-Z][a-z])/, "$1 $2");
    782 	}
    783 	return extra + `${field}: ${val}`;
    784 };
    785 
    786 Zotero_Import_Mendeley.prototype._convertNote = function (note) {
    787 	return note
    788 		// Add newlines after <br>
    789 		.replace(/<br\s*\/>/g, '<br\/>\n')
    790 		//
    791 		// Legacy pre-HTML stuff
    792 		//
    793 		// <m:linebreak>
    794 		.replace(/<m:linebreak><\/m:linebreak>/g, '<br/>')
    795 		// <m:bold>
    796 		.replace(/<(\/)?m:bold>/g, '<$1b>')
    797 		// <m:italic>
    798 		.replace(/<(\/)?m:italic>/g, '<$1i>')
    799 		// <m:center>
    800 		.replace(/<m:center>/g, '<p style="text-align: center;">')
    801 		.replace(/<\/m:center>/g, '</p>')
    802 		// <m:underline>
    803 		.replace(/<m:underline>/g, '<span style="text-decoration: underline;">')
    804 		.replace(/<\/m:underline>/g, '</span>');
    805 };
    806 
    807 Zotero_Import_Mendeley.prototype._saveItems = async function (libraryID, json) {
    808 	var idMap = new Map();
    809 	
    810 	var lastExistingParentItem;
    811 	for (let i = 0; i < json.length; i++) {
    812 		let itemJSON = json[i];
    813 		
    814 		// Check if the item has been previously imported
    815 		let item = this._findExistingItem(libraryID, itemJSON, lastExistingParentItem);
    816 		if (item) {
    817 			if (item.isRegularItem()) {
    818 				lastExistingParentItem = item;
    819 				
    820 				// Update any child items to point to the existing item's key instead of the
    821 				// new generated one
    822 				this._updateParentKeys('item', json, i + 1, itemJSON.key, item.key);
    823 				
    824 				// Leave item in any collections it's in
    825 				itemJSON.collections = item.getCollections()
    826 					.map(id => Zotero.Collections.getLibraryAndKeyFromID(id).key)
    827 					.concat(itemJSON.collections || []);
    828 			}
    829 		}
    830 		else {
    831 			lastExistingParentItem = null;
    832 			
    833 			item = new Zotero.Item;
    834 			item.libraryID = libraryID;
    835 			if (itemJSON.key) {
    836 				item.key = itemJSON.key;
    837 				await item.loadPrimaryData();
    838 			}
    839 		}
    840 		
    841 		// Remove external id before save
    842 		let toSave = Object.assign({}, itemJSON);
    843 		delete toSave.documentID;
    844 		
    845 		item.fromJSON(toSave);
    846 		await item.saveTx({
    847 			skipSelect: true,
    848 			skipDateModifiedUpdate: true
    849 		});
    850 		if (itemJSON.documentID) {
    851 			idMap.set(itemJSON.documentID, item.id);
    852 		}
    853 	}
    854 	return idMap;
    855 };
    856 
    857 
    858 Zotero_Import_Mendeley.prototype._findExistingItem = function (libraryID, itemJSON, existingParentItem) {
    859 	var predicate;
    860 	
    861 	//
    862 	// Child item
    863 	//
    864 	if (existingParentItem) {
    865 		if (itemJSON.itemType == 'note') {
    866 			if (!itemJSON.relations) {
    867 				return false;
    868 			}
    869 			
    870 			// Main note
    871 			let parentUUID = itemJSON.relations['mendeleyDB:relatedDocumentUUID'];
    872 			let parentRemoteUUID = itemJSON.relations['mendeleyDB:relatedRemoteDocumentUUID'];
    873 			if (parentUUID) {
    874 				let notes = existingParentItem.getNotes().map(id => Zotero.Items.get(id));
    875 				for (let note of notes) {
    876 					predicate = 'mendeleyDB:relatedDocumentUUID';
    877 					let rels = note.getRelationsByPredicate(predicate);
    878 					if (rels.length && rels[0] == parentUUID) {
    879 						Zotero.debug(`Found existing item ${note.libraryKey} for `
    880 								+ `${predicate} ${parentUUID}`);
    881 						return note;
    882 					}
    883 					if (parentRemoteUUID) {
    884 						predicate = 'mendeleyDB:relatedRemoteDocumentUUID';
    885 						rels = note.getRelationsByPredicate(predicate);
    886 						if (rels.length && rels[0] == parentRemoteUUID) {
    887 							Zotero.debug(`Found existing item ${note.libraryKey} for `
    888 								+ `${predicate} ${parentRemoteUUID}`);
    889 							return note;
    890 						}
    891 					}
    892 				}
    893 				return false;
    894 			}
    895 		}
    896 		else if (itemJSON.itemType == 'attachment') {
    897 			// Linked-URL attachments (other attachments are handled in _saveFilesAndAnnotations())
    898 			if (itemJSON.linkMode == 'linked_url') {
    899 				let attachments = existingParentItem.getAttachments().map(id => Zotero.Items.get(id));
    900 				for (let attachment of attachments) {
    901 					if (attachment.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL
    902 							&& attachment.getField('url') == itemJSON.url) {
    903 						Zotero.debug(`Found existing link attachment ${attachment.libraryKey}`);
    904 						return attachment;
    905 					}
    906 				}
    907 			}
    908 		}
    909 		
    910 		return false;
    911 	}
    912 	
    913 	//
    914 	// Parent item
    915 	//
    916 	if (!itemJSON.relations) {
    917 		return false;
    918 	}
    919 	var existingItem;
    920 	predicate = 'mendeleyDB:documentUUID';
    921 	if (itemJSON.relations[predicate]) {
    922 		existingItem = this._getItemByRelation(
    923 			libraryID,
    924 			predicate,
    925 			itemJSON.relations[predicate]
    926 		);
    927 	}
    928 	if (!existingItem) {
    929 		predicate = 'mendeleyDB:remoteDocumentUUID';
    930 		if (itemJSON.relations[predicate]) {
    931 			existingItem = this._getItemByRelation(
    932 				libraryID,
    933 				predicate,
    934 				itemJSON.relations[predicate]
    935 			);
    936 		}
    937 	}
    938 	// If not found or in trash
    939 	if (!existingItem) {
    940 		return false;
    941 	}
    942 	Zotero.debug(`Found existing item ${existingItem.libraryKey} for `
    943 		+ `${predicate} ${itemJSON.relations[predicate]}`);
    944 	return existingItem;
    945 }
    946 
    947 
    948 Zotero_Import_Mendeley.prototype._getItemByRelation = function (libraryID, predicate, object) {
    949 	var items = Zotero.Relations.getByPredicateAndObject('item', predicate, object)
    950 		.filter(item => item.libraryID == libraryID && !item.deleted);
    951 	if (!items.length) {
    952 		return false;
    953 	}
    954 	return items[0];
    955 };
    956 
    957 
    958 /**
    959  * Saves attachments and extracted annotations for a given document
    960  */
    961 Zotero_Import_Mendeley.prototype._saveFilesAndAnnotations = async function (files, libraryID, parentItemID, annotations) {
    962 	for (let file of files) {
    963 		try {
    964 			if (!file.fileURL) continue;
    965 			
    966 			let path = OS.Path.fromFileURI(file.fileURL);
    967 			let realPath = await this._getRealFilePath(path);
    968 			
    969 			let attachment;
    970 			if (realPath) {
    971 				if (this._findExistingFile(parentItemID, file)) {
    972 					continue;
    973 				}
    974 				
    975 				let options = {
    976 					libraryID,
    977 					parentItemID,
    978 					file: realPath
    979 				};
    980 				// If file is in Mendeley downloads folder, import it
    981 				if (this._isDownloadedFile(path)) {
    982 					if (file.url) {
    983 						options.title = file.title;
    984 						options.url = file.url;
    985 						options.contentType = file.contentType;
    986 						options.singleFile = true;
    987 						attachment = await Zotero.Attachments.importSnapshotFromFile(options);
    988 					}
    989 					else {
    990 						attachment = await Zotero.Attachments.importFromFile(options);
    991 					}
    992 				}
    993 				// Otherwise link it
    994 				else {
    995 					attachment = await Zotero.Attachments.linkFromFile(options);
    996 				}
    997 				attachment.setRelations({
    998 					'mendeleyDB:fileHash': file.hash
    999 				});
   1000 				await attachment.saveTx({
   1001 					skipSelect: true
   1002 				});
   1003 			}
   1004 			else {
   1005 				Zotero.warn(path + " not found -- not importing");
   1006 			}
   1007 			
   1008 			if (annotations) {
   1009 				await this._saveAnnotations(
   1010 					// We have annotations from all files for this document, so limit to just those on
   1011 					// this file
   1012 					annotations.filter(a => a.hash == file.hash),
   1013 					parentItemID,
   1014 					attachment ? attachment.id : null,
   1015 					file.hash
   1016 				);
   1017 			}
   1018 		}
   1019 		catch (e) {
   1020 			Zotero.logError(e);
   1021 		}
   1022 	}
   1023 }
   1024 
   1025 
   1026 Zotero_Import_Mendeley.prototype._findExistingFile = function (parentItemID, file) {
   1027 	var item = Zotero.Items.get(parentItemID);
   1028 	var attachmentIDs = item.getAttachments();
   1029 	for (let attachmentID of attachmentIDs) {
   1030 		let attachment = Zotero.Items.get(attachmentID);
   1031 		let predicate = 'mendeleyDB:fileHash';
   1032 		let rels = attachment.getRelationsByPredicate(predicate);
   1033 		if (rels.includes(file.hash)) {
   1034 			Zotero.debug(`Found existing file ${attachment.libraryKey} for `
   1035 				+ `${predicate} ${file.hash}`);
   1036 			return attachment;
   1037 		}
   1038 	}
   1039 	return false;
   1040 }
   1041 
   1042 Zotero_Import_Mendeley.prototype._isDownloadedFile = function (path) {
   1043 	var parentDir = OS.Path.dirname(path);
   1044 	return parentDir.endsWith(OS.Path.join('Application Support', 'Mendeley Desktop', 'Downloaded'))
   1045 		|| parentDir.endsWith(OS.Path.join('Local', 'Mendeley Ltd', 'Mendeley Desktop', 'Downloaded'))
   1046 		|| parentDir.endsWith(OS.Path.join('data', 'Mendeley Ltd.', 'Mendeley Desktop', 'Downloaded'));
   1047 }
   1048 
   1049 /**
   1050  * Get the path to use for a file that exists, or false if none
   1051  *
   1052  * This can be either the original path or, for a file in the Downloaded directory, in a directory
   1053  * relative to the database.
   1054  *
   1055  * @return {String|false}
   1056  */
   1057 Zotero_Import_Mendeley.prototype._getRealFilePath = async function (path) {
   1058 	if (await OS.File.exists(path)) {
   1059 		return path;
   1060 	}
   1061 	var isDownloadedFile = this._isDownloadedFile(path);
   1062 	if (!isDownloadedFile) {
   1063 		return false;
   1064 	}
   1065 	// For file paths in Downloaded folder, try relative to database if not found at the
   1066 	// absolute location, in case this is a DB backup
   1067 	var dataDir = OS.Path.dirname(this._file);
   1068 	var altPath = OS.Path.join(dataDir, 'Downloaded', OS.Path.basename(path));
   1069 	if (altPath != path && await OS.File.exists(altPath)) {
   1070 		return altPath;
   1071 	}
   1072 	return false;
   1073 }
   1074 
   1075 Zotero_Import_Mendeley.prototype._saveAnnotations = async function (annotations, parentItemID, attachmentItemID, fileHash) {
   1076 	if (!annotations.length) return;
   1077 	var noteStrings = [];
   1078 	var parentItem = Zotero.Items.get(parentItemID);
   1079 	var libraryID = parentItem.libraryID;
   1080 	if (attachmentItemID) {
   1081 		var attachmentItem = Zotero.Items.get(attachmentItemID);
   1082 		var attachmentURIPath = Zotero.API.getLibraryPrefix(libraryID) + '/items/' + attachmentItem.key;
   1083 	}
   1084 	
   1085 	for (let annotation of annotations) {
   1086 		if (!annotation.note || !annotation.note.trim()) continue;
   1087 		
   1088 		let linkStr;
   1089 		let linkText = `note on p. ${annotation.page}`;
   1090 		if (attachmentItem) {
   1091 			let url = `zotero://open-pdf/${attachmentURIPath}?page=${annotation.page}`;
   1092 			linkStr = `<a href="${url}">${linkText}</a>`;
   1093 		}
   1094 		else {
   1095 			linkStr = linkText;
   1096 		}
   1097 		
   1098 		noteStrings.push(
   1099 			Zotero.Utilities.text2html(annotation.note.trim())
   1100 				+ `<p class="pdf-link" style="margin-top: -0.5em; margin-bottom: 2em; font-size: .9em; text-align: right;">(${linkStr})</p>`
   1101 		);
   1102 	}
   1103 	
   1104 	if (!noteStrings.length) return;
   1105 	
   1106 	// Look for an existing note
   1107 	var existingNotes = parentItem.getNotes().map(id => Zotero.Items.get(id));
   1108 	var predicate = 'mendeleyDB:relatedFileHash';
   1109 	var note;
   1110 	for (let n of existingNotes) {
   1111 		let rels = n.getRelationsByPredicate(predicate);
   1112 		if (rels.length && rels[0] == fileHash) {
   1113 			Zotero.debug(`Found existing note ${n.libraryKey} for ${predicate} ${fileHash}`);
   1114 			note = n;
   1115 			break;
   1116 		}
   1117 	}
   1118 	// If not found, create new one
   1119 	if (!note) {
   1120 		note = new Zotero.Item('note');
   1121 		note.libraryID = libraryID;
   1122 		note.parentItemID = parentItemID;
   1123 		
   1124 		// Add relation to associated file
   1125 		note.setRelations({
   1126 			'mendeleyDB:relatedFileHash': fileHash
   1127 		});
   1128 	}
   1129 	note.setNote('<h1>' + Zotero.getString('extractedAnnotations') + '</h1>\n' + noteStrings.join('\n'));
   1130 	return note.saveTx({
   1131 		skipSelect: true
   1132 	});
   1133 };
   1134 
   1135 
   1136 Zotero_Import_Mendeley.prototype._updateParentKeys = function (objectType, json, i, oldKey, newKey) {
   1137 	var prop = 'parent' + objectType[0].toUpperCase() + objectType.substr(1);
   1138 	
   1139 	for (; i < json.length; i++) {
   1140 		let x = json[i];
   1141 		if (x[prop] == oldKey) {
   1142 			x[prop] = newKey;
   1143 		}
   1144 		// Child items are grouped together, so we can stop as soon as we stop seeing the prop
   1145 		else if (objectType == 'item') {
   1146 			break;
   1147 		}
   1148 	}
   1149 }
   1150 
   1151 Zotero_Import_Mendeley.prototype._updateItemCollectionKeys = function (json, oldKey, newKey) {
   1152 	for (; i < json.length; i++) {
   1153 		let x = json[i];
   1154 		if (x[prop] == oldKey) {
   1155 			x[prop] = newKey;
   1156 		}
   1157 	}
   1158 }
   1159 
   1160 
   1161 //
   1162 // Clean up extra files created <5.0.51
   1163 //
   1164 Zotero_Import_Mendeley.prototype.hasImportedFiles = async function () {
   1165 	return !!(await Zotero.DB.valueQueryAsync(
   1166 		"SELECT itemID FROM itemRelations JOIN relationPredicates USING (predicateID) "
   1167 			+ "WHERE predicate='mendeleyDB:fileHash' LIMIT 1"
   1168 	));
   1169 };
   1170 
   1171 Zotero_Import_Mendeley.prototype.queueFileCleanup = async function () {
   1172 	await Zotero.DB.queryAsync("INSERT INTO settings VALUES ('mImport', 'cleanup', 1)");
   1173 };
   1174 
   1175 Zotero_Import_Mendeley.prototype.deleteNonPrimaryFiles = async function () {
   1176 	var rows = await Zotero.DB.queryAsync(
   1177 		"SELECT key, path FROM itemRelations "
   1178 			+ "JOIN relationPredicates USING (predicateID) "
   1179 			+ "JOIN items USING (itemID) "
   1180 			+ "JOIN itemAttachments USING (itemID) "
   1181 			+ "WHERE predicate='mendeleyDB:fileHash' AND linkMode=1" // imported_url
   1182 	);
   1183 	for (let row of rows) {
   1184 		let dir = (Zotero.Attachments.getStorageDirectoryByLibraryAndKey(1, row.key)).path;
   1185 		if (!row.path.startsWith('storage:')) {
   1186 			Zotero.logError(row.path + " does not start with 'storage:'");
   1187 			continue;
   1188 		}
   1189 		let filename = row.path.substr(8);
   1190 		
   1191 		Zotero.debug(`Checking for extra files in ${dir}`);
   1192 		await Zotero.File.iterateDirectory(dir, function* (iterator) {
   1193 			while (true) {
   1194 				let entry = yield iterator.next();
   1195 				if (entry.name.startsWith('.zotero') || entry.name == filename) {
   1196 					continue;
   1197 				}
   1198 				Zotero.debug(`Deleting ${entry.path}`);
   1199 				try {
   1200 					yield OS.File.remove(entry.path);
   1201 				}
   1202 				catch (e) {
   1203 					Zotero.logError(e);
   1204 				}
   1205 			}
   1206 		});
   1207 	}
   1208 	
   1209 	await Zotero.DB.queryAsync("DELETE FROM settings WHERE setting='mImport' AND key='cleanup'");
   1210 };