www

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

item.js (125952B)


      1 /*
      2     ***** BEGIN LICENSE BLOCK *****
      3     
      4     Copyright © 2009 Center for History and New Media
      5                      George Mason University, Fairfax, Virginia, USA
      6                      http://zotero.org
      7     
      8     This file is part of Zotero.
      9     
     10     Zotero is free software: you can redistribute it and/or modify
     11     it under the terms of the GNU Affero General Public License as published by
     12     the Free Software Foundation, either version 3 of the License, or
     13     (at your option) any later version.
     14     
     15     Zotero is distributed in the hope that it will be useful,
     16     but WITHOUT ANY WARRANTY; without even the implied warranty of
     17     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     18     GNU Affero General Public License for more details.
     19     
     20     You should have received a copy of the GNU Affero General Public License
     21     along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
     22     
     23     ***** END LICENSE BLOCK *****
     24 */
     25 
     26 
     27 /*
     28  * Constructor for Item object
     29  */
     30 Zotero.Item = function(itemTypeOrID) {
     31 	if (arguments[1] || arguments[2]) {
     32 		throw ("Zotero.Item constructor only takes one parameter");
     33 	}
     34 	
     35 	Zotero.Item._super.apply(this);
     36 	
     37 	this._disabled = false;
     38 	
     39 	// loadPrimaryData (additional properties in dataObject.js)
     40 	this._itemTypeID = null;
     41 	this._firstCreator = null;
     42 	this._sortCreator = null;
     43 	this._attachmentCharset = null;
     44 	this._attachmentLinkMode = null;
     45 	this._attachmentContentType = null;
     46 	this._attachmentPath = null;
     47 	this._attachmentSyncState = 0;
     48 	this._attachmentSyncedModificationTime = null;
     49 	this._attachmentSyncedHash = null;
     50 	
     51 	// loadCreators
     52 	this._creators = [];
     53 	this._creatorIDs = [];
     54 	
     55 	// loadItemData
     56 	this._itemData = null;
     57 	this._noteTitle = null;
     58 	this._noteText = null;
     59 	this._displayTitle = null;
     60 	
     61 	// loadChildItems
     62 	this._attachments = null;
     63 	this._notes = null;
     64 	
     65 	this._tags = [];
     66 	this._collections = [];
     67 	
     68 	this._bestAttachmentState = null;
     69 	this._fileExists = null;
     70 	
     71 	this._deleted = null;
     72 	this._hasNote = null;
     73 	
     74 	this._noteAccessTime = null;
     75 	
     76 	if (itemTypeOrID) {
     77 		// setType initializes type-specific properties in this._itemData
     78 		this.setType(Zotero.ItemTypes.getID(itemTypeOrID));
     79 	}
     80 }
     81 
     82 Zotero.extendClass(Zotero.DataObject, Zotero.Item);
     83 
     84 Zotero.Item.prototype._objectType = 'item';
     85 Zotero.defineProperty(Zotero.Item.prototype, 'ContainerObjectsClass', {
     86 	get: function() { return Zotero.Collections; }
     87 });
     88 
     89 Zotero.Item.prototype._dataTypes = Zotero.Item._super.prototype._dataTypes.concat([
     90 	'creators',
     91 	'itemData',
     92 	'note',
     93 	'childItems',
     94 //	'relatedItems', // TODO: remove
     95 	'tags',
     96 	'collections',
     97 	'relations'
     98 ]);
     99 
    100 Zotero.defineProperty(Zotero.Item.prototype, 'id', {
    101 	get: function() { return this._id; },
    102 	set: function(val) { return this.setField('id', val); }
    103 });
    104 Zotero.defineProperty(Zotero.Item.prototype, 'itemID', {
    105 	get: function() {
    106 		Zotero.debug("Item.itemID is deprecated -- use Item.id");
    107 		return this._id;
    108 	},
    109 	enumerable: false
    110 });
    111 Zotero.defineProperty(Zotero.Item.prototype, 'libraryID', {
    112 	get: function() { return this._libraryID; },
    113 	set: function(val) { return this.setField('libraryID', val); }
    114 });
    115 Zotero.defineProperty(Zotero.Item.prototype, 'key', {
    116 	get: function() { return this._key; },
    117 	set: function(val) { return this.setField('key', val); }
    118 });
    119 Zotero.defineProperty(Zotero.Item.prototype, 'itemTypeID', {
    120 	get: function() { return this._itemTypeID; }
    121 });
    122 Zotero.defineProperty(Zotero.Item.prototype, 'dateAdded', {
    123 	get: function() { return this._dateAdded; },
    124 	set: function(val) { return this.setField('dateAdded', val); }
    125 });
    126 Zotero.defineProperty(Zotero.Item.prototype, 'dateModified', {
    127 	get: function() { return this._dateModified; },
    128 	set: function(val) { return this.setField('dateModified', val); }
    129 });
    130 Zotero.defineProperty(Zotero.Item.prototype, 'version', {
    131 	get: function() { return this._version; },
    132 	set: function(val) { return this.setField('version', val); }
    133 });
    134 Zotero.defineProperty(Zotero.Item.prototype, 'synced', {
    135 	get: function() { return this._synced; },
    136 	set: function(val) { return this.setField('synced', val); }
    137 });
    138 
    139 // .parentKey and .parentID defined in dataObject.js, but create aliases
    140 Zotero.defineProperty(Zotero.Item.prototype, 'parentItemID', {
    141 	get: function() { return this.parentID; },
    142 	set: function(val) { return this.parentID = val; }
    143 });
    144 Zotero.defineProperty(Zotero.Item.prototype, 'parentItemKey', {
    145 	get: function() { return this.parentKey; },
    146 	set: function(val) { return this.parentKey = val; }
    147 });
    148 Zotero.defineProperty(Zotero.Item.prototype, 'parentItem', {
    149 	get: function() { return Zotero.Items.get(this.parentID) || undefined; },
    150 });
    151 
    152 
    153 Zotero.defineProperty(Zotero.Item.prototype, 'firstCreator', {
    154 	get: function() { return this._firstCreator; }
    155 });
    156 Zotero.defineProperty(Zotero.Item.prototype, 'sortCreator', {
    157 	get: function() { return this._sortCreator; }
    158 });
    159 Zotero.defineProperty(Zotero.Item.prototype, 'relatedItems', {
    160 	get: function() { return this._getRelatedItems(); }
    161 });
    162 
    163 Zotero.defineProperty(Zotero.Item.prototype, 'treeViewID', {
    164 	get: function () {
    165 		return this.id
    166 	}
    167 });
    168 
    169 Zotero.Item.prototype.getID = function() {
    170 	Zotero.debug('Item.getID() is deprecated -- use Item.id');
    171 	return this._id;
    172 }
    173 
    174 Zotero.Item.prototype.getType = function() {
    175 	Zotero.debug('Item.getType() is deprecated -- use Item.itemTypeID');
    176 	return this._itemTypeID;
    177 }
    178 
    179 Zotero.Item.prototype.isPrimaryField = function (fieldName) {
    180 	Zotero.debug("Zotero.Item.isPrimaryField() is deprecated -- use Zotero.Items.isPrimaryField()");
    181 	return this.ObjectsClass.isPrimaryField(fieldName);
    182 }
    183 
    184 Zotero.Item.prototype._get = function () {
    185 	throw new Error("_get is not valid for items");
    186 }
    187 
    188 Zotero.Item.prototype._set = function () {
    189 	throw new Error("_set is not valid for items");
    190 }
    191 
    192 Zotero.Item.prototype._setParentKey = function() {
    193 	if (!this.isNote() && !this.isAttachment()) {
    194 		throw new Error("_setParentKey() can only be called on items of type 'note' or 'attachment'");
    195 	}
    196 	
    197 	Zotero.Item._super.prototype._setParentKey.apply(this, arguments);
    198 }
    199 
    200 //////////////////////////////////////////////////////////////////////////////
    201 //
    202 // Public Zotero.Item methods
    203 //
    204 //////////////////////////////////////////////////////////////////////////////
    205 /*
    206  * Retrieves an itemData field value
    207  *
    208  * @param {String|Integer} field fieldID or fieldName
    209  * @param {Boolean} [unformatted] Skip any special processing of DB value
    210  *   (e.g. multipart date field)
    211  * @param {Boolean} includeBaseMapped If true and field is a base field, returns
    212  *   value of type-specific field instead
    213  *   (e.g. 'label' for 'publisher' in 'audioRecording')
    214  * @return {String} Value as string or empty string if value is not present
    215  */
    216 Zotero.Item.prototype.getField = function(field, unformatted, includeBaseMapped) {
    217 	if (field != 'id') this._disabledCheck();
    218 	
    219 	//Zotero.debug('Requesting field ' + field + ' for item ' + this._id, 4);
    220 	
    221 	this._requireData('primaryData');
    222 	
    223 	// TODO: Add sortCreator
    224 	if (field === 'firstCreator' && !this._id) {
    225 		// Hack to get a firstCreator for an unsaved item
    226 		let creatorsData = this.getCreators(true);
    227 		return Zotero.Items.getFirstCreatorFromData(this.itemTypeID, creatorsData);
    228 	} else if (field === 'id' || this.ObjectsClass.isPrimaryField(field)) {
    229 		var privField = '_' + field;
    230 		//Zotero.debug('Returning ' + (this[privField] ? this[privField] : '') + ' (typeof ' + typeof this[privField] + ')');
    231 		return this[privField];
    232 	} else if (field == 'year') {
    233 		return this.getField('date', true, true).substr(0,4);
    234 	}
    235 	
    236 	if (this.isNote()) {
    237 		switch (Zotero.ItemFields.getName(field)) {
    238 			case 'title':
    239 				return this.getNoteTitle();
    240 				
    241 			default:
    242 				return '';
    243 		}
    244 	}
    245 	
    246 	if (includeBaseMapped) {
    247 		var fieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(
    248 			this._itemTypeID, field
    249 		);
    250 	}
    251 	
    252 	if (!fieldID) {
    253 		var fieldID = Zotero.ItemFields.getID(field);
    254 	}
    255 	
    256 	let value = this._itemData[fieldID];
    257 	
    258 	if (value === undefined) {
    259 		//Zotero.debug("Field '" + field + "' doesn't exist for item type " + this._itemTypeID + " in Item.getField()");
    260 		return '';
    261 	}
    262 	
    263 	// If the item is identified (has an id or key), this field has to be populated
    264 	if (this._identified && value === null && !this._loaded.itemData) {
    265 		throw new Zotero.Exception.UnloadedDataException(
    266 			"Item data not loaded and field '" + field + "' not set for item " +  this.libraryKey,
    267 			"itemData"
    268 		);
    269 	}
    270 	
    271 	value = (value !== null && value !== false) ? value : '';
    272 	
    273 	if (!unformatted) {
    274 		// Multipart date fields
    275 		// TEMP - filingDate
    276 		if (Zotero.ItemFields.isFieldOfBase(fieldID, 'date') || field == 'filingDate') {
    277 			value = Zotero.Date.multipartToStr(value);
    278 		}
    279 	}
    280 	//Zotero.debug('Returning ' + value);
    281 	return value;
    282 }
    283 
    284 
    285 /**
    286  * @param	{Boolean}				asNames
    287  * @return	{Integer[]|String[]}
    288  */
    289 Zotero.Item.prototype.getUsedFields = function(asNames) {
    290 	this._requireData('itemData');
    291 	
    292 	return Object.keys(this._itemData)
    293 		.filter(id => this._itemData[id] !== false && this._itemData[id] !== null)
    294 		.map(id => asNames ? Zotero.ItemFields.getName(id) : parseInt(id));
    295 };
    296 
    297 
    298 
    299 /*
    300  * Populate basic item data from a database row
    301  */
    302 Zotero.Item.prototype.loadFromRow = function(row, reload) {
    303 	// If necessary or reloading, set the type and reinitialize this._itemData
    304 	if (reload || (!this._itemTypeID && row.itemTypeID)) {
    305 		this.setType(row.itemTypeID, true);
    306 	}
    307 	
    308 	this._parseRowData(row);
    309 	this._finalizeLoadFromRow(row);
    310 }
    311 
    312 Zotero.Item.prototype._parseRowData = function(row) {
    313 	var primaryFields = this.ObjectsClass.primaryFields;
    314 	for (let i=0; i<primaryFields.length; i++) {
    315 		let col = primaryFields[i];
    316 		
    317 		try {
    318 			var val = row[col];
    319 		}
    320 		catch (e) {
    321 			Zotero.debug('Skipping missing field ' + col);
    322 			continue;
    323 		}
    324 		
    325 		//Zotero.debug("Setting field '" + col + "' to '" + val + "' for item " + this.id);
    326 		
    327 		switch (col) {
    328 			// Unchanged
    329 			case 'libraryID':
    330 			case 'itemTypeID':
    331 			case 'attachmentSyncState':
    332 			case 'attachmentSyncedHash':
    333 			case 'attachmentSyncedModificationTime':
    334 				break;
    335 			
    336 			case 'itemID':
    337 				col = 'id';
    338 				break;
    339 			
    340 			// Integer or 0
    341 			case 'version':
    342 				val = val ? parseInt(val) : 0;
    343 				break;
    344 			
    345 			// Value or false
    346 			case 'parentKey':
    347 				val = val || false;
    348 				break;
    349 			
    350 			// Integer or false if falsy
    351 			case 'parentID':
    352 				val = val ? parseInt(val) : false;
    353 				break;
    354 			
    355 			case 'attachmentLinkMode':
    356 				val = val !== null
    357 					? parseInt(val)
    358 					// Shouldn't happen
    359 					: Zotero.Attachments.LINK_MODE_IMPORTED_URL;
    360 				break;
    361 			
    362 			case 'attachmentPath':
    363 				// Ignore .zotero* files that were relinked before we started blocking them
    364 				if (!val || val.startsWith('.zotero')) {
    365 					val = '';
    366 				}
    367 				break;
    368 			
    369 			// Boolean
    370 			case 'synced':
    371 			case 'deleted':
    372 			case 'inPublications':
    373 				val = !!val;
    374 				break;
    375 				
    376 			default:
    377 				val = val ? val : '';
    378 		}
    379 		
    380 		this['_' + col] = val;
    381 	}
    382 }
    383 
    384 Zotero.Item.prototype._finalizeLoadFromRow = function(row) {
    385 	this._loaded.primaryData = true;
    386 	this._clearChanged('primaryData');
    387 	this._clearChanged('attachmentData');
    388 	this._identified = true;
    389 }
    390 
    391 
    392 /*
    393  * Set or change the item's type
    394  */
    395 Zotero.Item.prototype.setType = function(itemTypeID, loadIn) {
    396 	if (itemTypeID == this._itemTypeID) {
    397 		return true;
    398 	}
    399 	
    400 	// Adjust 'note' data type based on whether the item is an attachment or note
    401 	var isAttachment = Zotero.ItemTypes.getID('attachment') == itemTypeID;
    402 	var isNote = Zotero.ItemTypes.getID('note') == itemTypeID;
    403 	this._skipDataTypeLoad.note = !(isAttachment || isNote);
    404 	
    405 	var oldItemTypeID = this._itemTypeID;
    406 	if (oldItemTypeID) {
    407 		if (loadIn) {
    408 			throw new Error('Cannot change type in loadIn mode');
    409 		}
    410 		
    411 		// Changing the item type can affect fields and creators, so they need to be loaded
    412 		this._requireData('itemData');
    413 		this._requireData('creators');
    414 		
    415 		var copiedFields = [];
    416 		var newNotifierFields = [];
    417 		
    418 		// Special cases handled below
    419 		var bookTypeID = Zotero.ItemTypes.getID('book');
    420 		var bookSectionTypeID = Zotero.ItemTypes.getID('bookSection');
    421 		
    422 		var obsoleteFields = this.getFieldsNotInType(itemTypeID);
    423 		if (obsoleteFields) {
    424 			// Move bookTitle to title and clear short title when going from
    425 			// bookSection to book if there's not also a title
    426 			if (oldItemTypeID == bookSectionTypeID && itemTypeID == bookTypeID) {
    427 				var titleFieldID = Zotero.ItemFields.getID('title');
    428 				var bookTitleFieldID = Zotero.ItemFields.getID('bookTitle');
    429 				var shortTitleFieldID = Zotero.ItemFields.getID('shortTitle');
    430 				if (this._itemData[bookTitleFieldID] && !this._itemData[titleFieldID]) {
    431 					copiedFields.push([titleFieldID, this._itemData[bookTitleFieldID]]);
    432 					newNotifierFields.push(titleFieldID);
    433 					if (this._itemData[shortTitleFieldID]) {
    434 						this.setField(shortTitleFieldID, false);
    435 					}
    436 				}
    437 			}
    438 			
    439 			for (let oldFieldID of obsoleteFields) {
    440 				// Try to get a base type for this field
    441 				var baseFieldID =
    442 					Zotero.ItemFields.getBaseIDFromTypeAndField(oldItemTypeID, oldFieldID);
    443 				
    444 				if (baseFieldID) {
    445 					var newFieldID =
    446 						Zotero.ItemFields.getFieldIDFromTypeAndBase(itemTypeID, baseFieldID);
    447 						
    448 					// If so, save value to copy to new field
    449 					if (newFieldID) {
    450 						copiedFields.push([newFieldID, this.getField(oldFieldID)]);
    451 					}
    452 				}
    453 				
    454 				// Clear old field
    455 				/*
    456 				delete this._itemData[oldFieldID];
    457 				if (!this._changed.itemData) {
    458 					this._changed.itemData = {};
    459 				}
    460 				this._changed.itemData[oldFieldID] = true;
    461 				*/
    462 				this.setField(oldFieldID, false);
    463 			}
    464 		}
    465 		
    466 		// Move title to bookTitle and clear shortTitle when going from book to bookSection
    467 		if (oldItemTypeID == bookTypeID && itemTypeID == bookSectionTypeID) {
    468 			var titleFieldID = Zotero.ItemFields.getID('title');
    469 			var bookTitleFieldID = Zotero.ItemFields.getID('bookTitle');
    470 			var shortTitleFieldID = Zotero.ItemFields.getID('shortTitle');
    471 			if (this._itemData[titleFieldID]) {
    472 				copiedFields.push([bookTitleFieldID, this._itemData[titleFieldID]]);
    473 				newNotifierFields.push(bookTitleFieldID);
    474 				this.setField(titleFieldID, false);
    475 			}
    476 			if (this._itemData[shortTitleFieldID]) {
    477 				this.setField(shortTitleFieldID, false);
    478 			}
    479 		}
    480 		
    481 		for (var fieldID in this._itemData) {
    482 			if (this._itemData[fieldID] &&
    483 					(!obsoleteFields || obsoleteFields.indexOf(fieldID) == -1)) {
    484 				copiedFields.push([fieldID, this.getField(fieldID)]);
    485 			}
    486 		}
    487 	}
    488 	
    489 	this._itemTypeID = itemTypeID;
    490 	
    491 	// If there's an existing type
    492 	if (oldItemTypeID) {
    493 		// Reset custom creator types to the default
    494 		let creators = this.getCreators();
    495 		if (creators.length) {
    496 			let removeAll = !Zotero.CreatorTypes.itemTypeHasCreators(itemTypeID);
    497 			for (let i=0; i<creators.length; i++) {
    498 				// Remove all creators if new item type doesn't have any
    499 				if (removeAll) {
    500 					this.removeCreator(i);
    501 					continue;
    502 				}
    503 				
    504 				if (!Zotero.CreatorTypes.isValidForItemType(creators[i].creatorTypeID, itemTypeID)) {
    505 					// Convert existing primary creator type to new item type's
    506 					// primary creator type, or contributor (creatorTypeID 2)
    507 					// if none or not currently primary
    508 					let oldPrimary = Zotero.CreatorTypes.getPrimaryIDForType(oldItemTypeID);
    509 					let newPrimary = false;
    510 					if (oldPrimary == creators[i].creatorTypeID) {
    511 						newPrimary = Zotero.CreatorTypes.getPrimaryIDForType(itemTypeID);
    512 					}
    513 					creators[i].creatorTypeID = newPrimary ? newPrimary : 2;
    514 					
    515 					this.setCreator(i, creators[i]);
    516 				}
    517 			}
    518 		}
    519 	}
    520 	
    521 	// Initialize this._itemData with type-specific fields
    522 	this._itemData = {};
    523 	var fields = Zotero.ItemFields.getItemTypeFields(itemTypeID);
    524 	for (let fieldID of fields) {
    525 		this._itemData[fieldID] = null;
    526 	}
    527 	
    528 	// DEBUG: clear change item data?
    529 	
    530 	if (copiedFields) {
    531 		for (let f of copiedFields) {
    532 			// For fields that we moved to different fields in the new type
    533 			// (e.g., book -> bookTitle), mark the old value as explicitly
    534 			// false in previousData (since otherwise it would be null)
    535 			if (newNotifierFields.indexOf(f[0]) != -1) {
    536 				this._markFieldChange(Zotero.ItemFields.getName(f[0]), false);
    537 				this.setField(f[0], f[1]);
    538 			}
    539 			// For fields that haven't changed, clear from previousData
    540 			// after setting
    541 			else {
    542 				this.setField(f[0], f[1]);
    543 				this._clearFieldChange(Zotero.ItemFields.getName(f[0]));
    544 			}
    545 		}
    546 	}
    547 	
    548 	if (loadIn) {
    549 		this._loaded['itemData'] = false;
    550 	}
    551 	else {
    552 		if (oldItemTypeID) {
    553 			this._markFieldChange('itemType', Zotero.ItemTypes.getName(oldItemTypeID));
    554 		}
    555 		if (!this._changed.primaryData) {
    556 			this._changed.primaryData = {};
    557 		}
    558 		this._changed.primaryData.itemTypeID = true;
    559 	}
    560 	
    561 	return true;
    562 }
    563 
    564 
    565 /*
    566  * Find existing fields from current type that aren't in another
    567  *
    568  * If _allowBaseConversion_, don't return fields that can be converted
    569  * via base fields (e.g. label => publisher => studio)
    570  */
    571 Zotero.Item.prototype.getFieldsNotInType = function (itemTypeID, allowBaseConversion) {
    572 	var fieldIDs = [];
    573 	
    574 	for (var field in this._itemData) {
    575 		if (this._itemData[field]) {
    576 			var fieldID = Zotero.ItemFields.getID(field);
    577 			if (Zotero.ItemFields.isValidForType(fieldID, itemTypeID)) {
    578 				continue;
    579 			}
    580 			
    581 			if (allowBaseConversion) {
    582 				var baseID = Zotero.ItemFields.getBaseIDFromTypeAndField(this.itemTypeID, field);
    583 				if (baseID) {
    584 					var newFieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(itemTypeID, baseID);
    585 					if (newFieldID) {
    586 						continue;
    587 					}
    588 				}
    589 			}
    590 			
    591 			fieldIDs.push(fieldID);
    592 		}
    593 	}
    594 	/*
    595 	var sql = "SELECT fieldID FROM itemTypeFields WHERE itemTypeID=?1 AND "
    596 		+ "fieldID IN (SELECT fieldID FROM itemData WHERE itemID=?2) AND "
    597 		+ "fieldID NOT IN (SELECT fieldID FROM itemTypeFields WHERE itemTypeID=?3)";
    598 		
    599 	if (allowBaseConversion) {
    600 		// Not the type-specific field for a base field in the new type
    601 		sql += " AND fieldID NOT IN (SELECT fieldID FROM baseFieldMappings "
    602 			+ "WHERE itemTypeID=?1 AND baseFieldID IN "
    603 			+ "(SELECT fieldID FROM itemTypeFields WHERE itemTypeID=?3)) AND ";
    604 		// And not a base field with a type-specific field in the new type
    605 		sql += "fieldID NOT IN (SELECT baseFieldID FROM baseFieldMappings "
    606 			+ "WHERE itemTypeID=?3) AND ";
    607 		// And not the type-specific field for a base field that has
    608 		// a type-specific field in the new type
    609 		sql += "fieldID NOT IN (SELECT fieldID FROM baseFieldMappings "
    610 			+ "WHERE itemTypeID=?1 AND baseFieldID IN "
    611 			+ "(SELECT baseFieldID FROM baseFieldMappings WHERE itemTypeID=?3))";
    612 	}
    613 	
    614 	return Zotero.DB.columnQuery(sql, [this.itemTypeID, this.id, { int: itemTypeID }]);
    615 	*/
    616 	if (!fieldIDs.length) {
    617 		return false;
    618 	}
    619 	
    620 	return fieldIDs;
    621 }
    622 
    623 
    624 /*
    625  * Set a field value, loading existing itemData first if necessary
    626  *
    627  * Field can be passed as fieldID or fieldName
    628  */
    629 Zotero.Item.prototype.setField = function(field, value, loadIn) {
    630 	this._disabledCheck();
    631 	
    632 	if (value === undefined) {
    633 		throw new Error(`'${field}' value cannot be undefined`);
    634 	}
    635 	
    636 	// Normalize values
    637 	if (typeof value == 'number') {
    638 		value = "" + value;
    639 	}
    640 	else if (typeof value == 'string') {
    641 		value = value.trim().normalize();
    642 	}
    643 	if (value === "" || value === null || value === false) {
    644 		value = false;
    645 	}
    646 	
    647 	//Zotero.debug("Setting field '" + field + "' to '" + value + "' (loadIn: " + (loadIn ? 'true' : 'false') + ") for item " + this.id + " ");
    648 	
    649 	if (!field) {
    650 		throw new Error("Field not specified");
    651 	}
    652 	
    653 	if (field == 'id' || field == 'libraryID' || field == 'key') {
    654 		return this._setIdentifier(field, value);
    655 	}
    656 	
    657 	// Primary field
    658 	if (this.ObjectsClass.isPrimaryField(field)) {
    659 		this._requireData('primaryData');
    660 		
    661 		if (loadIn) {
    662 			throw new Error('Cannot set primary field ' + field + ' in loadIn mode in Zotero.Item.setField()');
    663 		}
    664 		
    665 		switch (field) {
    666 			case 'itemTypeID':
    667 				break;
    668 			
    669 			case 'dateAdded':
    670 			case 'dateModified':
    671 				// Accept ISO dates
    672 				if (Zotero.Date.isISODate(value)) {
    673 					let d = Zotero.Date.isoToDate(value);
    674 					value = Zotero.Date.dateToSQL(d, true);
    675 				}
    676 				
    677 				// Make sure it's valid
    678 				let date = Zotero.Date.sqlToDate(value, true);
    679 				if (!date) throw new Error("Invalid SQL date: " + value);
    680 				
    681 				value = Zotero.Date.dateToSQL(date, true);
    682 				break;
    683 			
    684 			case 'version':
    685 				value = parseInt(value);
    686 				break;
    687 			
    688 			case 'synced':
    689 				value = !!value;
    690 				break;
    691 			
    692 			default:
    693 				throw new Error('Primary field ' + field + ' cannot be changed in Zotero.Item.setField()');
    694 			
    695 		}
    696 		
    697 		/*
    698 		if (!Zotero.ItemFields.validate(field, value)) {
    699 			throw("Value '" + value + "' of type " + typeof value + " does not validate for field '" + field + "' in Zotero.Item.setField()");
    700 		}
    701 		*/
    702 		
    703 		// If field value has changed
    704 		if (this['_' + field] === value) {
    705 			if (field == 'synced') {
    706 				Zotero.debug("Setting synced to " + value);
    707 			}
    708 			else {
    709 				Zotero.debug("Field '" + field + "' has not changed", 4);
    710 				return false;
    711 			}
    712 		}
    713 		else {
    714 			Zotero.debug("Field '" + field + "' has changed from '" + this['_' + field] + "' to '" + value + "'", 4);
    715 		}
    716 		
    717 		// Save a copy of the field before modifying
    718 		this._markFieldChange(field, this['_' + field]);
    719 		
    720 		if (field == 'itemTypeID') {
    721 			this.setType(value, loadIn);
    722 		}
    723 		else {
    724 			
    725 			this['_' + field] = value;
    726 			
    727 			if (!this._changed.primaryData) {
    728 				this._changed.primaryData = {};
    729 			}
    730 			this._changed.primaryData[field] = true;
    731 		}
    732 		return true;
    733 	}
    734 	
    735 	if (!loadIn) {
    736 		this._requireData('itemData');
    737 	}
    738 	
    739 	let itemTypeID = this.itemTypeID;
    740 	if (!itemTypeID) {
    741 		throw new Error('Item type must be set before setting field data');
    742 	}
    743 	
    744 	var fieldID = Zotero.ItemFields.getID(field);
    745 	if (!fieldID) {
    746 		throw new Error('"' + field + '" is not a valid itemData field');
    747 	}
    748 	
    749 	if (loadIn && this.isNote() && field == 110) { // title
    750 		this._noteTitle = value ? value : "";
    751 		return true;
    752 	}
    753 	
    754 	// Make sure to use type-specific field ID if available
    755 	fieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(itemTypeID, fieldID) || fieldID;
    756 	
    757 	if (value !== false && !Zotero.ItemFields.isValidForType(fieldID, itemTypeID)) {
    758 		var msg = "'" + field + "' is not a valid field for type " + itemTypeID;
    759 		
    760 		if (loadIn) {
    761 			Zotero.debug(msg + " -- ignoring value '" + value + "'", 2);
    762 			return false;
    763 		}
    764 		else {
    765 			throw new Error(msg);
    766 		}
    767 	}
    768 	
    769 	// If not a multiline field, strip newlines
    770 	if (typeof value == 'string' && !Zotero.ItemFields.isMultiline(fieldID)) {
    771 		value = value.replace(/[\r\n]+/g, " ");;
    772 	}
    773 	
    774 	if (fieldID == Zotero.ItemFields.getID('ISBN')) {
    775 		// Hyphenate ISBNs, but only if everything is in expected format and valid
    776 		let isbns = ('' + value).trim().split(/\s*[,;]\s*|\s+/),
    777 			newISBNs = '',
    778 			failed = false;
    779 		for (let i=0; i<isbns.length; i++) {
    780 			let isbn = Zotero.Utilities.Internal.hyphenateISBN(isbns[i]);
    781 			if (!isbn) {
    782 				failed = true;
    783 				break;
    784 			}
    785 			
    786 			newISBNs += ' ' + isbn;
    787 		}
    788 		
    789 		if (!failed) value = newISBNs.substr(1);
    790 	}
    791 	
    792 	if (!loadIn) {
    793 		// Save date field as multipart date
    794 		// TEMP - filingDate
    795 		if (value !== false
    796 				&& (Zotero.ItemFields.isFieldOfBase(fieldID, 'date') || field == 'filingDate')
    797 				&& !Zotero.Date.isMultipart(value)) {
    798 			value = Zotero.Date.strToMultipart(value);
    799 		}
    800 		// Validate access date
    801 		else if (fieldID == Zotero.ItemFields.getID('accessDate')) {
    802 			if (value && value != 'CURRENT_TIMESTAMP') {
    803 				// Accept ISO dates
    804 				if (Zotero.Date.isISODate(value) && !Zotero.Date.isSQLDate(value)) {
    805 					let d = Zotero.Date.isoToDate(value);
    806 					value = Zotero.Date.dateToSQL(d, true);
    807 				}
    808 				
    809 				if (!Zotero.Date.isSQLDate(value) && !Zotero.Date.isSQLDateTime(value)) {
    810 					Zotero.logError(`Discarding invalid ${Zotero.ItemFields.getName(field)} '${value}' `
    811 						+ `for item ${this.libraryKey} in setField()`);
    812 					return false;
    813 				}
    814 			}
    815 		}
    816 		
    817 		// If existing value, make sure it's actually changing
    818 		if ((this._itemData[fieldID] === null && value === false)
    819 				|| (this._itemData[fieldID] !== null && this._itemData[fieldID] === value)) {
    820 			return false;
    821 		}
    822 		
    823 		// Save a copy of the field before modifying
    824 		this._markFieldChange(
    825 			Zotero.ItemFields.getName(field), this._itemData[fieldID]
    826 		);
    827 	}
    828 	
    829 	this._itemData[fieldID] = value;
    830 	
    831 	if (!loadIn) {
    832 		if (!this._changed.itemData) {
    833 			this._changed.itemData = {};
    834 		}
    835 		this._changed.itemData[fieldID] = true;
    836 	}
    837 	return true;
    838 }
    839 
    840 /*
    841  * Get the title for an item for display in the interface
    842  *
    843  * This is the same as the standard title field (with includeBaseMapped on)
    844  * except for letters and interviews, which get placeholder titles in
    845  * square braces (e.g. "[Letter to Thoreau]"), and cases
    846  */
    847 Zotero.Item.prototype.getDisplayTitle = function (includeAuthorAndDate) {
    848 	if (this._displayTitle !== null) {
    849 		return this._displayTitle;
    850 	}
    851 	return this._displayTitle = this.getField('title', false, true);
    852 }
    853 
    854 
    855 /**
    856  * Update the generated display title from the loaded data
    857  */
    858 Zotero.Item.prototype.updateDisplayTitle = function () {
    859 	var title = this.getField('title', false, true);
    860 	var itemTypeID = this.itemTypeID;
    861 	var itemTypeName = Zotero.ItemTypes.getName(itemTypeID);
    862 	
    863 	if (title === "" && (itemTypeID == 8 || itemTypeID == 10)) { // 'letter' and 'interview' itemTypeIDs
    864 		var creatorsData = this.getCreators();
    865 		var authors = [];
    866 		var participants = [];
    867 		for (let i=0; i<creatorsData.length; i++) {
    868 			let creatorData = creatorsData[i];
    869 			let creatorTypeID = creatorsData[i].creatorTypeID;
    870 			if ((itemTypeID == 8 && creatorTypeID == 16) || // 'letter'
    871 					(itemTypeID == 10 && creatorTypeID == 7)) { // 'interview'
    872 				participants.push(creatorData);
    873 			}
    874 			else if ((itemTypeID == 8 && creatorTypeID == 1) ||   // 'letter'/'author'
    875 					(itemTypeID == 10 && creatorTypeID == 6)) { // 'interview'/'interviewee'
    876 				authors.push(creatorData);
    877 			}
    878 		}
    879 		
    880 		var strParts = [];
    881 		if (participants.length > 0) {
    882 			let names = [];
    883 			let max = Math.min(4, participants.length);
    884 			for (let i=0; i<max; i++) {
    885 				names.push(
    886 					participants[i].name !== undefined
    887 						? participants[i].name
    888 						: participants[i].lastName
    889 				);
    890 			}
    891 			switch (names.length) {
    892 				case 1:
    893 					var str = 'oneParticipant';
    894 					break;
    895 					
    896 				case 2:
    897 					var str = 'twoParticipants';
    898 					break;
    899 					
    900 				case 3:
    901 					var str = 'threeParticipants';
    902 					break;
    903 					
    904 				default:
    905 					var str = 'manyParticipants';
    906 			}
    907 			strParts.push(Zotero.getString('pane.items.' + itemTypeName + '.' + str, names));
    908 		}
    909 		else {
    910 			strParts.push(Zotero.ItemTypes.getLocalizedString(itemTypeID));
    911 		}
    912 		
    913 		title = '[' + strParts.join('; ') + ']';
    914 	}
    915 	else if (itemTypeID == 17) { // 'case' itemTypeID
    916 		if (title) { // common law cases always have case names
    917 			var reporter = this.getField('reporter');
    918 			if (reporter) {
    919 				title = title + ' (' + reporter + ')';
    920 			} else {
    921 				var court = this.getField('court');
    922 				if (court) {
    923 					title = title + ' (' + court + ')';
    924 				}
    925 			}
    926 		}
    927 		else { // civil law cases have only shortTitle as case name
    928 			var strParts = [];
    929 			var caseinfo = "";
    930 			
    931 			var part = this.getField('court');
    932 			if (part) {
    933 				strParts.push(part);
    934 			}
    935 			
    936 			part = Zotero.Date.multipartToSQL(this.getField('date', true, true));
    937 			if (part) {
    938 				strParts.push(part);
    939 			}
    940 			
    941 			var creatorData = this.getCreator(0);
    942 			if (creatorData && creatorData.creatorTypeID === 1) { // author
    943 				strParts.push(creatorData.lastName);
    944 			}
    945 			
    946 			title = '[' + strParts.join(', ') + ']';
    947 		}
    948 	}
    949 	
    950 	this._displayTitle = title;
    951 };
    952 
    953 
    954 /*
    955  * Returns the number of creators for this item
    956  */
    957 Zotero.Item.prototype.numCreators = function() {
    958 	this._requireData('creators');
    959 	return this._creators.length;
    960 }
    961 
    962 
    963 Zotero.Item.prototype.hasCreatorAt = function(pos) {
    964 	this._requireData('creators');
    965 	return !!this._creators[pos];
    966 }
    967 
    968 
    969 /**
    970  * @param  {Integer} pos
    971  * @return {Object|Boolean} The internal creator data object at the given position, or FALSE if none
    972  */
    973 Zotero.Item.prototype.getCreator = function (pos) {
    974 	this._requireData('creators');
    975 	if (!this._creators[pos]) {
    976 		return false;
    977 	}
    978 	var creator = {};
    979 	for (let i in this._creators[pos]) {
    980 		creator[i] = this._creators[pos][i];
    981 	}
    982 	return creator;
    983 }
    984 
    985 
    986 /**
    987  * @param  {Integer} pos
    988  * @return {Object|Boolean} The API JSON creator data at the given position, or FALSE if none
    989  */
    990 Zotero.Item.prototype.getCreatorJSON = function (pos) {
    991 	this._requireData('creators');
    992 	return this._creators[pos] ? Zotero.Creators.internalToJSON(this._creators[pos]) : false;
    993 }
    994 
    995 
    996 /**
    997  * Returns creator data in internal format
    998  *
    999  * @return {Array<Object>}  An array of internal creator data objects
   1000  *                          ('firstName', 'lastName', 'fieldMode', 'creatorTypeID')
   1001  */
   1002 Zotero.Item.prototype.getCreators = function () {
   1003 	this._requireData('creators');
   1004 	// Create copies of the creator data objects
   1005 	return this._creators.map(function (data) {
   1006 		var creator = {};
   1007 		for (let i in data) {
   1008 			creator[i] = data[i];
   1009 		}
   1010 		return creator;
   1011 	});
   1012 }
   1013 
   1014 
   1015 /**
   1016  * @return {Array<Object>} An array of creator data objects in API JSON format
   1017  *                         ('firstName'/'lastName' or 'name', 'creatorType')
   1018  */
   1019 Zotero.Item.prototype.getCreatorsJSON = function () {
   1020 	this._requireData('creators');
   1021 	return this._creators.map(data => Zotero.Creators.internalToJSON(data));
   1022 }
   1023 
   1024 
   1025 /**
   1026  * Set or update the creator at the specified position
   1027  *
   1028  * @param {Integer} orderIndex
   1029  * @param {Object} Creator data in internal or API JSON format:
   1030  *                   <ul>
   1031  *                     <li>'name' or 'firstName'/'lastName', or 'firstName'/'lastName'/'fieldMode'</li>
   1032  *                     <li>'creatorType' (can be name or id) or 'creatorTypeID'</li>
   1033  *                   </ul>
   1034  */
   1035 Zotero.Item.prototype.setCreator = function (orderIndex, data) {
   1036 	var itemTypeID = this._itemTypeID;
   1037 	if (!itemTypeID) {
   1038 		throw new Error('Item type must be set before setting creators');
   1039 	}
   1040 	
   1041 	this._requireData('creators');
   1042 	
   1043 	data = Zotero.Creators.cleanData(data);
   1044 	
   1045 	if (data.creatorTypeID === undefined) {
   1046 		throw new Error("Creator data must include a valid 'creatorType' or 'creatorTypeID' property");
   1047 	}
   1048 	
   1049 	// If creatorTypeID isn't valid for this type, use the primary type
   1050 	if (!data.creatorTypeID || !Zotero.CreatorTypes.isValidForItemType(data.creatorTypeID, itemTypeID)) {
   1051 		var msg = "Creator type '" + Zotero.CreatorTypes.getName(data.creatorTypeID) + "' "
   1052 			+ "isn't valid for " + Zotero.ItemTypes.getName(itemTypeID)
   1053 			+ " -- changing to primary creator";
   1054 		Zotero.warn(msg);
   1055 		data.creatorTypeID = Zotero.CreatorTypes.getPrimaryIDForType(itemTypeID);
   1056 	}
   1057 	
   1058 	// If creator at this position hasn't changed, cancel
   1059 	let previousData = this._creators[orderIndex];
   1060 	if (previousData
   1061 			&& previousData.creatorTypeID === data.creatorTypeID
   1062 			&& previousData.fieldMode === data.fieldMode
   1063 			&& previousData.firstName === data.firstName
   1064 			&& previousData.lastName === data.lastName) {
   1065 		Zotero.debug("Creator in position " + orderIndex + " hasn't changed", 4);
   1066 		return false;
   1067 	}
   1068 	
   1069 	// Save copy of old creators for save() and notifier
   1070 	if (!this._changed.creators) {
   1071 		this._changed.creators = {};
   1072 		this._markFieldChange('creators', this._getOldCreators());
   1073 	}
   1074 	this._changed.creators[orderIndex] = true;
   1075 	this._creators[orderIndex] = data;
   1076 	return true;
   1077 }
   1078 
   1079 
   1080 /**
   1081  * @param {Object[]} data - An array of creator data in internal or API JSON format
   1082  */
   1083 Zotero.Item.prototype.setCreators = function (data) {
   1084 	// If empty array, clear all existing creators
   1085 	if (!data.length) {
   1086 		while (this.hasCreatorAt(0)) {
   1087 			this.removeCreator(0);
   1088 		}
   1089 		return;
   1090 	}
   1091 	
   1092 	for (let i = 0; i < data.length; i++) {
   1093 		this.setCreator(i, data[i]);
   1094 	}
   1095 }
   1096 
   1097 
   1098 /*
   1099  * Remove a creator and shift others down
   1100  */
   1101 Zotero.Item.prototype.removeCreator = function(orderIndex, allowMissing) {
   1102 	var creatorData = this.getCreator(orderIndex);
   1103 	if (!creatorData && !allowMissing) {
   1104 		throw new Error('No creator exists at position ' + orderIndex);
   1105 	}
   1106 	
   1107 	// Save copy of old creators for notifier
   1108 	if (!this._changed.creators) {
   1109 		this._changed.creators = {};
   1110 		
   1111 		var oldCreators = this._getOldCreators();
   1112 		this._markFieldChange('creators', oldCreators);
   1113 	}
   1114 	
   1115 	// Shift creator orderIndexes down, going to length+1 so we clear the last one
   1116 	for (var i=orderIndex, max=this._creators.length+1; i<max; i++) {
   1117 		var next = this._creators[i+1] ? this._creators[i+1] : false;
   1118 		if (next) {
   1119 			this._creators[i] = next;
   1120 		}
   1121 		else {
   1122 			this._creators.splice(i, 1);
   1123 		}
   1124 		
   1125 		this._changed.creators[i] = true;
   1126 	}
   1127 	
   1128 	return true;
   1129 }
   1130 
   1131 
   1132 // Define boolean properties
   1133 for (let name of ['deleted', 'inPublications']) {
   1134 	let prop = '_' + name;
   1135 	Zotero.defineProperty(Zotero.Item.prototype, name, {
   1136 		get: function() {
   1137 			if (!this.id) {
   1138 				return false;
   1139 			}
   1140 			if (this[prop] !== null) {
   1141 				return this[prop];
   1142 			}
   1143 			this._requireData('primaryData');
   1144 		},
   1145 		set: function(val) {
   1146 			val = !!val;
   1147 			
   1148 			if (this[prop] == val) {
   1149 				Zotero.debug(Zotero.Utilities.capitalize(name)
   1150 					+ " state hasn't changed for item " + this.id);
   1151 				return;
   1152 			}
   1153 			this._markFieldChange(name, !!this[prop]);
   1154 			this._changed[name] = true;
   1155 			this[prop] = val;
   1156 		}
   1157 	});
   1158 }
   1159 
   1160 
   1161 /**
   1162  * Relate this item to another. A separate save is required.
   1163  *
   1164  * @param {Zotero.Item}
   1165  * @return {Boolean}
   1166  */
   1167 Zotero.Item.prototype.addRelatedItem = function (item) {
   1168 	if (!(item instanceof Zotero.Item)) {
   1169 		throw new Error("'item' must be a Zotero.Item");
   1170 	}
   1171 	
   1172 	if (item == this) {
   1173 		Zotero.debug("Can't relate item to itself in Zotero.Item.addRelatedItem()", 2);
   1174 		return false;
   1175 	}
   1176 	
   1177 	if (!this.libraryID) {
   1178 		this.libraryID = Zotero.Libraries.userLibraryID;
   1179 	}
   1180 	
   1181 	if (item.libraryID != this.libraryID) {
   1182 		throw new Error("Cannot relate item to an item in a different library");
   1183 	}
   1184 	
   1185 	return this.addRelation(Zotero.Relations.relatedItemPredicate, Zotero.URI.getItemURI(item));
   1186 }
   1187 
   1188 
   1189 /**
   1190  * @param {Zotero.Item}
   1191  */
   1192 Zotero.Item.prototype.removeRelatedItem = Zotero.Promise.coroutine(function* (item) {
   1193 	if (!(item instanceof Zotero.Item)) {
   1194 		throw new Error("'item' must be a Zotero.Item");
   1195 	}
   1196 	
   1197 	return this.removeRelation(Zotero.Relations.relatedItemPredicate, Zotero.URI.getItemURI(item));
   1198 });
   1199 
   1200 
   1201 Zotero.Item.prototype.isEditable = function() {
   1202 	var editable = Zotero.Item._super.prototype.isEditable.apply(this);
   1203 	if (!editable) return false;
   1204 	
   1205 	// Check if we're allowed to save attachments
   1206 	if (this.isAttachment()
   1207 		&& (this.attachmentLinkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL ||
   1208 			this.attachmentLinkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE)
   1209 		&& !Zotero.Libraries.get(this.libraryID).filesEditable
   1210 	) {
   1211 		return false;
   1212 	}
   1213 	
   1214 	return true;
   1215 }
   1216 
   1217 Zotero.Item.prototype._initSave = Zotero.Promise.coroutine(function* (env) {
   1218 	if (!this.itemTypeID) {
   1219 		throw new Error("Item type must be set before saving");
   1220 	}
   1221 	return Zotero.Item._super.prototype._initSave.apply(this, arguments);
   1222 })
   1223 
   1224 Zotero.Item.prototype._saveData = Zotero.Promise.coroutine(function* (env) {
   1225 	Zotero.DB.requireTransaction();
   1226 	
   1227 	var isNew = env.isNew;
   1228 	var options = env.options;
   1229 	var libraryType = env.libraryType = Zotero.Libraries.get(env.libraryID).libraryType;
   1230 	
   1231 	var itemTypeID = this.itemTypeID;
   1232 	
   1233 	var reloadParentChildItems = {};
   1234 	
   1235 	//
   1236 	// Primary fields
   1237 	//
   1238 	// If available id value, use it -- otherwise we'll use autoincrement
   1239 	var itemID = this._id = this.id ? this.id : Zotero.ID.get('items');
   1240 	
   1241 	if (this._changed.primaryData && this._changed.primaryData.itemTypeID) {
   1242 		env.sqlColumns.push('itemTypeID');
   1243 		env.sqlValues.push({ int: itemTypeID });
   1244 	}
   1245 	
   1246 	// If a new item and Date Modified hasn't been provided, or an existing item and
   1247 	// Date Modified hasn't changed from its previous value and skipDateModifiedUpdate wasn't
   1248 	// passed, use the current timestamp
   1249 	if (!this.dateModified
   1250 			|| ((!this._changed.primaryData || !this._changed.primaryData.dateModified)
   1251 				&& !options.skipDateModifiedUpdate)) {
   1252 		env.sqlColumns.push('dateModified');
   1253 		env.sqlValues.push(Zotero.DB.transactionDateTime);
   1254 	}
   1255 	// Otherwise, if a new Date Modified was provided, use that. (This would also work when
   1256 	// skipDateModifiedUpdate was passed and there's an existing value, but in that case we
   1257 	// can just not change the field at all.)
   1258 	else if (this._changed.primaryData && this._changed.primaryData.dateModified) {
   1259 		env.sqlColumns.push('dateModified');
   1260 		env.sqlValues.push(this.dateModified);
   1261 	}
   1262 	
   1263 	if (env.sqlColumns.length) {
   1264 		if (isNew) {
   1265 			env.sqlColumns.push('dateAdded');
   1266 			env.sqlValues.push(this.dateAdded ? this.dateAdded : Zotero.DB.transactionDateTime);
   1267 			
   1268 			env.sqlColumns.unshift('itemID');
   1269 			env.sqlValues.unshift(parseInt(itemID));
   1270 			
   1271 			let sql = "INSERT INTO items (" + env.sqlColumns.join(", ") + ") "
   1272 				+ "VALUES (" + env.sqlValues.map(() => "?").join() + ")";
   1273 			yield Zotero.DB.queryAsync(sql, env.sqlValues);
   1274 			
   1275 			if (!env.options.skipNotifier) {
   1276 				Zotero.Notifier.queue('add', 'item', itemID, env.notifierData, env.options.notifierQueue);
   1277 			}
   1278 		}
   1279 		else {
   1280 			let sql = "UPDATE items SET " + env.sqlColumns.join("=?, ") + "=? WHERE itemID=?";
   1281 			env.sqlValues.push(parseInt(itemID));
   1282 			yield Zotero.DB.queryAsync(sql, env.sqlValues);
   1283 			
   1284 			if (!env.options.skipNotifier) {
   1285 				Zotero.Notifier.queue('modify', 'item', itemID, env.notifierData, env.options.notifierQueue);
   1286 			}
   1287 		}
   1288 	}
   1289 	
   1290 	//
   1291 	// ItemData
   1292 	//
   1293 	if (this._changed.itemData) {
   1294 		let del = [];
   1295 		
   1296 		let valueSQL = "SELECT valueID FROM itemDataValues WHERE value=?";
   1297 		let insertValueSQL = "INSERT INTO itemDataValues VALUES (?,?)";
   1298 		let replaceSQL = "REPLACE INTO itemData VALUES (?,?,?)";
   1299 		
   1300 		for (let fieldID in this._changed.itemData) {
   1301 			fieldID = parseInt(fieldID);
   1302 			let value = this.getField(fieldID, true);
   1303 			
   1304 			// If field changed and is empty, mark row for deletion
   1305 			if (value === '') {
   1306 				del.push(fieldID);
   1307 				continue;
   1308 			}
   1309 			
   1310 			if (Zotero.ItemFields.getID('accessDate') == fieldID
   1311 					&& (this.getField(fieldID)) == 'CURRENT_TIMESTAMP') {
   1312 				value = Zotero.DB.transactionDateTime;
   1313 			}
   1314 			
   1315 			let valueID = yield Zotero.DB.valueQueryAsync(valueSQL, [value], { debug: true })
   1316 			if (!valueID) {
   1317 				valueID = Zotero.ID.get('itemDataValues');
   1318 				yield Zotero.DB.queryAsync(insertValueSQL, [valueID, value], { debug: false });
   1319 			}
   1320 			
   1321 			yield Zotero.DB.queryAsync(replaceSQL, [itemID, fieldID, valueID], { debug: false });
   1322 		}
   1323 		
   1324 		// Delete blank fields
   1325 		if (del.length) {
   1326 			sql = 'DELETE from itemData WHERE itemID=? AND '
   1327 				+ 'fieldID IN (' + del.map(() => '?').join() + ')';
   1328 			yield Zotero.DB.queryAsync(sql, [itemID].concat(del));
   1329 		}
   1330 	}
   1331 	
   1332 	//
   1333 	// Creators
   1334 	//
   1335 	if (this._changed.creators) {
   1336 		for (let orderIndex in this._changed.creators) {
   1337 			orderIndex = parseInt(orderIndex);
   1338 			
   1339 			if (isNew) {
   1340 				Zotero.debug('Adding creator in position ' + orderIndex, 4);
   1341 			}
   1342 			else {
   1343 				Zotero.debug('Creator ' + orderIndex + ' has changed', 4);
   1344 			}
   1345 			
   1346 			let creatorData = this.getCreator(orderIndex);
   1347 			// If no creator in this position, just remove the item-creator association
   1348 			if (!creatorData) {
   1349 				let sql = "DELETE FROM itemCreators WHERE itemID=? AND orderIndex=?";
   1350 				yield Zotero.DB.queryAsync(sql, [itemID, orderIndex]);
   1351 				Zotero.Prefs.set('purge.creators', true);
   1352 				continue;
   1353 			}
   1354 			
   1355 			let previousCreatorID = !isNew && this._previousData.creators[orderIndex]
   1356 				? this._previousData.creators[orderIndex].id
   1357 				: false;
   1358 			let newCreatorID = yield Zotero.Creators.getIDFromData(creatorData, true);
   1359 			
   1360 			// If there was previously a creator at this position and it's different from
   1361 			// the new one, the old one might need to be purged.
   1362 			if (previousCreatorID && previousCreatorID != newCreatorID) {
   1363 				Zotero.Prefs.set('purge.creators', true);
   1364 			}
   1365 			
   1366 			let sql = "INSERT OR REPLACE INTO itemCreators "
   1367 				+ "(itemID, creatorID, creatorTypeID, orderIndex) VALUES (?, ?, ?, ?)";
   1368 			yield Zotero.DB.queryAsync(
   1369 				sql,
   1370 				[
   1371 					itemID,
   1372 					newCreatorID,
   1373 					creatorData.creatorTypeID,
   1374 					orderIndex
   1375 				]
   1376 			);
   1377 		}
   1378 	}
   1379 	
   1380 	// Parent item (DB update is done below after collection removals)
   1381 	var parentItemKey = this.parentKey;
   1382 	var parentItemID = parentItemKey
   1383 		? (this.ObjectsClass.getIDFromLibraryAndKey(this.libraryID, parentItemKey) || null)
   1384 		: null;
   1385 	if (this._changed.parentKey) {
   1386 		if (isNew) {
   1387 			if (!parentItemID) {
   1388 				// TODO: clear caches?
   1389 				let msg = "Parent item " + this.libraryID + "/" + parentItemKey + " not found";
   1390 				let e = new Error(msg);
   1391 				e.name = "ZoteroMissingObjectError";
   1392 				throw e;
   1393 			}
   1394 			
   1395 			let newParentItemNotifierData = {};
   1396 			//newParentItemNotifierData[newParentItem.id] = {};
   1397 			if (!env.options.skipNotifier) {
   1398 				Zotero.Notifier.queue(
   1399 					'modify', 'item', parentItemID, newParentItemNotifierData, env.options.notifierQueue
   1400 				);
   1401 			}
   1402 			
   1403 			switch (Zotero.ItemTypes.getName(itemTypeID)) {
   1404 				case 'note':
   1405 				case 'attachment':
   1406 					reloadParentChildItems[parentItemID] = true;
   1407 					break;
   1408 			}
   1409 		}
   1410 		else {
   1411 			if (parentItemKey) {
   1412 				if (!parentItemID) {
   1413 					// TODO: clear caches
   1414 					let msg = "Parent item " + this.libraryID + "/" + parentItemKey + " not found";
   1415 					let e = new Error(msg);
   1416 					e.name = "ZoteroMissingObjectError";
   1417 					throw e;
   1418 				}
   1419 				
   1420 				let newParentItemNotifierData = {};
   1421 				//newParentItemNotifierData[newParentItem.id] = {};
   1422 				if (!env.options.skipNotifier) {
   1423 					Zotero.Notifier.queue(
   1424 						'modify',
   1425 						'item',
   1426 						parentItemID,
   1427 						newParentItemNotifierData,
   1428 						env.options.notifierQueue
   1429 					);
   1430 				}
   1431 			}
   1432 			
   1433 			let oldParentKey = this._previousData.parentKey;
   1434 			let oldParentItemID;
   1435 			if (oldParentKey) {
   1436 				oldParentItemID = this.ObjectsClass.getIDFromLibraryAndKey(this.libraryID, oldParentKey);
   1437 				if (oldParentItemID) {
   1438 					let oldParentItemNotifierData = {};
   1439 					//oldParentItemNotifierData[oldParentItemID] = {};
   1440 					if (!env.options.skipNotifier) {
   1441 						Zotero.Notifier.queue(
   1442 							'modify',
   1443 							'item',
   1444 							oldParentItemID,
   1445 							oldParentItemNotifierData,
   1446 							env.options.notifierQueue
   1447 						);
   1448 					}
   1449 				}
   1450 				else {
   1451 					Zotero.debug("Old source item " + oldParentKey
   1452 						+ " didn't exist in Zotero.Item.save()", 2);
   1453 				}
   1454 			}
   1455 			
   1456 			// If this was an independent item, remove from any collections
   1457 			// where it existed previously and add parent instead
   1458 			if (!oldParentKey) {
   1459 				let sql = "SELECT collectionID FROM collectionItems WHERE itemID=?";
   1460 				let changedCollections = yield Zotero.DB.columnQueryAsync(sql, this.id);
   1461 				if (changedCollections.length) {
   1462 					let parentItem = yield this.ObjectsClass.getByLibraryAndKeyAsync(
   1463 						this.libraryID, parentItemKey
   1464 					);
   1465 					for (let i=0; i<changedCollections.length; i++) {
   1466 						parentItem.addToCollection(changedCollections[i]);
   1467 						this.removeFromCollection(changedCollections[i]);
   1468 						
   1469 						if (!env.options.skipNotifier) {
   1470 							Zotero.Notifier.queue(
   1471 								'remove',
   1472 								'collection-item',
   1473 								changedCollections[i] + '-' + this.id,
   1474 								{},
   1475 								env.options.notifierQueue
   1476 							);
   1477 						}
   1478 					}
   1479 					let parentOptions = {
   1480 						skipDateModifiedUpdate: true
   1481 					};
   1482 					// Apply options (e.g., skipNotifier) from outer save
   1483 					for (let o in env.options) {
   1484 						if (!o.startsWith('skip')) continue;
   1485 						parentOptions[o] = env.options[o];
   1486 					}
   1487 					yield parentItem.save(parentOptions);
   1488 				}
   1489 			}
   1490 			
   1491 			// Update the counts of the previous and new sources
   1492 			if (oldParentItemID) {
   1493 				reloadParentChildItems[oldParentItemID] = true;
   1494 			}
   1495 			if (parentItemID) {
   1496 				reloadParentChildItems[parentItemID] = true;
   1497 			}
   1498 		}
   1499 	}
   1500 	
   1501 	if (this._inPublications) {
   1502 		if (!this.isRegularItem() && !parentItemID) {
   1503 			throw new Error("Top-level attachments and notes cannot be added to My Publications");
   1504 		}
   1505 		if (this.isAttachment() && this.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
   1506 			throw new Error("Linked-file attachments cannot be added to My Publications");
   1507 		}
   1508 		if (Zotero.Libraries.get(this.libraryID).libraryType != 'user') {
   1509 			throw new Error("Only items in user libraries can be added to My Publications");
   1510 		}
   1511 	}
   1512 	
   1513 	// Trashed status
   1514 	if (this._changed.deleted) {
   1515 		if (this._deleted) {
   1516 			sql = "REPLACE INTO deletedItems (itemID) VALUES (?)";
   1517 		}
   1518 		else {
   1519 			// If undeleting, remove any merge-tracking relations
   1520 			let predicate = Zotero.Relations.replacedItemPredicate;
   1521 			let thisURI = Zotero.URI.getItemURI(this);
   1522 			let mergeItems = Zotero.Relations.getByPredicateAndObject(
   1523 				'item', predicate, thisURI
   1524 			);
   1525 			for (let mergeItem of mergeItems) {
   1526 				// An item shouldn't have itself as a dc:replaces relation, but if it does it causes an
   1527 				// infinite loop
   1528 				if (mergeItem.id == this.id) {
   1529 					Zotero.logError(`Item ${this.libraryKey} has itself as a ${predicate} relation`);
   1530 					this.removeRelation(predicate, thisURI);
   1531 					continue;
   1532 				}
   1533 				
   1534 				mergeItem.removeRelation(predicate, thisURI);
   1535 				yield mergeItem.save({
   1536 					skipDateModifiedUpdate: true
   1537 				});
   1538 			}
   1539 			
   1540 			sql = "DELETE FROM deletedItems WHERE itemID=?";
   1541 		}
   1542 		yield Zotero.DB.queryAsync(sql, itemID);
   1543 		
   1544 		// Refresh trash
   1545 		if (!env.options.skipNotifier) {
   1546 			Zotero.Notifier.queue('refresh', 'trash', this.libraryID, {}, env.options.notifierQueue);
   1547 			if (this._deleted) {
   1548 				Zotero.Notifier.queue('trash', 'item', this.id, {}, env.options.notifierQueue);
   1549 			}
   1550 		}
   1551 		
   1552 		if (parentItemID) {
   1553 			reloadParentChildItems[parentItemID] = true;
   1554 		}
   1555 	}
   1556 	
   1557 	if (this._changed.inPublications) {
   1558 		if (this._inPublications) {
   1559 			sql = "INSERT OR IGNORE INTO publicationsItems (itemID) VALUES (?)";
   1560 		}
   1561 		else {
   1562 			sql = "DELETE FROM publicationsItems WHERE itemID=?";
   1563 		}
   1564 		yield Zotero.DB.queryAsync(sql, itemID);
   1565 	}
   1566 	
   1567 	// Collections
   1568 	//
   1569 	// Only diffing and removal are done here. Additions have to be done below after parentItemID has
   1570 	// been updated in itemAttachments/itemNotes, since a child item that was made a standalone item and
   1571 	// added to a collection can't be added to the collection while it still has a parent, and vice
   1572 	// versa, due to the trigger checks on collectionItems/itemAttachments/itemNotes.
   1573 	if (this._changed.collections) {
   1574 		if (libraryType == 'publications') {
   1575 			throw new Error("Items in My Publications cannot be added to collections");
   1576 		}
   1577 		
   1578 		let oldCollections = this._previousData.collections || [];
   1579 		let newCollections = this._collections;
   1580 		
   1581 		let toAdd = Zotero.Utilities.arrayDiff(newCollections, oldCollections);
   1582 		let toRemove = Zotero.Utilities.arrayDiff(oldCollections, newCollections);
   1583 		
   1584 		env.collectionsAdded = toAdd;
   1585 		env.collectionsRemoved = toRemove;
   1586 		
   1587 		if (toRemove.length) {
   1588 			let sql = "DELETE FROM collectionItems WHERE itemID=? AND collectionID IN ("
   1589 				+ toRemove.join(',')
   1590 				+ ")";
   1591 			yield Zotero.DB.queryAsync(sql, this.id);
   1592 			
   1593 			for (let i=0; i<toRemove.length; i++) {
   1594 				let collectionID = toRemove[i];
   1595 				
   1596 				if (!env.options.skipNotifier) {
   1597 					Zotero.Notifier.queue(
   1598 						'remove',
   1599 						'collection-item',
   1600 						collectionID + '-' + this.id,
   1601 						{},
   1602 						env.options.notifierQueue
   1603 					);
   1604 				}
   1605 			}
   1606 			
   1607 			// Remove this item from any loaded collections' cached item lists after commit
   1608 			Zotero.DB.addCurrentCallback("commit", function () {
   1609 				for (let i = 0; i < toRemove.length; i++) {
   1610 					this.ContainerObjectsClass.unregisterChildItem(toRemove[i], this.id);
   1611 				}
   1612 			}.bind(this));
   1613 		}
   1614 	}
   1615 	
   1616 	// Add parent item for existing item, if note or attachment data isn't going to be updated below
   1617 	//
   1618 	// Technically this doesn't have to go below collection removals, but only because the
   1619 	// 'collectionitem must be top level' trigger check applies only to INSERTs, not UPDATEs, which was
   1620 	// probably done in an earlier attempt to solve this problem.
   1621 	if (!isNew && this._changed.parentKey && !this._changed.note && !this._changed.attachmentData) {
   1622 		let type = Zotero.ItemTypes.getName(itemTypeID);
   1623 		let Type = type[0].toUpperCase() + type.substr(1);
   1624 		let sql = "UPDATE item" + Type + "s SET parentItemID=? WHERE itemID=?";
   1625 		yield Zotero.DB.queryAsync(sql, [parentItemID, this.id]);
   1626 	}
   1627 	
   1628 	// There's no reload for parentKey, so clear it here
   1629 	if (this._changed.parentKey) {
   1630 		this._clearChanged('parentKey');
   1631 	}
   1632 	
   1633 	// Note
   1634 	if ((isNew && this.isNote()) || this._changed.note) {
   1635 		if (!isNew) {
   1636 			if (this._noteText === null || this._noteTitle === null) {
   1637 				throw new Error("Cached note values not set with "
   1638 					+ "this._changed.note set to true");
   1639 			}
   1640 		}
   1641 		
   1642 		let parent = this.isNote() ? this.parentID : null;
   1643 		let noteText = this._noteText ? this._noteText : '';
   1644 		// Add <div> wrapper if not present
   1645 		if (!noteText.match(/^<div class="zotero-note znv[0-9]+">[\s\S]*<\/div>$/)) {
   1646 			noteText = Zotero.Notes.notePrefix + noteText + Zotero.Notes.noteSuffix;
   1647 		}
   1648 		
   1649 		let params = [
   1650 			parent ? parent : null,
   1651 			noteText,
   1652 			this._noteTitle ? this._noteTitle : ''
   1653 		];
   1654 		let sql = "SELECT COUNT(*) FROM itemNotes WHERE itemID=?";
   1655 		if (yield Zotero.DB.valueQueryAsync(sql, itemID)) {
   1656 			sql = "UPDATE itemNotes SET parentItemID=?, note=?, title=? WHERE itemID=?";
   1657 			params.push(itemID);
   1658 		}
   1659 		else {
   1660 			sql = "INSERT INTO itemNotes "
   1661 					+ "(itemID, parentItemID, note, title) VALUES (?,?,?,?)";
   1662 			params.unshift(itemID);
   1663 		}
   1664 		yield Zotero.DB.queryAsync(sql, params);
   1665 		
   1666 		if (parentItemID) {
   1667 			reloadParentChildItems[parentItemID] = true;
   1668 		}
   1669 	}
   1670 	
   1671 	//
   1672 	// Attachment
   1673 	//
   1674 	if (!isNew) {
   1675 		// If attachment title changes, update parent attachments
   1676 		if (this._changed.itemData && this._changed.itemData[110] && this.isAttachment() && parentItemID) {
   1677 			reloadParentChildItems[parentItemID] = true;
   1678 		}
   1679 	}
   1680 	if (this._changed.attachmentData) {
   1681 		let sql = "REPLACE INTO itemAttachments "
   1682 			+ "(itemID, parentItemID, linkMode, contentType, charsetID, path, "
   1683 				+ "syncState, storageModTime, storageHash) "
   1684 			+ "VALUES (?,?,?,?,?,?,?,?,?)";
   1685 		let linkMode = this.attachmentLinkMode;
   1686 		let contentType = this.attachmentContentType;
   1687 		let charsetID = this.attachmentCharset
   1688 			? Zotero.CharacterSets.getID(this.attachmentCharset)
   1689 			: null;
   1690 		let path = this.attachmentPath;
   1691 		let syncState = this.attachmentSyncState;
   1692 		let storageModTime = this.attachmentSyncedModificationTime;
   1693 		let storageHash = this.attachmentSyncedHash;
   1694 		
   1695 		if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE && libraryType != 'user') {
   1696 			throw new Error("Linked files can only be added to user library");
   1697 		}
   1698 		
   1699 		let params = [
   1700 			itemID,
   1701 			parentItemID,
   1702 			{ int: linkMode },
   1703 			contentType ? { string: contentType } : null,
   1704 			charsetID ? { int: charsetID } : null,
   1705 			path ? { string: path } : null,
   1706 			syncState !== undefined ? syncState : 0,
   1707 			storageModTime !== undefined ? storageModTime : null,
   1708 			storageHash || null
   1709 		];
   1710 		yield Zotero.DB.queryAsync(sql, params);
   1711 		
   1712 		// Clear cached child attachments of the parent
   1713 		if (!isNew && parentItemID) {
   1714 			reloadParentChildItems[parentItemID] = true;
   1715 		}
   1716 	}
   1717 	
   1718 	// Add to new collections
   1719 	if (env.collectionsAdded) {
   1720 		let toAdd = env.collectionsAdded;
   1721 		for (let i=0; i<toAdd.length; i++) {
   1722 			let collectionID = toAdd[i];
   1723 			
   1724 			let sql = "SELECT IFNULL(MAX(orderIndex)+1, 0) FROM collectionItems "
   1725 				+ "WHERE collectionID=?";
   1726 			let orderIndex = yield Zotero.DB.valueQueryAsync(sql, collectionID);
   1727 			
   1728 			sql = "INSERT OR IGNORE INTO collectionItems "
   1729 				+ "(collectionID, itemID, orderIndex) VALUES (?, ?, ?)";
   1730 			yield Zotero.DB.queryAsync(sql, [collectionID, this.id, orderIndex]);
   1731 			
   1732 			if (!env.options.skipNotifier) {
   1733 				Zotero.Notifier.queue(
   1734 					'add',
   1735 					'collection-item',
   1736 					collectionID + '-' + this.id,
   1737 					{},
   1738 					env.options.notifierQueue
   1739 				);
   1740 			}
   1741 		}
   1742 		
   1743 		// Add this item to any loaded collections' cached item lists after commit
   1744 		Zotero.DB.addCurrentCallback("commit", function () {
   1745 			for (let i = 0; i < toAdd.length; i++) {
   1746 				this.ContainerObjectsClass.registerChildItem(toAdd[i], this.id);
   1747 			}
   1748 		}.bind(this));
   1749 	}
   1750 	
   1751 	// Tags
   1752 	if (this._changedData.tags) {
   1753 		let oldTags = this._tags;
   1754 		let newTags = this._changedData.tags;
   1755 		this._clearChanged('tags');
   1756 		this._markForReload('tags');
   1757 		
   1758 		// Convert to individual JSON objects, diff, and convert back
   1759 		let oldTagsJSON = oldTags.map(x => JSON.stringify(x));
   1760 		let newTagsJSON = newTags.map(x => JSON.stringify(x));
   1761 		
   1762 		let toAdd = Zotero.Utilities.arrayDiff(newTagsJSON, oldTagsJSON).map(x => JSON.parse(x));
   1763 		let toRemove = Zotero.Utilities.arrayDiff(oldTagsJSON, newTagsJSON).map(x => JSON.parse(x));
   1764 		
   1765 		for (let i=0; i<toAdd.length; i++) {
   1766 			let tag = toAdd[i];
   1767 			let tagID = yield Zotero.Tags.create(tag.tag);
   1768 			let tagType = tag.type ? tag.type : 0;
   1769 			// "OR REPLACE" allows changing type
   1770 			let sql = "INSERT OR REPLACE INTO itemTags (itemID, tagID, type) VALUES (?, ?, ?)";
   1771 			yield Zotero.DB.queryAsync(sql, [this.id, tagID, tagType]);
   1772 			
   1773 			let notifierData = {};
   1774 			notifierData[this.id + '-' + tagID] = {
   1775 				libraryID: this.libraryID,
   1776 				tag: tag.tag,
   1777 				type: tagType
   1778 			};
   1779 			if (!env.options.skipNotifier) {
   1780 				Zotero.Notifier.queue(
   1781 					'add', 'item-tag', this.id + '-' + tagID, notifierData, env.options.notifierQueue
   1782 				);
   1783 			}
   1784 		}
   1785 		
   1786 		if (toRemove.length) {
   1787 			for (let i=0; i<toRemove.length; i++) {
   1788 				let tag = toRemove[i];
   1789 				let tagID = Zotero.Tags.getID(tag.tag);
   1790 				let sql = "DELETE FROM itemTags WHERE itemID=? AND tagID=? AND type=?";
   1791 				yield Zotero.DB.queryAsync(sql, [this.id, tagID, tag.type ? tag.type : 0]);
   1792 				let notifierData = {};
   1793 				notifierData[this.id + '-' + tagID] = {
   1794 					libraryID: this.libraryID,
   1795 					tag: tag.tag
   1796 				};
   1797 
   1798 				if (!env.options.skipNotifier) {
   1799 					Zotero.Notifier.queue(
   1800 						'remove', 'item-tag', this.id + '-' + tagID, notifierData, env.options.notifierQueue
   1801 					);
   1802 				}
   1803 			}
   1804 			Zotero.Prefs.set('purge.tags', true);
   1805 		}
   1806 	}
   1807 	
   1808 	// Update child item counts and contents
   1809 	if (reloadParentChildItems) {
   1810 		for (let parentItemID in reloadParentChildItems) {
   1811 			// Keep in sync with Zotero.Items.trash()
   1812 			let parentItem = yield this.ObjectsClass.getAsync(parseInt(parentItemID));
   1813 			yield parentItem.reload(['primaryData', 'childItems'], true);
   1814 			parentItem.clearBestAttachmentState();
   1815 		}
   1816 	}
   1817 	
   1818 	Zotero.DB.requireTransaction();
   1819 });
   1820 
   1821 Zotero.Item.prototype._finalizeSave = Zotero.Promise.coroutine(function* (env) {
   1822 	if (!env.skipCache) {
   1823 		// Always reload primary data. DataObject.reload() only reloads changed data types, so
   1824 		// it won't reload, say, dateModified and firstCreator if only creator data was changed
   1825 		// and not primaryData.
   1826 		yield this.loadPrimaryData(true);
   1827 		yield this.reload();
   1828 		// If new, there's no other data we don't have, so we can mark everything as loaded
   1829 		if (env.isNew) {
   1830 			this._markAllDataTypeLoadStates(true);
   1831 		}
   1832 	}
   1833 	
   1834 	return env.isNew ? this.id : true;
   1835 });
   1836 
   1837 
   1838 Zotero.Item.prototype.isRegularItem = function() {
   1839 	return !(this.isNote() || this.isAttachment());
   1840 }
   1841 
   1842 
   1843 Zotero.Item.prototype.isTopLevelItem = function () {
   1844 	return this.isRegularItem() || !this.parentKey;
   1845 }
   1846 
   1847 
   1848 Zotero.Item.prototype.numChildren = function(includeTrashed) {
   1849 	return this.numNotes(includeTrashed) + this.numAttachments(includeTrashed);
   1850 }
   1851 
   1852 
   1853 /**
   1854  * @return	{String|FALSE}	 Key of the parent item for an attachment or note, or FALSE if none
   1855  */
   1856 Zotero.Item.prototype.getSourceKey = function() {
   1857 	Zotero.debug("Zotero.Item.prototype.getSource() is deprecated -- use .parentKey");
   1858 	return this._parentKey;
   1859 }
   1860 
   1861 
   1862 Zotero.Item.prototype.setSourceKey = function(sourceItemKey) {
   1863 	Zotero.debug("Zotero.Item.prototype.setSourceKey() is deprecated -- use .parentKey");
   1864 	return this.parentKey = sourceItemKey;
   1865 }
   1866 
   1867 
   1868 ////////////////////////////////////////////////////////
   1869 //
   1870 // Methods dealing with note items
   1871 //
   1872 ////////////////////////////////////////////////////////
   1873 /**
   1874 * Determine if an item is a note
   1875 **/
   1876 Zotero.Item.prototype.isNote = function() {
   1877 	return Zotero.ItemTypes.getName(this.itemTypeID) == 'note';
   1878 }
   1879 
   1880 
   1881 /**
   1882 * Update an item note
   1883 *
   1884 * Note: This can only be called on saved notes and attachments
   1885 **/
   1886 Zotero.Item.prototype.updateNote = function(text) {
   1887 	throw ('updateNote() removed -- use setNote() and save()');
   1888 }
   1889 
   1890 
   1891 /**
   1892  * Returns number of child notes of item
   1893  *
   1894  * @param	{Boolean}	includeTrashed		Include trashed child items in count
   1895  * @param	{Boolean}	includeEmbedded		Include notes embedded in attachments
   1896  * @return	{Integer}
   1897  */
   1898 Zotero.Item.prototype.numNotes = function(includeTrashed, includeEmbedded) {
   1899 	this._requireData('childItems');
   1900 	var notes = Zotero.Items.get(this.getNotes(includeTrashed));
   1901 	var num = notes.length;
   1902 	if (includeEmbedded) {
   1903 		// Include embedded attachment notes that aren't empty
   1904 		num += Zotero.Items.get(this.getAttachments(includeTrashed))
   1905 			.filter(x => x.getNote() !== '').length;
   1906 	}
   1907 	return num;
   1908 }
   1909 
   1910 
   1911 /**
   1912  * Get the first line of the note for display in the items list
   1913  *
   1914  * @return	{String}
   1915  */
   1916 Zotero.Item.prototype.getNoteTitle = function() {
   1917 	if (!this.isNote() && !this.isAttachment()) {
   1918 		throw ("getNoteTitle() can only be called on notes and attachments");
   1919 	}
   1920 	if (this._noteTitle !== null) {
   1921 		return this._noteTitle;
   1922 	}
   1923 	this._requireData('itemData');
   1924 	return "";
   1925 };
   1926 
   1927 
   1928 Zotero.Item.prototype.hasNote = Zotero.Promise.coroutine(function* () {
   1929 	if (!this.isNote() && !this.isAttachment()) {
   1930 		throw new Error("hasNote() can only be called on notes and attachments");
   1931 	}
   1932 	
   1933 	if (this._hasNote !== null) {
   1934 		return this._hasNote;
   1935 	}
   1936 	
   1937 	if (!this._id) {
   1938 		return false;
   1939 	}
   1940 	
   1941 	var sql = "SELECT COUNT(*) FROM itemNotes WHERE itemID=? "
   1942 				+ "AND note!='' AND note!=?";
   1943 	var hasNote = !!(yield Zotero.DB.valueQueryAsync(sql, [this._id, Zotero.Notes.defaultNote]));
   1944 	
   1945 	this._hasNote = hasNote;
   1946 	return hasNote;
   1947 });
   1948 
   1949 
   1950 /**
   1951  * Get the text of an item note
   1952  **/
   1953 Zotero.Item.prototype.getNote = function() {
   1954 	if (!this.isNote() && !this.isAttachment()) {
   1955 		throw new Error("getNote() can only be called on notes and attachments "
   1956 			+ `(${this.libraryID}/${this.key} is a ${Zotero.ItemTypes.getName(this.itemTypeID)})`);
   1957 	}
   1958 	
   1959 	// Store access time for later garbage collection
   1960 	this._noteAccessTime = new Date();
   1961 	
   1962 	if (this._noteText !== null) {
   1963 		return this._noteText;
   1964 	}
   1965 	
   1966 	this._requireData('note');
   1967 	return "";
   1968 }
   1969 
   1970 
   1971 /**
   1972 * Set an item note
   1973 *
   1974 * Note: This can only be called on notes and attachments
   1975 **/
   1976 Zotero.Item.prototype.setNote = function(text) {
   1977 	if (!this.isNote() && !this.isAttachment()) {
   1978 		throw ("updateNote() can only be called on notes and attachments");
   1979 	}
   1980 	
   1981 	if (typeof text != 'string') {
   1982 		throw ("text must be a string in Zotero.Item.setNote() (was " + typeof text + ")");
   1983 	}
   1984 	
   1985 	text = text
   1986 		// Strip control characters
   1987 		.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
   1988 		.trim();
   1989 	
   1990 	var oldText = this.getNote();
   1991 	if (text === oldText) {
   1992 		Zotero.debug("Note hasn't changed", 4);
   1993 		return false;
   1994 	}
   1995 	
   1996 	this._hasNote = text !== '';
   1997 	this._noteText = text;
   1998 	this._noteTitle = Zotero.Notes.noteToTitle(text);
   1999 	if (this.isNote()) {
   2000 		this._displayTitle = this._noteTitle;
   2001 	}
   2002 	
   2003 	this._markFieldChange('note', oldText);
   2004 	this._changed.note = true;
   2005 	
   2006 	return true;
   2007 }
   2008 
   2009 
   2010 /**
   2011  * Returns child notes of this item
   2012  *
   2013  * @param	{Boolean}	includeTrashed		Include trashed child items
   2014  * @param	{Boolean}	includeEmbedded		Include embedded attachment notes
   2015  * @return	{Integer[]}						Array of itemIDs
   2016  */
   2017 Zotero.Item.prototype.getNotes = function(includeTrashed) {
   2018 	if (this.isNote()) {
   2019 		throw new Error("getNotes() cannot be called on items of type 'note'");
   2020 	}
   2021 	
   2022 	this._requireData('childItems');
   2023 	
   2024 	if (!this._notes) {
   2025 		return [];
   2026 	}
   2027 	
   2028 	var sortChronologically = Zotero.Prefs.get('sortNotesChronologically');
   2029 	var cacheKey = (sortChronologically ? "chronological" : "alphabetical")
   2030 		+ 'With' + (includeTrashed ? '' : 'out') + 'Trashed';
   2031 	
   2032 	if (this._notes[cacheKey]) {
   2033 		return this._notes[cacheKey];
   2034 	}
   2035 	
   2036 	var rows = this._notes.rows.concat();
   2037 	// Remove trashed items if necessary
   2038 	if (!includeTrashed) {
   2039 		rows = rows.filter(row => !row.trashed);
   2040 	}
   2041 	// Sort by title if necessary
   2042 	if (!sortChronologically) {
   2043 		var collation = Zotero.getLocaleCollation();
   2044 		rows.sort((a, b) => {
   2045 			var aTitle = this.ObjectsClass.getSortTitle(a.title);
   2046 			var bTitle = this.ObjectsClass.getSortTitle(b.title);
   2047 			return collation.compareString(1, aTitle, bTitle);
   2048 		});
   2049 	}
   2050 	var ids = rows.map(row => row.itemID);
   2051 	this._notes[cacheKey] = ids;
   2052 	return ids;
   2053 }
   2054 
   2055 
   2056 ////////////////////////////////////////////////////////
   2057 //
   2058 // Methods dealing with attachments
   2059 //
   2060 // save() is not required for attachment functions
   2061 //
   2062 ///////////////////////////////////////////////////////
   2063 /**
   2064 * Determine if an item is an attachment
   2065 **/
   2066 Zotero.Item.prototype.isAttachment = function() {
   2067 	return Zotero.ItemTypes.getName(this.itemTypeID) == 'attachment';
   2068 }
   2069 
   2070 
   2071 /**
   2072  * @return {Promise<Boolean>}
   2073  */
   2074 Zotero.Item.prototype.isImportedAttachment = function() {
   2075 	if (!this.isAttachment()) {
   2076 		return false;
   2077 	}
   2078 	var linkMode = this.attachmentLinkMode;
   2079 	if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE || linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL) {
   2080 		return true;
   2081 	}
   2082 	return false;
   2083 }
   2084 
   2085 
   2086 /**
   2087  * @return {Promise<Boolean>}
   2088  */
   2089 Zotero.Item.prototype.isWebAttachment = function() {
   2090 	if (!this.isAttachment()) {
   2091 		return false;
   2092 	}
   2093 	var linkMode = this.attachmentLinkMode;
   2094 	if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE || linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
   2095 		return false;
   2096 	}
   2097 	return true;
   2098 }
   2099 
   2100 
   2101 /**
   2102  * @return {Promise<Boolean>}
   2103  */
   2104 Zotero.Item.prototype.isFileAttachment = function() {
   2105 	if (!this.isAttachment()) {
   2106 		return false;
   2107 	}
   2108 	return this.attachmentLinkMode != Zotero.Attachments.LINK_MODE_LINKED_URL;
   2109 }
   2110 
   2111 
   2112 /**
   2113  * Returns number of child attachments of item
   2114  *
   2115  * @param	{Boolean}	includeTrashed		Include trashed child items in count
   2116  * @return	<Integer>
   2117  */
   2118 Zotero.Item.prototype.numAttachments = function (includeTrashed) {
   2119 	this._requireData('childItems');
   2120 	return this.getAttachments(includeTrashed).length;
   2121 }
   2122 
   2123 
   2124 Zotero.Item.prototype.numNonHTMLFileAttachments = function () {
   2125 	this._requireData('childItems');
   2126 	return this.getAttachments()
   2127 		.map(itemID => Zotero.Items.get(itemID))
   2128 		.filter(item => item.isFileAttachment() && item.attachmentContentType != 'text/html')
   2129 		.length;
   2130 };
   2131 
   2132 
   2133 Zotero.Item.prototype.getFile = function () {
   2134 	Zotero.debug("Zotero.Item.prototype.getFile() is deprecated -- use getFilePath[Async]()", 2);
   2135 	
   2136 	var path = this.getFilePath();
   2137 	if (path) {
   2138 		return Zotero.File.pathToFile(path);
   2139 	}
   2140 	return false;
   2141 }
   2142 
   2143 
   2144 /**
   2145  * Get the absolute file path for the attachment
   2146  *
   2147  * @return {string|false} - The absolute file path of the attachment, or false for invalid paths
   2148  */
   2149 Zotero.Item.prototype.getFilePath = function () {
   2150 	if (!this.isAttachment()) {
   2151 		throw new Error("getFilePath() can only be called on attachment items");
   2152 	}
   2153 	
   2154 	var linkMode = this.attachmentLinkMode;
   2155 	var path = this.attachmentPath;
   2156 	
   2157 	// No associated files for linked URLs
   2158 	if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
   2159 		return false;
   2160 	}
   2161 	
   2162 	if (!path) {
   2163 		Zotero.debug("Attachment path is empty", 2);
   2164 		this._updateAttachmentStates(false);
   2165 		return false;
   2166 	}
   2167 	
   2168 	if (!this._identified) {
   2169 		Zotero.debug("Can't get file path for unsaved file");
   2170 		return false;
   2171 	}
   2172 	
   2173 	// Imported file with relative path
   2174 	if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL ||
   2175 			linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE) {
   2176 		if (!path.includes("storage:")) {
   2177 			Zotero.logError("Invalid attachment path '" + path + "'");
   2178 			this._updateAttachmentStates(false);
   2179 			return false;
   2180 		}
   2181 		// Strip "storage:"
   2182 		path = path.substr(8);
   2183 		
   2184 		// Ignore .zotero* files that were relinked before we started blocking them
   2185 		if (path.startsWith(".zotero")) {
   2186 			Zotero.debug("Ignoring attachment file " + path, 2);
   2187 			return false;
   2188 		}
   2189 		
   2190 		return OS.Path.join(
   2191 			OS.Path.normalize(Zotero.Attachments.getStorageDirectory(this).path), path
   2192 		);
   2193 	}
   2194 	
   2195 	// Linked file with relative path
   2196 	if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE &&
   2197 			path.indexOf(Zotero.Attachments.BASE_PATH_PLACEHOLDER) == 0) {
   2198 		path = Zotero.Attachments.resolveRelativePath(path);
   2199 		if (!path) {
   2200 			this._updateAttachmentStates(false);
   2201 		}
   2202 		return path;
   2203 	}
   2204 	
   2205 	// Old-style OS X persistent descriptor (Base64-encoded opaque alias record)
   2206 	//
   2207 	// These should only exist if they weren't converted in the 80 DB upgrade step because
   2208 	// the file couldn't be found.
   2209 	if (path.startsWith('AAAA')) {
   2210 		// These can only be resolved on Macs
   2211 		if (!Zotero.isMac) {
   2212 			Zotero.debug(`Can't resolve old-style attachment path '${path}' on non-Mac platform`);
   2213 			this._updateAttachmentStates(false);
   2214 			return false;
   2215 		}
   2216 		
   2217 		let file = Components.classes["@mozilla.org/file/local;1"]
   2218 			.createInstance(Components.interfaces.nsILocalFile);
   2219 		try {
   2220 			file.persistentDescriptor = path;
   2221 		}
   2222 		catch (e) {
   2223 			Zotero.debug(`Can't resolve old-style attachment path '${path}'`);
   2224 			this._updateAttachmentStates(false);
   2225 			return false;
   2226 		}
   2227 		
   2228 		// If valid, convert this to a regular string in the background
   2229 		Zotero.DB.queryAsync(
   2230 			"UPDATE itemAttachments SET path=? WHERE itemID=?",
   2231 			[file.path, this._id]
   2232 		);
   2233 		
   2234 		return file.path;
   2235 	}
   2236 	
   2237 	return path;
   2238 };
   2239 
   2240 
   2241 /**
   2242  * Get the absolute path for the attachment, if the file exists
   2243  *
   2244  * @return {Promise<String|false>} - A promise for either the absolute path of the attachment
   2245  *                                   or false for invalid paths or if the file doesn't exist
   2246  */
   2247 Zotero.Item.prototype.getFilePathAsync = Zotero.Promise.coroutine(function* () {
   2248 	if (!this.isAttachment()) {
   2249 		throw new Error("getFilePathAsync() can only be called on attachment items");
   2250 	}
   2251 	
   2252 	var linkMode = this.attachmentLinkMode;
   2253 	var path = this.attachmentPath;
   2254 	
   2255 	// No associated files for linked URLs
   2256 	if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
   2257 		this._updateAttachmentStates(false);
   2258 		return false;
   2259 	}
   2260 	
   2261 	if (!path) {
   2262 		Zotero.debug("Attachment path is empty", 2);
   2263 		this._updateAttachmentStates(false);
   2264 		return false;
   2265 	}
   2266 	
   2267 	// Imported file with relative path
   2268 	if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL ||
   2269 			linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE) {
   2270 		if (!path.includes("storage:")) {
   2271 			Zotero.logError("Invalid attachment path '" + path + "'");
   2272 			this._updateAttachmentStates(false);
   2273 			return false;
   2274 		}
   2275 		
   2276 		// Strip "storage:"
   2277 		path = path.substr(8);
   2278 		
   2279 		// Ignore .zotero* files that were relinked before we started blocking them
   2280 		if (path.startsWith(".zotero")) {
   2281 			Zotero.debug("Ignoring attachment file " + path, 2);
   2282 			this._updateAttachmentStates(false);
   2283 			return false;
   2284 		}
   2285 		
   2286 		path = OS.Path.join(
   2287 			OS.Path.normalize(Zotero.Attachments.getStorageDirectory(this).path), path
   2288 		);
   2289 		
   2290 		if (!(yield OS.File.exists(path))) {
   2291 			Zotero.debug("Attachment file '" + path + "' not found", 2);
   2292 			this._updateAttachmentStates(false);
   2293 			return false;
   2294 		}
   2295 		
   2296 		this._updateAttachmentStates(true);
   2297 		return path;
   2298 	}
   2299 	
   2300 	// Linked file with relative path
   2301 	if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE &&
   2302 			path.indexOf(Zotero.Attachments.BASE_PATH_PLACEHOLDER) == 0) {
   2303 		path = Zotero.Attachments.resolveRelativePath(path);
   2304 		if (!path) {
   2305 			this._updateAttachmentStates(false);
   2306 			return false;
   2307 		}
   2308 		if (!(yield OS.File.exists(path))) {
   2309 			Zotero.debug("Attachment file '" + path + "' not found", 2);
   2310 			this._updateAttachmentStates(false);
   2311 			return false;
   2312 		}
   2313 		
   2314 		this._updateAttachmentStates(true);
   2315 		return path;
   2316 	}
   2317 	
   2318 	// Old-style OS X persistent descriptor (Base64-encoded opaque alias record)
   2319 	//
   2320 	// These should only exist if they weren't converted in the 80 DB upgrade step because
   2321 	// the file couldn't be found
   2322 	if (Zotero.isMac && path.startsWith('AAAA')) {
   2323 		let file = Components.classes["@mozilla.org/file/local;1"]
   2324 			.createInstance(Components.interfaces.nsILocalFile);
   2325 		try {
   2326 			file.persistentDescriptor = path;
   2327 		}
   2328 		catch (e) {
   2329 			this._updateAttachmentStates(false);
   2330 			return false;
   2331 		}
   2332 		
   2333 		// If valid, convert this to a regular string
   2334 		yield Zotero.DB.queryAsync(
   2335 			"UPDATE itemAttachments SET path=? WHERE itemID=?",
   2336 			[file.leafName, this._id]
   2337 		);
   2338 		
   2339 		if (!(yield OS.File.exists(file.path))) {
   2340 			Zotero.debug("Attachment file '" + file.path + "' not found", 2);
   2341 			this._updateAttachmentStates(false);
   2342 			return false;
   2343 		}
   2344 		
   2345 		this._updateAttachmentStates(true);
   2346 		
   2347 		return file.path;
   2348 	}
   2349 	
   2350 	if (!(yield OS.File.exists(path))) {
   2351 		Zotero.debug("Attachment file '" + path + "' not found", 2);
   2352 		this._updateAttachmentStates(false);
   2353 		return false;
   2354 	}
   2355 	
   2356 	this._updateAttachmentStates(true);
   2357 	
   2358 	return path;
   2359 });
   2360 
   2361 
   2362 /**
   2363  * Update file existence state of this item and best attachment state of parent item
   2364  */
   2365 Zotero.Item.prototype._updateAttachmentStates = function (exists) {
   2366 	this._fileExists = exists;
   2367 	
   2368 	if (this.isTopLevelItem()) {
   2369 		return;
   2370 	}
   2371 	
   2372 	try {
   2373 		var parentKey = this.parentKey;
   2374 	}
   2375 	// This can happen during classic sync conflict resolution, if a
   2376 	// standalone attachment was modified locally and remotely was changed
   2377 	// into a child attachment
   2378 	catch (e) {
   2379 		Zotero.logError(`Attachment parent ${this.libraryID}/${parentKey} doesn't exist for `
   2380 			+ "source key in Zotero.Item.updateAttachmentStates()");
   2381 		return;
   2382 	}
   2383 	
   2384 	try {
   2385 		var item = this.ObjectsClass.getByLibraryAndKey(this.libraryID, parentKey);
   2386 	}
   2387 	catch (e) {
   2388 		if (e instanceof Zotero.Exception.UnloadedDataException) {
   2389 			Zotero.logError(`Attachment parent ${this.libraryID}/${parentKey} not yet loaded in `
   2390 				+ "Zotero.Item.updateAttachmentStates()");
   2391 			return;
   2392 		}
   2393 		throw e;
   2394 	}
   2395 	if (!item) {
   2396 		Zotero.logError(`Attachment parent ${this.libraryID}/${parentKey} doesn't exist`);
   2397 		return;
   2398 	}
   2399 	item.clearBestAttachmentState();
   2400 };
   2401 
   2402 
   2403 Zotero.Item.prototype.getFilename = function () {
   2404 	Zotero.debug("getFilename() deprecated -- use .attachmentFilename");
   2405 	return this.attachmentFilename;
   2406 }
   2407 
   2408 
   2409 /**
   2410  * Asynchronous check for file existence
   2411  */
   2412 Zotero.Item.prototype.fileExists = Zotero.Promise.coroutine(function* () {
   2413 	if (!this.isAttachment()) {
   2414 		throw new Error("Zotero.Item.fileExists() can only be called on attachment items");
   2415 	}
   2416 	
   2417 	if (this.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
   2418 		throw new Error("Zotero.Item.fileExists() cannot be called on link attachments");
   2419 	}
   2420 	
   2421 	return !!(yield this.getFilePathAsync());
   2422 });
   2423 
   2424 
   2425 /**
   2426  * Synchronous cached check for file existence, used for items view
   2427  */
   2428 Zotero.Item.prototype.fileExistsCached = function () {
   2429 	return this._fileExists;
   2430 }
   2431 
   2432 
   2433 
   2434 /**
   2435  * Rename file associated with an attachment
   2436  *
   2437  * @param {String} newName
   2438  * @param {Boolean} [overwrite=false] - Overwrite file if one exists
   2439  * @param {Boolean} [unique=false] - Add suffix to create unique filename if necessary
   2440  * @return {Number|false} -- true - Rename successful
   2441  *                           -1 - Destination file exists; use _force_ to overwrite
   2442  *                           -2 - Error renaming
   2443  *                           false - Attachment file not found
   2444  */
   2445 Zotero.Item.prototype.renameAttachmentFile = async function (newName, overwrite = false, unique = false) {
   2446 	var origPath = await this.getFilePathAsync();
   2447 	if (!origPath) {
   2448 		Zotero.debug("Attachment file not found in renameAttachmentFile()", 2);
   2449 		return false;
   2450 	}
   2451 	
   2452 	try {
   2453 		let origName = OS.Path.basename(origPath);
   2454 		if (this.isImportedAttachment()) {
   2455 			var origModDate = (await OS.File.stat(origPath)).lastModificationDate;
   2456 		}
   2457 		
   2458 		// No change
   2459 		if (origName === newName) {
   2460 			Zotero.debug("Filename has not changed");
   2461 			return true;
   2462 		}
   2463 		
   2464 		// Update mod time and clear hash so the file syncs
   2465 		// TODO: use an integer counter instead of mod time for change detection
   2466 		// Update mod time first, because it may fail for read-only files on Windows
   2467 		if (this.isImportedAttachment()) {
   2468 			await OS.File.setDates(origPath, null, null);
   2469 		}
   2470 		
   2471 		newName = await Zotero.File.rename(
   2472 			origPath,
   2473 			newName,
   2474 			{
   2475 				overwrite,
   2476 				unique
   2477 			}
   2478 		);
   2479 		let destPath = OS.Path.join(OS.Path.dirname(origPath), newName);
   2480 		
   2481 		await this.relinkAttachmentFile(destPath);
   2482 		
   2483 		if (this.isImportedAttachment()) {
   2484 			this.attachmentSyncedHash = null;
   2485 			this.attachmentSyncState = "to_upload";
   2486 			await this.saveTx({ skipAll: true });
   2487 		}
   2488 		
   2489 		return true;
   2490 	}
   2491 	catch (e) {
   2492 		Zotero.logError(e);
   2493 		
   2494 		// Restore original modification date in case we managed to change it
   2495 		if (this.isImportedAttachment()) {
   2496 			try {
   2497 				OS.File.setDates(origPath, null, origModDate);
   2498 			} catch (e) {
   2499 				Zotero.debug(e, 2);
   2500 			}
   2501 		}
   2502 		
   2503 		return -2;
   2504 	}
   2505 };
   2506 
   2507 
   2508 /**
   2509  * @param {string} path  File path
   2510  * @param {Boolean} [skipItemUpdate] Don't update attachment item mod time, so that item doesn't
   2511  *     sync. Used when a file needs to be renamed to be accessible but the user doesn't have
   2512  *     access to modify the attachment metadata. This also allows a save when the library is
   2513  *     read-only.
   2514  */
   2515 Zotero.Item.prototype.relinkAttachmentFile = Zotero.Promise.coroutine(function* (path, skipItemUpdate) {
   2516 	if (path instanceof Components.interfaces.nsIFile) {
   2517 		Zotero.debug("WARNING: Zotero.Item.prototype.relinkAttachmentFile() now takes an absolute "
   2518 			+ "file path instead of an nsIFile");
   2519 		path = path.path;
   2520 	}
   2521 	
   2522 	var linkMode = this.attachmentLinkMode;
   2523 	if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
   2524 		throw new Error('Cannot relink linked URL');
   2525 	}
   2526 	
   2527 	var fileName = OS.Path.basename(path);
   2528 	if (fileName.endsWith(".lnk")) {
   2529 		throw new Error("Cannot relink to Windows shortcut");
   2530 	}
   2531 	var newPath;
   2532 	var newName = Zotero.File.getValidFileName(fileName);
   2533 	if (!newName) {
   2534 		throw new Error("No valid characters in filename after filtering");
   2535 	}
   2536 	
   2537 	// If selected file isn't in the attachment's storage directory,
   2538 	// copy it in and use that one instead
   2539 	var storageDir = Zotero.Attachments.getStorageDirectory(this).path;
   2540 	if (this.isImportedAttachment() && OS.Path.dirname(path) != storageDir) {
   2541 		newPath = OS.Path.join(storageDir, newName);
   2542 		
   2543 		// If file with same name already exists in the storage directory,
   2544 		// move it out of the way
   2545 		let backupCreated = false;
   2546 		if (yield OS.File.exists(newPath)) {
   2547 			backupCreated = true;
   2548 			yield OS.File.move(newPath, newPath + ".bak");
   2549 		}
   2550 		// Create storage directory if necessary
   2551 		else if (!(yield OS.File.exists(storageDir))) {
   2552 			yield Zotero.Attachments.createDirectoryForItem(this);
   2553 		}
   2554 		
   2555 		let newFile;
   2556 		try {
   2557 			newFile = Zotero.File.copyToUnique(path, newPath);
   2558 		}
   2559 		catch (e) {
   2560 			// Restore backup file if copying failed
   2561 			if (backupCreated) {
   2562 				yield OS.File.move(newPath + ".bak", newPath);
   2563 			}
   2564 			throw e;
   2565 		}
   2566 		newPath = newFile.path;
   2567 		
   2568 		// Delete backup file
   2569 		if (backupCreated) {
   2570 			yield OS.File.remove(newPath + ".bak");
   2571 		}
   2572 	}
   2573 	else {
   2574 		newPath = OS.Path.join(OS.Path.dirname(path), newName);
   2575 		
   2576 		// Rename file to filtered name if necessary
   2577 		if (fileName != newName) {
   2578 			Zotero.debug("Renaming file '" + fileName + "' to '" + newName + "'");
   2579 			try {
   2580 				yield OS.File.move(path, newPath, { noOverwrite: true });
   2581 			}
   2582 			catch (e) {
   2583 				if (e instanceof OS.File.Error && e.becauseExists && fileName.normalize() == newName) {
   2584 					// Ignore normalization differences that the filesystem ignores
   2585 				}
   2586 				else {
   2587 					throw e;
   2588 				}
   2589 			}
   2590 		}
   2591 	}
   2592 	
   2593 	this.attachmentPath = newPath;
   2594 	
   2595 	yield this.saveTx({
   2596 		skipDateModifiedUpdate: true,
   2597 		skipClientDateModifiedUpdate: skipItemUpdate,
   2598 		skipEditCheck: skipItemUpdate
   2599 	});
   2600 	
   2601 	this._updateAttachmentStates(true);
   2602 	yield Zotero.Notifier.trigger('refresh', 'item', this.id);
   2603 	
   2604 	return true;
   2605 });
   2606 
   2607 
   2608 Zotero.Item.prototype.deleteAttachmentFile = Zotero.Promise.coroutine(function* () {
   2609 	if (!this.isImportedAttachment()) {
   2610 		throw new Error("deleteAttachmentFile() can only be called on imported attachment items");
   2611 	}
   2612 	
   2613 	var path = yield this.getFilePathAsync();
   2614 	if (!path) {
   2615 		Zotero.debug(`File not found for item ${this.libraryKey} in deleteAttachmentFile()`, 2);
   2616 		return false;
   2617 	}
   2618 	
   2619 	Zotero.debug("Deleting attachment file for item " + this.libraryKey);
   2620 	try {
   2621 		yield Zotero.File.removeIfExists(path);
   2622 		this.attachmentSyncState = "to_download";
   2623 		yield this.saveTx({ skipAll: true });
   2624 		return true;
   2625 	}
   2626 	catch (e) {
   2627 		Zotero.logError(e);
   2628 		return false;
   2629 	}
   2630 });
   2631 
   2632 
   2633 
   2634 /*
   2635  * Return a file:/// URL path to files and snapshots
   2636  */
   2637 Zotero.Item.prototype.getLocalFileURL = function() {
   2638 	if (!this.isAttachment) {
   2639 		throw ("getLocalFileURL() can only be called on attachment items");
   2640 	}
   2641 	
   2642 	var file = this.getFile();
   2643 	if (!file) {
   2644 		return false;
   2645 	}
   2646 	
   2647 	var nsIFPH = Components.classes["@mozilla.org/network/protocol;1?name=file"]
   2648 			.getService(Components.interfaces.nsIFileProtocolHandler);
   2649 	return nsIFPH.getURLSpecFromFile(file);
   2650 }
   2651 
   2652 
   2653 Zotero.Item.prototype.getAttachmentLinkMode = function() {
   2654 	Zotero.debug("getAttachmentLinkMode() deprecated -- use .attachmentLinkMode");
   2655 	return this.attachmentLinkMode;
   2656 }
   2657 
   2658 /**
   2659  * Link mode of an attachment
   2660  *
   2661  * Possible values specified as constants in Zotero.Attachments
   2662  * (e.g. Zotero.Attachments.LINK_MODE_LINKED_FILE)
   2663  */
   2664 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentLinkMode', {
   2665 	get: function() {
   2666 		if (!this.isAttachment()) {
   2667 			return undefined;
   2668 		}
   2669 		return this._attachmentLinkMode;
   2670 	},
   2671 	set: function(val) {
   2672 		if (!this.isAttachment()) {
   2673 			throw (".attachmentLinkMode can only be set for attachment items");
   2674 		}
   2675 		
   2676 		// Allow 'imported_url', etc.
   2677 		if (typeof val == 'string') {
   2678 			let code = Zotero.Attachments["LINK_MODE_" + val.toUpperCase()];
   2679 			if (code !== undefined) {
   2680 				val = code;
   2681 			}
   2682 		}
   2683 		
   2684 		switch (val) {
   2685 			case Zotero.Attachments.LINK_MODE_IMPORTED_FILE:
   2686 			case Zotero.Attachments.LINK_MODE_IMPORTED_URL:
   2687 			case Zotero.Attachments.LINK_MODE_LINKED_FILE:
   2688 			case Zotero.Attachments.LINK_MODE_LINKED_URL:
   2689 				break;
   2690 			
   2691 			default:
   2692 				throw ("Invalid attachment link mode '" + val
   2693 					+ "' in Zotero.Item.attachmentLinkMode setter");
   2694 		}
   2695 		
   2696 		if (val === this.attachmentLinkMode) {
   2697 			return;
   2698 		}
   2699 		if (!this._changed.attachmentData) {
   2700 			this._changed.attachmentData = {};
   2701 		}
   2702 		this._changed.attachmentData.linkMode = true;
   2703 		this._attachmentLinkMode = val;
   2704 	}
   2705 });
   2706 
   2707 
   2708 Zotero.Item.prototype.getAttachmentMIMEType = function() {
   2709 	Zotero.debug("getAttachmentMIMEType() deprecated -- use .attachmentContentType");
   2710 	return this.attachmentContentType;
   2711 };
   2712 
   2713 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentMIMEType', {
   2714 	get: function() {
   2715 		Zotero.debug(".attachmentMIMEType deprecated -- use .attachmentContentType");
   2716 		return this.attachmentContentType;
   2717 	},
   2718 	enumerable: false
   2719 });
   2720 
   2721 /**
   2722  * Content type of an attachment (e.g. 'text/plain')
   2723  */
   2724 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentContentType', {
   2725 	get: function() {
   2726 		if (!this.isAttachment()) {
   2727 			return undefined;
   2728 		}
   2729 		return this._attachmentContentType;
   2730 	},
   2731 	set: function(val) {
   2732 		if (!this.isAttachment()) {
   2733 			throw (".attachmentContentType can only be set for attachment items");
   2734 		}
   2735 		
   2736 		if (!val) {
   2737 			val = '';
   2738 		}
   2739 		
   2740 		if (val == this.attachmentContentType) {
   2741 			return;
   2742 		}
   2743 		
   2744 		if (!this._changed.attachmentData) {
   2745 			this._changed.attachmentData = {};
   2746 		}
   2747 		this._changed.attachmentData.contentType = true;
   2748 		this._attachmentContentType = val;
   2749 	}
   2750 });
   2751 
   2752 
   2753 Zotero.Item.prototype.getAttachmentCharset = function() {
   2754 	Zotero.debug("getAttachmentCharset() deprecated -- use .attachmentCharset");
   2755 	return this.attachmentCharset;
   2756 }
   2757 
   2758 
   2759 /**
   2760  * Character set of an attachment
   2761  */
   2762 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentCharset', {
   2763 	get: function() {
   2764 		if (!this.isAttachment()) {
   2765 			return undefined;
   2766 		}
   2767 		return this._attachmentCharset
   2768 	},
   2769 	set: function(val) {
   2770 		if (!this.isAttachment()) {
   2771 			throw (".attachmentCharset can only be set for attachment items");
   2772 		}
   2773 		
   2774 		if (typeof val == 'number') {
   2775 			throw new Error("Character set must be a string");
   2776 		}
   2777 		oldVal = this.attachmentCharset;
   2778 		
   2779 		if (val) {
   2780 			val = Zotero.CharacterSets.toCanonical(val);
   2781 		}
   2782 		if (!val) {
   2783 			val = "";
   2784 		}
   2785 		
   2786 		if (val === oldVal) {
   2787 			return;
   2788 		}
   2789 		
   2790 		if (!this._changed.attachmentData) {
   2791 			this._changed.attachmentData= {};
   2792 		}
   2793 		this._changed.attachmentData.charset = true;
   2794 		this._attachmentCharset = val;
   2795 	}
   2796 });
   2797 
   2798 
   2799 /**
   2800  * Get or set the filename of file attachments
   2801  *
   2802  * This will return the filename for all file attachments, but the filename can only be set
   2803  * for stored file attachments. Linked file attachments should be set using .attachmentPath.
   2804  */
   2805 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentFilename', {
   2806 	get: function () {
   2807 		if (!this.isAttachment()) {
   2808 			return undefined;
   2809 		}
   2810 		var path = this.attachmentPath;
   2811 		if (!path) {
   2812 			return '';
   2813 		}
   2814 		var prefixedPath = path.match(/^(?:attachments|storage):(.*)$/);
   2815 		if (prefixedPath) {
   2816 			return prefixedPath[1];
   2817 		}
   2818 		return OS.Path.basename(path);
   2819 	},
   2820 	set: function (val) {
   2821 		if (!this.isAttachment()) {
   2822 			throw new Error("Attachment filename can only be set for attachment items");
   2823 		}
   2824 		var linkMode = this.attachmentLinkMode;
   2825 		if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE
   2826 				|| linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
   2827 			throw new Error("Attachment filename can only be set for stored files");
   2828 		}
   2829 		
   2830 		if (!val) {
   2831 			throw new Error("Attachment filename cannot be blank");
   2832 		}
   2833 		
   2834 		this.attachmentPath = 'storage:' + val;
   2835 	}
   2836 });
   2837 
   2838 
   2839 /**
   2840  * Returns raw attachment path string as stored in DB
   2841  * (e.g., "storage:foo.pdf", "attachments:foo/bar.pdf", "/Users/foo/Desktop/bar.pdf")
   2842  *
   2843  * Can be set as absolute path or prefixed string ("storage:foo.pdf")
   2844  */
   2845 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentPath', {
   2846 	get: function() {
   2847 		if (!this.isAttachment()) {
   2848 			return undefined;
   2849 		}
   2850 		return this._attachmentPath;
   2851 	},
   2852 	set: function(val) {
   2853 		if (!this.isAttachment()) {
   2854 			throw new Error(".attachmentPath can only be set for attachment items");
   2855 		}
   2856 		
   2857 		if (typeof val != 'string') {
   2858 			throw new Error(".attachmentPath must be a string");
   2859 		}
   2860 		
   2861 		var linkMode = this.attachmentLinkMode;
   2862 		if (linkMode === null) {
   2863 			throw new Error("Link mode must be set before setting attachment path");
   2864 		}
   2865 		if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
   2866 			throw new Error('attachmentPath cannot be set for link attachments');
   2867 		}
   2868 		
   2869 		if (!val) {
   2870 			val = '';
   2871 		}
   2872 		
   2873 		if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
   2874 			if (this._libraryID) {
   2875 				let libraryType = Zotero.Libraries.get(this._libraryID).libraryType;
   2876 				if (libraryType != 'user') {
   2877 					throw new Error("Linked files can only be added to user library");
   2878 				}
   2879 			}
   2880 			
   2881 			// If base directory is enabled, save attachment within as relative path
   2882 			if (Zotero.Prefs.get('saveRelativeAttachmentPath')) {
   2883 				val = Zotero.Attachments.getBaseDirectoryRelativePath(val);
   2884 			}
   2885 			// Otherwise, convert relative path to absolute if possible
   2886 			else {
   2887 				val = Zotero.Attachments.resolveRelativePath(val) || val;
   2888 			}
   2889 		}
   2890 		else if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL ||
   2891 				linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE) {
   2892 			if (!val.startsWith('storage:')) {
   2893 				let storagePath = Zotero.Attachments.getStorageDirectory(this).path;
   2894 				if (!val.startsWith(storagePath)) {
   2895 					throw new Error("Imported file path must be within storage directory");
   2896 				}
   2897 				val = 'storage:' + OS.Path.basename(val);
   2898 			}
   2899 		}
   2900 		
   2901 		if (val == this.attachmentPath) {
   2902 			return;
   2903 		}
   2904 		
   2905 		if (!this._changed.attachmentData) {
   2906 			this._changed.attachmentData = {};
   2907 		}
   2908 		this._changed.attachmentData.path = true;
   2909 		this._attachmentPath = val;
   2910 	}
   2911 });
   2912 
   2913 
   2914 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentSyncState', {
   2915 	get: function() {
   2916 		if (!this.isAttachment()) {
   2917 			return undefined;
   2918 		}
   2919 		return this._attachmentSyncState;
   2920 	},
   2921 	set: function(val) {
   2922 		if (!this.isAttachment()) {
   2923 			throw new Error("attachmentSyncState can only be set for attachment items");
   2924 		}
   2925 		
   2926 		if (typeof val == 'string') {
   2927 			val = Zotero.Sync.Storage.Local["SYNC_STATE_" + val.toUpperCase()];
   2928 		}
   2929 		
   2930 		switch (this.attachmentLinkMode) {
   2931 			case Zotero.Attachments.LINK_MODE_IMPORTED_URL:
   2932 			case Zotero.Attachments.LINK_MODE_IMPORTED_FILE:
   2933 				break;
   2934 				
   2935 			default:
   2936 				throw new Error("attachmentSyncState can only be set for stored files");
   2937 		}
   2938 		
   2939 		switch (val) {
   2940 			case Zotero.Sync.Storage.Local.SYNC_STATE_TO_UPLOAD:
   2941 			case Zotero.Sync.Storage.Local.SYNC_STATE_TO_DOWNLOAD:
   2942 			case Zotero.Sync.Storage.Local.SYNC_STATE_IN_SYNC:
   2943 			case Zotero.Sync.Storage.Local.SYNC_STATE_FORCE_UPLOAD:
   2944 			case Zotero.Sync.Storage.Local.SYNC_STATE_FORCE_DOWNLOAD:
   2945 			case Zotero.Sync.Storage.Local.SYNC_STATE_IN_CONFLICT:
   2946 				break;
   2947 				
   2948 			default:
   2949 				throw new Error("Invalid sync state '" + val + "'");
   2950 		}
   2951 		
   2952 		if (val == this.attachmentSyncState) {
   2953 			return;
   2954 		}
   2955 		
   2956 		if (!this._changed.attachmentData) {
   2957 			this._changed.attachmentData = {};
   2958 		}
   2959 		this._changed.attachmentData.syncState = true;
   2960 		this._attachmentSyncState = val;
   2961 	}
   2962 });
   2963 
   2964 
   2965 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentSyncedModificationTime', {
   2966 	get: function () {
   2967 		if (!this.isFileAttachment()) {
   2968 			return undefined;
   2969 		}
   2970 		return this._attachmentSyncedModificationTime;
   2971 	},
   2972 	set: function (val) {
   2973 		if (!this.isAttachment()) {
   2974 			throw ("attachmentSyncedModificationTime can only be set for attachment items");
   2975 		}
   2976 		
   2977 		switch (this.attachmentLinkMode) {
   2978 			case Zotero.Attachments.LINK_MODE_IMPORTED_URL:
   2979 			case Zotero.Attachments.LINK_MODE_IMPORTED_FILE:
   2980 				break;
   2981 				
   2982 			default:
   2983 				throw new Error("attachmentSyncedModificationTime can only be set for stored files");
   2984 		}
   2985 		
   2986 		if (typeof val != 'number') {
   2987 			throw new Error("attachmentSyncedModificationTime must be a number");
   2988 		}
   2989 		if (parseInt(val) != val || val < 0) {
   2990 			throw new Error("attachmentSyncedModificationTime must be a timestamp in milliseconds");
   2991 		}
   2992 		if (val < 10000000000) {
   2993 			Zotero.logError("attachmentSyncedModificationTime should be a timestamp in milliseconds "
   2994 				+ "-- " + val + " given");
   2995 		}
   2996 		
   2997 		if (val == this._attachmentSyncedModificationTime) {
   2998 			return;
   2999 		}
   3000 		
   3001 		if (!this._changed.attachmentData) {
   3002 			this._changed.attachmentData = {};
   3003 		}
   3004 		this._changed.attachmentData.syncedModificationTime = true;
   3005 		this._attachmentSyncedModificationTime = val;
   3006 	}
   3007 });
   3008 
   3009 
   3010 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentSyncedHash', {
   3011 	get: function () {
   3012 		if (!this.isFileAttachment()) {
   3013 			return undefined;
   3014 		}
   3015 		return this._attachmentSyncedHash;
   3016 	},
   3017 	set: function (val) {
   3018 		if (!this.isAttachment()) {
   3019 			throw ("attachmentSyncedHash can only be set for attachment items");
   3020 		}
   3021 		
   3022 		switch (this.attachmentLinkMode) {
   3023 			case Zotero.Attachments.LINK_MODE_IMPORTED_URL:
   3024 			case Zotero.Attachments.LINK_MODE_IMPORTED_FILE:
   3025 				break;
   3026 				
   3027 			default:
   3028 				throw new Error("attachmentSyncedHash can only be set for stored files");
   3029 		}
   3030 		
   3031 		if (val !== null && val.length != 32) {
   3032 			throw new Error("Invalid attachment hash '" + val + "'");
   3033 		}
   3034 		
   3035 		if (val == this._attachmentSyncedHash) {
   3036 			return;
   3037 		}
   3038 		
   3039 		if (!this._changed.attachmentData) {
   3040 			this._changed.attachmentData = {};
   3041 		}
   3042 		this._changed.attachmentData.syncedHash = true;
   3043 		this._attachmentSyncedHash = val;
   3044 	}
   3045 });
   3046 
   3047 
   3048 /**
   3049  * Modification time of an attachment file
   3050  *
   3051  * Note: This is the mod time of the file itself, not the last-known mod time
   3052  * of the file on the storage server as stored in the database
   3053  *
   3054  * @return {Promise<Number|undefined>} File modification time as timestamp in milliseconds,
   3055  *                                     or undefined if no file
   3056  */
   3057 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentModificationTime', {
   3058 	get: Zotero.Promise.coroutine(function* () {
   3059 		if (!this.isFileAttachment()) {
   3060 			return undefined;
   3061 		}
   3062 		
   3063 		if (!this.id) {
   3064 			return undefined;
   3065 		}
   3066 		
   3067 		var path = yield this.getFilePathAsync();
   3068 		if (!path) {
   3069 			return undefined;
   3070 		}
   3071 		
   3072 		var fmtime = ((yield OS.File.stat(path)).lastModificationDate).getTime();
   3073 		
   3074 		if (fmtime < 1) {
   3075 			Zotero.debug("File mod time " + fmtime + " is less than 1 -- interpreting as 1", 2);
   3076 			fmtime = 1;
   3077 		}
   3078 		
   3079 		return fmtime;
   3080 	})
   3081 });
   3082 
   3083 
   3084 /**
   3085  * MD5 hash of an attachment file
   3086  *
   3087  * Note: This is the hash of the file itself, not the last-known hash
   3088  * of the file on the storage server as stored in the database
   3089  *
   3090  * @return {Promise<String>} - MD5 hash of file as hex string
   3091  */
   3092 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentHash', {
   3093 	get: Zotero.Promise.coroutine(function* () {
   3094 		if (!this.isAttachment()) {
   3095 			return undefined;
   3096 		}
   3097 		
   3098 		if (!this.id) {
   3099 			return undefined;
   3100 		}
   3101 		
   3102 		var path = yield this.getFilePathAsync();
   3103 		if (!path) {
   3104 			return undefined;
   3105 		}
   3106 		
   3107 		return Zotero.Utilities.Internal.md5Async(path);
   3108 	})
   3109 });
   3110 
   3111 
   3112 
   3113 /**
   3114  * Return plain text of attachment content
   3115  *
   3116  * - Currently works on HTML, PDF and plaintext attachments
   3117  * - Paragraph breaks will be lost in PDF content
   3118  *
   3119  * @return {Promise<String>} - A promise for attachment text or empty string if unavailable
   3120  */
   3121 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentText', {
   3122 	get: Zotero.Promise.coroutine(function* () {
   3123 		if (!this.isAttachment()) {
   3124 			return undefined;
   3125 		}
   3126 		
   3127 		if (!this.id) {
   3128 			return null;
   3129 		}
   3130 		
   3131 		var file = this.getFile();
   3132 		
   3133 		if (!(yield OS.File.exists(file.path))) {
   3134 			file = false;
   3135 		}
   3136 		
   3137 		var cacheFile = Zotero.Fulltext.getItemCacheFile(this);
   3138 		if (!file) {
   3139 			if (cacheFile.exists()) {
   3140 				var str = yield Zotero.File.getContentsAsync(cacheFile);
   3141 				
   3142 				return str.trim();
   3143 			}
   3144 			return '';
   3145 		}
   3146 		
   3147 		var contentType = this.attachmentContentType;
   3148 		if (!contentType) {
   3149 			contentType = yield Zotero.MIME.getMIMETypeFromFile(file);
   3150 			if (contentType) {
   3151 				this.attachmentContentType = contentType;
   3152 				yield this.save();
   3153 			}
   3154 		}
   3155 		
   3156 		var str;
   3157 		if (Zotero.Fulltext.isCachedMIMEType(contentType)) {
   3158 			var reindex = false;
   3159 			
   3160 			if (!cacheFile.exists()) {
   3161 				Zotero.debug("Regenerating item " + this.id + " full-text cache file");
   3162 				reindex = true;
   3163 			}
   3164 			// Fully index item if it's not yet
   3165 			else if (!(yield Zotero.Fulltext.isFullyIndexed(this))) {
   3166 				Zotero.debug("Item " + this.id + " is not fully indexed -- caching now");
   3167 				reindex = true;
   3168 			}
   3169 			
   3170 			if (reindex) {
   3171 				yield Zotero.Fulltext.indexItems(this.id, false);
   3172 			}
   3173 			
   3174 			if (!cacheFile.exists()) {
   3175 				Zotero.debug("Cache file doesn't exist after indexing -- returning empty .attachmentText");
   3176 				return '';
   3177 			}
   3178 			str = yield Zotero.File.getContentsAsync(cacheFile);
   3179 		}
   3180 		
   3181 		else if (contentType == 'text/html') {
   3182 			str = yield Zotero.File.getContentsAsync(file);
   3183 			str = Zotero.Utilities.unescapeHTML(str);
   3184 		}
   3185 		
   3186 		else if (contentType == 'text/plain') {
   3187 			str = yield Zotero.File.getContentsAsync(file);
   3188 		}
   3189 		
   3190 		else {
   3191 			return '';
   3192 		}
   3193 		
   3194 		return str.trim();
   3195 	})
   3196 });
   3197 
   3198 
   3199 
   3200 /**
   3201  * Returns child attachments of this item
   3202  *
   3203  * @param	{Boolean}	includeTrashed		Include trashed child items
   3204  * @return	{Integer[]}						Array of itemIDs
   3205  */
   3206 Zotero.Item.prototype.getAttachments = function(includeTrashed) {
   3207 	if (this.isAttachment()) {
   3208 		throw new Error("getAttachments() cannot be called on attachment items");
   3209 	}
   3210 	
   3211 	this._requireData('childItems');
   3212 	
   3213 	if (!this._attachments) {
   3214 		return [];
   3215 	}
   3216 	
   3217 	var cacheKey = (Zotero.Prefs.get('sortAttachmentsChronologically') ? 'chronological' : 'alphabetical')
   3218 		+ 'With' + (includeTrashed ? '' : 'out') + 'Trashed';
   3219 	
   3220 	if (this._attachments[cacheKey]) {
   3221 		return this._attachments[cacheKey];
   3222 	}
   3223 	
   3224 	var rows = this._attachments.rows.concat();
   3225 	// Remove trashed items if necessary
   3226 	if (!includeTrashed) {
   3227 		rows = rows.filter(row => !row.trashed);
   3228 	}
   3229 	// Sort by title if necessary
   3230 	if (!Zotero.Prefs.get('sortAttachmentsChronologically')) {
   3231 		var collation = Zotero.getLocaleCollation();
   3232 		rows.sort((a, b) => collation.compareString(1, a.title, b.title));
   3233 	}
   3234 	var ids = rows.map(row => row.itemID);
   3235 	this._attachments[cacheKey] = ids;
   3236 	return ids;
   3237 }
   3238 
   3239 
   3240 /**
   3241  * Looks for attachment in the following order: oldest PDF attachment matching parent URL,
   3242  * oldest non-PDF attachment matching parent URL, oldest PDF attachment not matching URL,
   3243  * old non-PDF attachment not matching URL
   3244  *
   3245  * @return {Promise<Zotero.Item|FALSE>} - A promise for attachment item or FALSE if none
   3246  */
   3247 Zotero.Item.prototype.getBestAttachment = Zotero.Promise.coroutine(function* () {
   3248 	if (!this.isRegularItem()) {
   3249 		throw ("getBestAttachment() can only be called on regular items");
   3250 	}
   3251 	var attachments = yield this.getBestAttachments();
   3252 	return attachments ? attachments[0] : false;
   3253 });
   3254 
   3255 
   3256 /**
   3257  * Looks for attachment in the following order: oldest PDF attachment matching parent URL,
   3258  * oldest PDF attachment not matching parent URL, oldest non-PDF attachment matching parent URL,
   3259  * old non-PDF attachment not matching parent URL
   3260  *
   3261  * @return {Promise<Zotero.Item[]>} - A promise for an array of Zotero items
   3262  */
   3263 Zotero.Item.prototype.getBestAttachments = Zotero.Promise.coroutine(function* () {
   3264 	if (!this.isRegularItem()) {
   3265 		throw new Error("getBestAttachments() can only be called on regular items");
   3266 	}
   3267 	
   3268 	var url = this.getField('url');
   3269 	
   3270 	var sql = "SELECT IA.itemID FROM itemAttachments IA NATURAL JOIN items I "
   3271 		+ "LEFT JOIN itemData ID ON (IA.itemID=ID.itemID AND fieldID=1) "
   3272 		+ "LEFT JOIN itemDataValues IDV ON (ID.valueID=IDV.valueID) "
   3273 		+ "WHERE parentItemID=? AND linkMode NOT IN (?) "
   3274 		+ "AND IA.itemID NOT IN (SELECT itemID FROM deletedItems) "
   3275 		+ "ORDER BY contentType='application/pdf' DESC, value=? DESC, dateAdded ASC";
   3276 	var itemIDs = yield Zotero.DB.columnQueryAsync(sql, [this.id, Zotero.Attachments.LINK_MODE_LINKED_URL, url]);
   3277 	return this.ObjectsClass.get(itemIDs);
   3278 });
   3279 
   3280 
   3281 
   3282 /**
   3283  * Return state of best attachment
   3284  *
   3285  * @return {Promise<Integer>}  Promise for 0 (none), 1 (present), -1 (missing)
   3286  */
   3287 Zotero.Item.prototype.getBestAttachmentState = Zotero.Promise.coroutine(function* () {
   3288 	if (this._bestAttachmentState !== null) {
   3289 		return this._bestAttachmentState;
   3290 	}
   3291 	var item = yield this.getBestAttachment();
   3292 	if (item) {
   3293 		let exists = yield item.fileExists();
   3294 		return this._bestAttachmentState = exists ? 1 : -1;
   3295 	}
   3296 	return this._bestAttachmentState = 0;
   3297 });
   3298 
   3299 
   3300 /**
   3301  * Return cached state of best attachment for use in items view
   3302  *
   3303  * @return {Integer|null}  0 (none), 1 (present), -1 (missing), null (unavailable)
   3304  */
   3305 Zotero.Item.prototype.getBestAttachmentStateCached = function () {
   3306 	return this._bestAttachmentState;
   3307 }
   3308 
   3309 
   3310 Zotero.Item.prototype.clearBestAttachmentState = function () {
   3311 	this._bestAttachmentState = null;
   3312 }
   3313 
   3314 
   3315 //
   3316 // Methods dealing with item tags
   3317 //
   3318 //
   3319 /**
   3320  * Returns all tags assigned to an item
   3321  *
   3322  * @return {Array} Array of tag data in API JSON format
   3323  */
   3324 Zotero.Item.prototype.getTags = function () {
   3325 	this._requireData('tags');
   3326 	// BETTER DEEP COPY?
   3327 	return JSON.parse(JSON.stringify(this._changedData.tags || this._tags));
   3328 };
   3329 
   3330 
   3331 /**
   3332  * Check if the item has a given tag
   3333  *
   3334  * @param {String}
   3335  * @return {Boolean}
   3336  */
   3337 Zotero.Item.prototype.hasTag = function (tagName) {
   3338 	this._requireData('tags');
   3339 	var tags = this._changedData.tags || this._tags;
   3340 	return tags.some(tagData => tagData.tag == tagName);
   3341 }
   3342 
   3343 
   3344 /**
   3345  * Get the assigned type for a given tag of the item
   3346  */
   3347 Zotero.Item.prototype.getTagType = function (tagName) {
   3348 	this._requireData('tags');
   3349 	var tags = this._changedData.tags || this._tags;
   3350 	for (let tag of tags) {
   3351 		if (tag.tag === tagName) {
   3352 			return tag.type ? tag.type : 0;
   3353 		}
   3354 	}
   3355 	return null;
   3356 }
   3357 
   3358 
   3359 /**
   3360  * Set the item's tags
   3361  *
   3362  * A separate save() is required to update the database.
   3363  *
   3364  * @param {String[]|Object[]} tags - Array of strings or object in API JSON format
   3365  *                                   (e.g., [{tag: 'tag', type: 1}])
   3366  */
   3367 Zotero.Item.prototype.setTags = function (tags) {
   3368 	this._requireData('tags');
   3369 	var oldTags = this._changedData.tags || this._tags;
   3370 	var newTags = tags.concat()
   3371 		// Allow array of strings
   3372 		.map(tag => typeof tag == 'string' ? { tag } : tag);
   3373 	for (let i=0; i<oldTags.length; i++) {
   3374 		oldTags[i] = Zotero.Tags.cleanData(oldTags[i]);
   3375 	}
   3376 	for (let i=0; i<newTags.length; i++) {
   3377 		newTags[i] = Zotero.Tags.cleanData(newTags[i]);
   3378 	}
   3379 	
   3380 	// Sort to allow comparison with JSON, which maybe we'll stop doing if it's too slow
   3381 	var sorter = function (a, b) {
   3382 		if (a.type < b.type) return -1;
   3383 		if (a.type > b.type) return 1;
   3384 		return a.tag.localeCompare(b.tag);
   3385 	};
   3386 	oldTags.sort(sorter);
   3387 	newTags.sort(sorter);
   3388 	
   3389 	if (JSON.stringify(oldTags) == JSON.stringify(newTags)) {
   3390 		Zotero.debug("Tags haven't changed", 4);
   3391 		return;
   3392 	}
   3393 	
   3394 	this._markFieldChange('tags', newTags);
   3395 }
   3396 
   3397 
   3398 /**
   3399  * Add a single tag to the item. If type is 1 and an automatic tag with the same name already
   3400  * exists, replace it with a manual one.
   3401  *
   3402  * A separate save() is required to update the database.
   3403  *
   3404  * @param {String} name
   3405  * @param {Number} [type=0]
   3406  */
   3407 Zotero.Item.prototype.addTag = function (name, type) {
   3408 	type = type ? parseInt(type) : 0;
   3409 	
   3410 	var changed = false;
   3411 	var tags = this.getTags();
   3412 	for (let i=0; i<tags.length; i++) {
   3413 		let tag = tags[i];
   3414 		if (tag.tag === name) {
   3415 			if (tag.type == type) {
   3416 				Zotero.debug("Tag '" + name + "' already exists on item " + this.libraryKey);
   3417 				return false;
   3418 			}
   3419 			tag.type = type;
   3420 			changed = true;
   3421 			break;
   3422 		}
   3423 	}
   3424 	if (!changed) {
   3425 		tags.push({
   3426 			tag: name,
   3427 			type: type
   3428 		});
   3429 	}
   3430 	this.setTags(tags);
   3431 	return true;
   3432 }
   3433 
   3434 
   3435 /**
   3436  * Replace an existing tag with a new manual tag
   3437  *
   3438  * A separate save() is required to update the database.
   3439  *
   3440  * @param {String} oldTag
   3441  * @param {String} newTag
   3442  */
   3443 Zotero.Item.prototype.replaceTag = function (oldTag, newTag) {
   3444 	var tags = this.getTags();
   3445 	newTag = newTag.trim();
   3446 	
   3447 	if (newTag === "") {
   3448 		Zotero.debug('Not replacing with empty tag', 2);
   3449 		return false;
   3450 	}
   3451 	
   3452 	var changed = false;
   3453 	for (let i=0; i<tags.length; i++) {
   3454 		let tag = tags[i];
   3455 		if (tag.tag === oldTag) {
   3456 			tag.tag = newTag;
   3457 			tag.type = 0;
   3458 			changed = true;
   3459 		}
   3460 	}
   3461 	if (!changed) {
   3462 		Zotero.debug("Tag '" + oldTag + "' not found on item -- not replacing", 2);
   3463 		return false;
   3464 	}
   3465 	this.setTags(tags);
   3466 	return true;
   3467 }
   3468 
   3469 
   3470 /**
   3471  * Remove a tag from the item
   3472  *
   3473  * A separate save() is required to update the database.
   3474  */
   3475 Zotero.Item.prototype.removeTag = function(tagName) {
   3476 	this._requireData('tags');
   3477 	var oldTags = this._changedData.tags || this._tags;
   3478 	var newTags = oldTags.filter(tagData => tagData.tag !== tagName);
   3479 	if (newTags.length == oldTags.length) {
   3480 		Zotero.debug('Cannot remove missing tag ' + tagName + ' from item ' + this.libraryKey);
   3481 		return;
   3482 	}
   3483 	this.setTags(newTags);
   3484 }
   3485 
   3486 
   3487 /**
   3488  * Remove all tags from the item
   3489  *
   3490  * A separate save() is required to update the database.
   3491  */
   3492 Zotero.Item.prototype.removeAllTags = function() {
   3493 	this._requireData('tags');
   3494 	this.setTags([]);
   3495 }
   3496 
   3497 
   3498 //
   3499 // Methods dealing with collections
   3500 //
   3501 /**
   3502  * Gets the collections the item is in
   3503  *
   3504  * @return {Array<Integer>}  An array of collectionIDs for all collections the item belongs to
   3505  */
   3506 Zotero.Item.prototype.getCollections = function () {
   3507 	this._requireData('collections');
   3508 	return this._collections.concat();
   3509 };
   3510 
   3511 
   3512 /**
   3513  * Sets the collections the item is in
   3514  *
   3515  * A separate save() (with options.skipDateModifiedUpdate, possibly) is required to save changes.
   3516  *
   3517  * @param {Array<String|Integer>} collectionIDsOrKeys Collection ids or keys
   3518  */
   3519 Zotero.Item.prototype.setCollections = function (collectionIDsOrKeys) {
   3520 	if (!this.libraryID) {
   3521 		this.libraryID = Zotero.Libraries.userLibraryID;
   3522 	}
   3523 	
   3524 	this._requireData('collections');
   3525 	
   3526 	if (!collectionIDsOrKeys) {
   3527 		collectionIDsOrKeys = [];
   3528 	}
   3529 	
   3530 	// Convert any keys to ids
   3531 	var collectionIDs = collectionIDsOrKeys.map(function (val) {
   3532 		if (parseInt(val) == val) {
   3533 			return parseInt(val);
   3534 		}
   3535 		var id = this.ContainerObjectsClass.getIDFromLibraryAndKey(this.libraryID, val);
   3536 		if (!id) {
   3537 			let e = new Error("Collection " + val + " not found for item " + this.libraryKey);
   3538 			e.name = "ZoteroMissingObjectError";
   3539 			throw e;
   3540 		}
   3541 		return id;
   3542 	}.bind(this));
   3543 	collectionIDs = Zotero.Utilities.arrayUnique(collectionIDs);
   3544 	
   3545 	if (Zotero.Utilities.arrayEquals(this._collections, collectionIDs)) {
   3546 		Zotero.debug("Collections have not changed for item " + this.id);
   3547 		return;
   3548 	}
   3549 	
   3550 	this._markFieldChange("collections", this._collections);
   3551 	this._collections = collectionIDs;
   3552 	this._changed.collections = true;
   3553 };
   3554 
   3555 
   3556 /**
   3557  * Add this item to a collection
   3558  *
   3559  * A separate save() (with options.skipDateModifiedUpdate, possibly) is required to save changes.
   3560  *
   3561  * @param {Number} collectionID
   3562  */
   3563 Zotero.Item.prototype.addToCollection = function (collectionIDOrKey) {
   3564 	if (!this.libraryID) {
   3565 		this.libraryID = Zotero.Libraries.userLibraryID;
   3566 	}
   3567 	
   3568 	var collectionID = parseInt(collectionIDOrKey) == collectionIDOrKey
   3569 			? parseInt(collectionIDOrKey)
   3570 			: this.ContainerObjectsClass.getIDFromLibraryAndKey(this.libraryID, collectionIDOrKey)
   3571 	
   3572 	if (!collectionID) {
   3573 		throw new Error("Invalid collection '" + collectionIDOrKey + "'");
   3574 	}
   3575 	
   3576 	this._requireData('collections');
   3577 	if (this._collections.indexOf(collectionID) != -1) {
   3578 		Zotero.debug("Item is already in collection " + collectionID);
   3579 		return;
   3580 	}
   3581 	this.setCollections(this._collections.concat(collectionID));
   3582 };
   3583 
   3584 
   3585 /**
   3586  * Remove this item from a collection
   3587  *
   3588  * A separate save() (with options.skipDateModifiedUpdate, possibly) is required to save changes.
   3589  *
   3590  * @param {Number} collectionID
   3591  */
   3592 Zotero.Item.prototype.removeFromCollection = function (collectionIDOrKey) {
   3593 	if (!this.libraryID) {
   3594 		this.libraryID = Zotero.Libraries.userLibraryID;
   3595 	}
   3596 	
   3597 	var collectionID = parseInt(collectionIDOrKey) == collectionIDOrKey
   3598 			? parseInt(collectionIDOrKey)
   3599 			: this.ContainerObjectsClass.getIDFromLibraryAndKey(this.libraryID, collectionIDOrKey)
   3600 	
   3601 	if (!collectionID) {
   3602 		throw new Error("Invalid collection '" + collectionIDOrKey + "'");
   3603 	}
   3604 	
   3605 	this._requireData('collections');
   3606 	var pos = this._collections.indexOf(collectionID);
   3607 	if (pos == -1) {
   3608 		Zotero.debug("Item is not in collection " + collectionID);
   3609 		return;
   3610 	}
   3611 	this.setCollections(this._collections.slice(0, pos).concat(this._collections.slice(pos + 1)));
   3612 };
   3613 
   3614 
   3615 /**
   3616 * Determine whether the item belongs to a given collectionID
   3617 **/
   3618 Zotero.Item.prototype.inCollection = function (collectionID) {
   3619 	this._requireData('collections');
   3620 	return this._collections.indexOf(collectionID) != -1;
   3621 };
   3622 
   3623 
   3624 /**
   3625  * Update item deleted (i.e., trash) state without marking as changed or modifying DB
   3626  *
   3627  * This is used by Zotero.Items.trash().
   3628  *
   3629  * Database state must be set separately!
   3630  *
   3631  * @param {Boolean} deleted
   3632  */
   3633 Zotero.DataObject.prototype.setDeleted = Zotero.Promise.coroutine(function* (deleted) {
   3634 	if (!this.id) {
   3635 		throw new Error("Cannot update deleted state of unsaved item");
   3636 	}
   3637 	
   3638 	this._deleted = !!deleted;
   3639 	
   3640 	if (this._changed.deleted) {
   3641 		delete this._changed.deleted;
   3642 	}
   3643 });
   3644 
   3645 
   3646 /**
   3647  * Update item publications state without marking as changed or modifying DB
   3648  *
   3649  * This is used by Zotero.Items.addToPublications()/removeFromPublications()
   3650  *
   3651  * Database state must be set separately!
   3652  *
   3653  * @param {Boolean} inPublications
   3654  */
   3655 Zotero.DataObject.prototype.setPublications = Zotero.Promise.coroutine(function* (inPublications) {
   3656 	if (!this.id) {
   3657 		throw new Error("Cannot update publications state of unsaved item");
   3658 	}
   3659 	
   3660 	this._inPublications = !!inPublications;
   3661 	
   3662 	if (this._changed.inPublications) {
   3663 		delete this._changed.inPublications;
   3664 	}
   3665 });
   3666 
   3667 
   3668 Zotero.Item.prototype.getImageSrc = function() {
   3669 	var itemType = Zotero.ItemTypes.getName(this.itemTypeID);
   3670 	if (itemType == 'attachment') {
   3671 		var linkMode = this.attachmentLinkMode;
   3672 		
   3673 		// Quick hack to use PDF icon for imported files and URLs --
   3674 		// extend to support other document types later
   3675 		if ((linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE ||
   3676 				linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL) &&
   3677 				this.attachmentContentType == 'application/pdf') {
   3678 			itemType += '-pdf';
   3679 		}
   3680 		else if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE) {
   3681 			itemType += "-file";
   3682 		}
   3683 		else if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
   3684 			itemType += "-link";
   3685 		}
   3686 		else if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL) {
   3687 			itemType += "-snapshot";
   3688 		}
   3689 		else if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
   3690 			itemType += "-web-link";
   3691 		}
   3692 	}
   3693 	
   3694 	return Zotero.ItemTypes.getImageSrc(itemType);
   3695 }
   3696 
   3697 
   3698 Zotero.Item.prototype.getImageSrcWithTags = Zotero.Promise.coroutine(function* () {
   3699 	//Zotero.debug("Generating tree image for item " + this.id);
   3700 	
   3701 	var uri = this.getImageSrc();
   3702 	
   3703 	var tags = this.getTags();
   3704 	if (!tags.length) {
   3705 		return uri;
   3706 	}
   3707 	
   3708 	var tagColors = Zotero.Tags.getColors(this.libraryID);
   3709 	var colorData = [];
   3710 	for (let i=0; i<tags.length; i++) {
   3711 		let tag = tags[i];
   3712 		let data = tagColors.get(tag.tag);
   3713 		if (data) {
   3714 			colorData.push(data);
   3715 		}
   3716 	}
   3717 	if (!colorData.length) {
   3718 		return uri;
   3719 	}
   3720 	colorData.sort(function (a, b) {
   3721 		return a.position - b.position;
   3722 	});
   3723 	var colors = colorData.map(val => val.color);
   3724 	return Zotero.Tags.generateItemsListImage(colors, uri);
   3725 });
   3726 
   3727 
   3728 
   3729 /**
   3730  * Compares this item to another
   3731  *
   3732  * Returns a two-element array containing two objects with the differing values,
   3733  * or FALSE if no differences
   3734  *
   3735  * @param	{Zotero.Item}	item						Zotero.Item to compare this item to
   3736  * @param	{Boolean}		includeMatches			Include all fields, even those that aren't different
   3737  * @param	{Boolean}		ignoreFields			If no fields other than those specified
   3738  *														are different, just return false --
   3739  *														only works for primary fields
   3740  */
   3741 Zotero.Item.prototype.diff = function (item, includeMatches, ignoreFields) {
   3742 	var diff = [];
   3743 	
   3744 	if (!ignoreFields) {
   3745 		ignoreFields = [];
   3746 	}
   3747 	
   3748 	var thisData = this.serialize();
   3749 	var otherData = item.serialize();
   3750 	
   3751 	var numDiffs = this.ObjectsClass.diff(thisData, otherData, diff, includeMatches);
   3752 	
   3753 	diff[0].creators = [];
   3754 	diff[1].creators = [];
   3755 	// TODO: creators?
   3756 	// TODO: tags?
   3757 	// TODO: related?
   3758 	// TODO: annotations
   3759 	
   3760 	var changed = false;
   3761 	
   3762 	changed = thisData.parentKey != otherData.parentKey;
   3763 	if (includeMatches || changed) {
   3764 		diff[0].parentKey = thisData.parentKey;
   3765 		diff[1].parentKey = otherData.parentKey;
   3766 		
   3767 		if (changed) {
   3768 			numDiffs++;
   3769 		}
   3770 	}
   3771 	
   3772 	if (thisData.attachment) {
   3773 		for (var field in thisData.attachment) {
   3774 			changed = thisData.attachment[field] != otherData.attachment[field];
   3775 			if (includeMatches || changed) {
   3776 				if (!diff[0].attachment) {
   3777 					diff[0].attachment = {};
   3778 					diff[1].attachment = {};
   3779 				}
   3780 				diff[0].attachment[field] = thisData.attachment[field];
   3781 				diff[1].attachment[field] = otherData.attachment[field];
   3782 			}
   3783 			
   3784 			if (changed) {
   3785 				numDiffs++;
   3786 			}
   3787 		}
   3788 	}
   3789 	
   3790 	if (thisData.note != undefined) {
   3791 		// Whitespace and entity normalization
   3792 		//
   3793 		// Ideally this would all be fixed elsewhere so we didn't have to
   3794 		// convert on every sync diff
   3795 		//
   3796 		// TEMP: Using a try/catch to avoid unexpected errors in 2.1 releases
   3797 		try {
   3798 			var thisNote = thisData.note;
   3799 			var otherNote = otherData.note;
   3800 			
   3801 			// Stop non-Unix newlines from triggering erroneous conflicts
   3802 			thisNote = thisNote.replace(/\r\n?/g, "\n");
   3803 			otherNote = otherNote.replace(/\r\n?/g, "\n");
   3804 			
   3805 			// Normalize multiple spaces (due to differences TinyMCE, Z.U.text2html(),
   3806 			// and the server)
   3807 			var re = /(&nbsp; |&nbsp;&nbsp;|\u00a0 |\u00a0\u00a0)/g;
   3808 			thisNote = thisNote.replace(re, "  ");
   3809 			otherNote = otherNote.replace(re, "  ");
   3810 			
   3811 			// Normalize new paragraphs
   3812 			var re = /<p>(&nbsp;|\u00a0)<\/p>/g;
   3813 			thisNote = thisNote.replace(re, "<p> </p>");
   3814 			otherNote = otherNote.replace(re, "<p> </p>");
   3815 			
   3816 			// Unencode XML entities
   3817 			thisNote = thisNote.replace(/&amp;/g, "&");
   3818 			otherNote = otherNote.replace(/&amp;/g, "&");
   3819 			thisNote = thisNote.replace(/&apos;/g, "'");
   3820 			otherNote = otherNote.replace(/&apos;/g, "'");
   3821 			thisNote = thisNote.replace(/&quot;/g, '"');
   3822 			otherNote = otherNote.replace(/&quot;/g, '"');
   3823 			thisNote = thisNote.replace(/&lt;/g, "<");
   3824 			otherNote = otherNote.replace(/&lt;/g, "<");
   3825 			thisNote = thisNote.replace(/&gt;/g, ">");
   3826 			otherNote = otherNote.replace(/&gt;/g, ">");
   3827 			
   3828 			changed = thisNote != otherNote;
   3829 		}
   3830 		catch (e) {
   3831 			Zotero.debug(e);
   3832 			Components.utils.reportError(e);
   3833 			changed = thisNote != otherNote;
   3834 		}
   3835 		
   3836 		if (includeMatches || changed) {
   3837 			diff[0].note = thisNote;
   3838 			diff[1].note = otherNote;
   3839 		}
   3840 		
   3841 		if (changed) {
   3842 			numDiffs++;
   3843 		}
   3844 	}
   3845 	
   3846 	//Zotero.debug(thisData);
   3847 	//Zotero.debug(otherData);
   3848 	//Zotero.debug(diff);
   3849 	
   3850 	if (numDiffs == 0) {
   3851 		return false;
   3852 	}
   3853 	if (ignoreFields.length && diff[0].primary) {
   3854 		if (includeMatches) {
   3855 			throw ("ignoreFields cannot be used if includeMatches is set");
   3856 		}
   3857 		var realDiffs = numDiffs;
   3858 		for (let field of ignoreFields) {
   3859 			if (diff[0].primary[field] != undefined) {
   3860 				realDiffs--;
   3861 				if (realDiffs == 0) {
   3862 					return false;
   3863 				}
   3864 			}
   3865 		}
   3866 	}
   3867 	
   3868 	return diff;
   3869 }
   3870 
   3871 
   3872 /**
   3873  * Compare multiple items against this item and return fields that differ
   3874  *
   3875  * Currently compares only item data, not primary fields
   3876  */
   3877 Zotero.Item.prototype.multiDiff = function (otherItems, ignoreFields) {
   3878 	var thisData = this.toJSON();
   3879 	
   3880 	var alternatives = {};
   3881 	var hasDiffs = false;
   3882 	
   3883 	for (let i = 0; i < otherItems.length; i++) {
   3884 		let otherData = otherItems[i].toJSON();
   3885 		let changeset = Zotero.DataObjectUtilities.diff(thisData, otherData, ignoreFields);
   3886 		
   3887 		for (let i = 0; i < changeset.length; i++) {
   3888 			let change = changeset[i];
   3889 			
   3890 			if (change.op == 'delete') {
   3891 				continue;
   3892 			}
   3893 			
   3894 			if (!alternatives[change.field]) {
   3895 				hasDiffs = true;
   3896 				alternatives[change.field] = [change.value];
   3897 			}
   3898 			else if (alternatives[change.field].indexOf(change.value) == -1) {
   3899 				hasDiffs = true;
   3900 				alternatives[change.field].push(change.value);
   3901 			}
   3902 		}
   3903 	}
   3904 	
   3905 	if (!hasDiffs) {
   3906 		return false;
   3907 	}
   3908 	
   3909 	return alternatives;
   3910 };
   3911 
   3912 
   3913 /**
   3914  * Returns an unsaved copy of the item without itemID and key
   3915  *
   3916  * This is used to duplicate items and copy them between libraries.
   3917  *
   3918  * @param {Number} [libraryID] - libraryID of the new item, or the same as original if omitted
   3919  * @param {Boolean} [options.skipTags=false] - Skip tags
   3920  * @param {Boolean} [options.includeCollections=false] - Add new item to all collections
   3921  * @return {Promise<Zotero.Item>}
   3922  */
   3923 Zotero.Item.prototype.clone = function (libraryID, options = {}) {
   3924 	Zotero.debug('Cloning item ' + this.id);
   3925 	
   3926 	if (libraryID !== undefined && libraryID !== null && typeof libraryID !== 'number') {
   3927 		throw new Error("libraryID must be null or an integer");
   3928 	}
   3929 	
   3930 	if (libraryID === undefined || libraryID === null) {
   3931 		libraryID = this.libraryID;
   3932 	}
   3933 	var sameLibrary = libraryID == this.libraryID;
   3934 	
   3935 	var newItem = new Zotero.Item;
   3936 	newItem.libraryID = libraryID;
   3937 	newItem.setType(this.itemTypeID);
   3938 	
   3939 	var fieldIDs = this.getUsedFields();
   3940 	for (let i = 0; i < fieldIDs.length; i++) {
   3941 		let fieldID = fieldIDs[i];
   3942 		newItem.setField(fieldID, this.getField(fieldID));
   3943 	}
   3944 	
   3945 	// Regular item
   3946 	if (this.isRegularItem()) {
   3947 		newItem.setCreators(this.getCreators());
   3948 	}
   3949 	else {
   3950 		newItem.setNote(this.getNote());
   3951 		if (sameLibrary) {
   3952 			var parent = this.parentKey;
   3953 			if (parent) {
   3954 				newItem.parentKey = parent;
   3955 			}
   3956 		}
   3957 		
   3958 		if (this.isAttachment()) {
   3959 			newItem.attachmentLinkMode = this.attachmentLinkMode;
   3960 			newItem.attachmentContentType = this.attachmentContentType;
   3961 			newItem.attachmentCharset = this.attachmentCharset;
   3962 			if (sameLibrary) {
   3963 				if (this.attachmentPath) {
   3964 					newItem.attachmentPath = this.attachmentPath;
   3965 				}
   3966 			}
   3967 		}
   3968 	}
   3969 	
   3970 	if (!options.skipTags) {
   3971 		newItem.setTags(this.getTags());
   3972 	}
   3973 	
   3974 	if (options.includeCollections) {
   3975 		if (!sameLibrary) {
   3976 			throw new Error("Can't include collections when cloning to different library");
   3977 		}
   3978 		newItem.setCollections(this.getCollections());
   3979 	}
   3980 	
   3981 	if (sameLibrary) {
   3982 		// DEBUG: this will add reverse-only relateds too
   3983 		newItem.setRelations(this.getRelations());
   3984 	}
   3985 	
   3986 	return newItem;
   3987 }
   3988 
   3989 
   3990 /**
   3991  * @param {Zotero.Item} item
   3992  * @param {Integer} libraryID
   3993  * @return {Zotero.Item} - New item
   3994  */
   3995 Zotero.Item.prototype.moveToLibrary = async function (libraryID, onSkippedAttachment) {
   3996 	if (!this.isEditable) {
   3997 		throw new Error("Can't move item in read-only library");
   3998 	}
   3999 	var library = Zotero.Libraries.get(libraryID);
   4000 	Zotero.debug("Moving item to " + library.name);
   4001 	if (!library.editable) {
   4002 		throw new Error("Can't move item to read-only library");
   4003 	}
   4004 	var filesEditable = library.filesEditable;
   4005 	var allowsLinkedFiles = library.allowsLinkedFiles;
   4006 	
   4007 	var newItem = await Zotero.DB.executeTransaction(async function () {
   4008 		// Create new clone item in target library
   4009 		var newItem = this.clone(libraryID);
   4010 		var newItemID = await newItem.save({
   4011 			skipSelect: true
   4012 		});
   4013 		
   4014 		if (this.isNote()) {
   4015 			// Delete old item
   4016 			await this.erase();
   4017 			return newItem;
   4018 		}
   4019 		
   4020 		// For regular items, add child items
   4021 		
   4022 		// Child notes
   4023 		var noteIDs = this.getNotes();
   4024 		var notes = Zotero.Items.get(noteIDs);
   4025 		for (let note of notes) {
   4026 			let newNote = note.clone(libraryID);
   4027 			newNote.parentID = newItemID;
   4028 			await newNote.save({
   4029 				skipSelect: true
   4030 			});
   4031 		}
   4032 		
   4033 		// Child attachments
   4034 		var attachmentIDs = this.getAttachments();
   4035 		var attachments = Zotero.Items.get(attachmentIDs);
   4036 		for (let attachment of attachments) {
   4037 			let linkMode = attachment.attachmentLinkMode;
   4038 			
   4039 			// Skip linked files if not allowed in destination
   4040 			if (!allowsLinkedFiles && linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
   4041 				Zotero.debug("Target library doesn't support linked files -- skipping attachment");
   4042 				if (onSkippedAttachment) {
   4043 					await onSkippedAttachment(attachment);
   4044 				}
   4045 				continue;
   4046 			}
   4047 			
   4048 			// Skip files if not allowed in destination
   4049 			if (!filesEditable && linkMode != Zotero.Attachments.LINK_MODE_LINKED_URL) {
   4050 				Zotero.debug("Target library doesn't allow file editing -- skipping attachment");
   4051 				if (onSkippedAttachment) {
   4052 					await onSkippedAttachment(attachment);
   4053 				}
   4054 				continue;
   4055 			}
   4056 			
   4057 			await Zotero.Attachments.moveAttachmentToLibrary(
   4058 				attachment, libraryID, newItemID
   4059 			);
   4060 		}
   4061 		
   4062 		return newItem;
   4063 	}.bind(this));
   4064 	
   4065 	// Delete old item. Do this outside of a transaction so we don't leave stranded files
   4066 	// in the target library if deleting fails.
   4067 	await this.eraseTx();
   4068 	
   4069 	return newItem;
   4070 };
   4071 
   4072 
   4073 Zotero.Item.prototype._eraseData = Zotero.Promise.coroutine(function* (env) {
   4074 	Zotero.DB.requireTransaction();
   4075 	
   4076 	// Remove item from parent collections
   4077 	var parentCollectionIDs = this._collections;
   4078 	for (let parentCollectionID of parentCollectionIDs) {
   4079 		let parentCollection = yield Zotero.Collections.getAsync(parentCollectionID);
   4080 		yield parentCollection.removeItem(this.id);
   4081 	}
   4082 	
   4083 	var parentItem = this.parentKey;
   4084 	parentItem = parentItem
   4085 		? (yield this.ObjectsClass.getByLibraryAndKeyAsync(this.libraryID, parentItem))
   4086 		: null;
   4087 	
   4088 	if (parentItem && !env.options.skipParentRefresh) {
   4089 		Zotero.Notifier.queue('refresh', 'item', parentItem.id);
   4090 	}
   4091 	
   4092 	// // Delete associated attachment files
   4093 	if (this.isAttachment()) {
   4094 		let linkMode = this.attachmentLinkMode;
   4095 		// If link only, nothing to delete
   4096 		if (linkMode != Zotero.Attachments.LINK_MODE_LINKED_URL) {
   4097 			try {
   4098 				let file = Zotero.Attachments.getStorageDirectory(this);
   4099 				yield OS.File.removeDir(file.path, {
   4100 					ignoreAbsent: true,
   4101 					ignorePermissions: true
   4102 				});
   4103 			}
   4104 			catch (e) {
   4105 				Zotero.debug(e, 2);
   4106 				Components.utils.reportError(e);
   4107 			}
   4108 		}
   4109 		
   4110 		// Zotero.Sync.EventListeners.ChangeListener needs to know if this was a storage file
   4111 		env.notifierData[this.id].storageDeleteLog = this.isImportedAttachment();
   4112 	}
   4113 	// Regular item
   4114 	else {
   4115 		let sql = "SELECT itemID FROM itemNotes WHERE parentItemID=?1 UNION "
   4116 			+ "SELECT itemID FROM itemAttachments WHERE parentItemID=?1";
   4117 		let toDelete = yield Zotero.DB.columnQueryAsync(sql, [this.id]);
   4118 		for (let i=0; i<toDelete.length; i++) {
   4119 			let obj = yield this.ObjectsClass.getAsync(toDelete[i]);
   4120 			// Copy all options other than 'tx', which would cause a deadlock
   4121 			let options = {
   4122 				skipParentRefresh: true
   4123 			};
   4124 			Object.assign(options, env.options);
   4125 			delete options.tx;
   4126 			yield obj.erase(options);
   4127 		}
   4128 	}
   4129 	
   4130 	// Remove related-item relations pointing to this item
   4131 	var relatedItems = Zotero.Relations.getByPredicateAndObject(
   4132 		'item', Zotero.Relations.relatedItemPredicate, Zotero.URI.getItemURI(this)
   4133 	);
   4134 	for (let relatedItem of relatedItems) {
   4135 		relatedItem.removeRelatedItem(this);
   4136 		relatedItem.save();
   4137 	}
   4138 	
   4139 	// Clear fulltext cache
   4140 	if (this.isAttachment()) {
   4141 		yield Zotero.Fulltext.clearItemWords(this.id);
   4142 		//Zotero.Fulltext.clearItemContent(this.id);
   4143 	}
   4144 	
   4145 	yield Zotero.DB.queryAsync('DELETE FROM items WHERE itemID=?', this.id);
   4146 	
   4147 	if (parentItem && !env.options.skipParentRefresh) {
   4148 		yield parentItem.reload(['primaryData', 'childItems'], true);
   4149 		parentItem.clearBestAttachmentState();
   4150 	}
   4151 	
   4152 	Zotero.Prefs.set('purge.items', true);
   4153 	Zotero.Prefs.set('purge.creators', true);
   4154 	Zotero.Prefs.set('purge.tags', true);
   4155 });
   4156 
   4157 
   4158 Zotero.Item.prototype.isCollection = function() {
   4159 	return false;
   4160 }
   4161 
   4162 
   4163 /**
   4164  * Populate the object's data from an API JSON data object
   4165  */
   4166 Zotero.Item.prototype.fromJSON = function (json) {
   4167 	if (!json.itemType && !this._itemTypeID) {
   4168 		throw new Error("itemType property not provided");
   4169 	}
   4170 	
   4171 	let itemTypeID = Zotero.ItemTypes.getID(json.itemType);
   4172 	if (!itemTypeID) {
   4173 		let e = new Error(`Invalid item type '${json.itemType}'`);
   4174 		e.name = "ZoteroUnknownTypeError";
   4175 		throw e;
   4176 	}
   4177 	this.setType(itemTypeID);
   4178 	
   4179 	var isValidForType = {};
   4180 	var setFields = {};
   4181 	
   4182 	// Primary data
   4183 	for (let field in json) {
   4184 		let val = json[field];
   4185 		
   4186 		switch (field) {
   4187 		case 'key':
   4188 		case 'version':
   4189 		case 'synced':
   4190 		case 'itemType':
   4191 		case 'note':
   4192 		// Use?
   4193 		case 'md5':
   4194 		case 'mtime':
   4195 		// Handled below
   4196 		case 'collections':
   4197 		case 'parentItem':
   4198 		case 'deleted':
   4199 		case 'inPublications':
   4200 			break;
   4201 		
   4202 		case 'accessDate':
   4203 			if (val && !Zotero.Date.isSQLDate(val)) {
   4204 				let d = Zotero.Date.isoToDate(val);
   4205 				if (!d) {
   4206 					Zotero.logError(`Discarding invalid ${field} '${val}' for item ${this.libraryKey}`);
   4207 					continue;
   4208 				}
   4209 				val = Zotero.Date.dateToSQL(d, true);
   4210 			}
   4211 			this.setField(field, val);
   4212 			setFields[field] = true;
   4213 			break;
   4214 		
   4215 		case 'dateAdded':
   4216 		case 'dateModified':
   4217 			if (val) {
   4218 				let d = Zotero.Date.isoToDate(val);
   4219 				if (!d) {
   4220 					Zotero.logError(`Discarding invalid ${field} '${val}' for item ${this.libraryKey}`);
   4221 					continue;
   4222 				}
   4223 				val = Zotero.Date.dateToSQL(d, true);
   4224 			}
   4225 			this[field] = val;
   4226 			break;
   4227 		
   4228 		case 'creators':
   4229 			this.setCreators(json.creators);
   4230 			break;
   4231 		
   4232 		case 'tags':
   4233 			this.setTags(json.tags);
   4234 			break;
   4235 		
   4236 		case 'relations':
   4237 			this.setRelations(json.relations);
   4238 			break;
   4239 		
   4240 		//
   4241 		// Attachment metadata
   4242 		//
   4243 		case 'linkMode':
   4244 			this.attachmentLinkMode = Zotero.Attachments["LINK_MODE_" + val.toUpperCase()];
   4245 			break;
   4246 		
   4247 		case 'contentType':
   4248 			this.attachmentContentType = val;
   4249 			break;
   4250 		
   4251 		case 'charset':
   4252 			this.attachmentCharset = val;
   4253 			break;
   4254 		
   4255 		case 'filename':
   4256 			if (val === "") {
   4257 				Zotero.logError("Ignoring empty attachment filename in JSON for item " + this.libraryKey);
   4258 			}
   4259 			else {
   4260 				this.attachmentFilename = val;
   4261 			}
   4262 			break;
   4263 		
   4264 		case 'path':
   4265 			this.attachmentPath = val;
   4266 			break;
   4267 		
   4268 		// Item fields
   4269 		default:
   4270 			let fieldID = Zotero.ItemFields.getID(field);
   4271 			if (!fieldID) {
   4272 				Zotero.logError("Discarding unknown JSON field '" + field + "' for item "
   4273 					+ this.libraryKey);
   4274 				continue;
   4275 			}
   4276 			// Convert to base-mapped field if necessary, so that setFields has the base-mapped field
   4277 			// when it's checked for values from getUsedFields() below
   4278 			let origFieldID = fieldID;
   4279 			let origField = field;
   4280 			fieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(itemTypeID, fieldID) || fieldID;
   4281 			if (origFieldID != fieldID) {
   4282 				field = Zotero.ItemFields.getName(fieldID);
   4283 			}
   4284 			isValidForType[field] = Zotero.ItemFields.isValidForType(fieldID, this.itemTypeID);
   4285 			if (!isValidForType[field]) {
   4286 				Zotero.logError("Discarding invalid field '" + origField + "' for type " + itemTypeID
   4287 					+ " for item " + this.libraryKey);
   4288 				continue;
   4289 			}
   4290 			this.setField(field, json[origField]);
   4291 			setFields[field] = true;
   4292 		}
   4293 	}
   4294 	
   4295 	if (json.collections || this._collections.length) {
   4296 		this.setCollections(json.collections);
   4297 	}
   4298 	
   4299 	// Clear existing fields not specified
   4300 	var previousFields = this.getUsedFields(true);
   4301 	for (let field of previousFields) {
   4302 		if (!setFields[field] && isValidForType[field] !== false) {
   4303 			this.setField(field, false);
   4304 		}
   4305 	}
   4306 	
   4307 	// Both notes and attachments might have parents and notes
   4308 	if (this.isNote() || this.isAttachment()) {
   4309 		let parentKey = json.parentItem;
   4310 		this.parentKey = parentKey ? parentKey : false;
   4311 		
   4312 		let note = json.note;
   4313 		this.setNote(note !== undefined ? note : "");
   4314 	}
   4315 	
   4316 	// Update boolean fields that might not be present in JSON
   4317 	['deleted', 'inPublications'].forEach(field => {
   4318 		if (json[field] || this[field]) {
   4319 			this[field] = !!json[field];
   4320 		}
   4321 	});
   4322 }
   4323 
   4324 
   4325 /**
   4326  * @param {Object} options
   4327  */
   4328 Zotero.Item.prototype.toJSON = function (options = {}) {
   4329 	var env = this._preToJSON(options);
   4330 	var mode = env.mode;
   4331 	
   4332 	var obj = env.obj = {};
   4333 	obj.key = this.key;
   4334 	obj.version = this.version;
   4335 	obj.itemType = Zotero.ItemTypes.getName(this.itemTypeID);
   4336 	
   4337 	// Fields
   4338 	for (let i in this._itemData) {
   4339 		let val = this.getField(i) + '';
   4340 		if (val !== '' || mode == 'full') {
   4341 			obj[Zotero.ItemFields.getName(i)] = val;
   4342 		}
   4343 	}
   4344 	
   4345 	// Creators
   4346 	if (this.isRegularItem()) {
   4347 		obj.creators = this.getCreatorsJSON();
   4348 	}
   4349 	else {
   4350 		var parent = this.parentKey;
   4351 		if (parent || mode == 'full') {
   4352 			obj.parentItem = parent ? parent : false;
   4353 		}
   4354 		
   4355 		// Attachment fields
   4356 		if (this.isAttachment()) {
   4357 			let linkMode = this.attachmentLinkMode;
   4358 			obj.linkMode = Zotero.Attachments.linkModeToName(linkMode);
   4359 			
   4360 			obj.contentType = this.attachmentContentType;
   4361 			obj.charset = this.attachmentCharset;
   4362 			
   4363 			if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
   4364 				obj.path = this.attachmentPath;
   4365 			}
   4366 			else if (linkMode != Zotero.Attachments.LINK_MODE_LINKED_URL) {
   4367 				obj.filename = this.attachmentFilename;
   4368 			}
   4369 			
   4370 			if (this.isImportedAttachment() && !options.skipStorageProperties) {
   4371 				if (options.syncedStorageProperties) {
   4372 					obj.mtime = this.attachmentSyncedModificationTime;
   4373 					obj.md5 = this.attachmentSyncedHash;
   4374 				}
   4375 				else {
   4376 					// TEMP
   4377 					//obj.mtime = (yield this.attachmentModificationTime) || null;
   4378 					//obj.md5 = (yield this.attachmentHash) || null;
   4379 				}
   4380 			}
   4381 		}
   4382 		
   4383 		// Notes and embedded attachment notes
   4384 		let note = this.getNote();
   4385 		if (note !== "" || mode == 'full' || (mode == 'new' && this.isNote())) {
   4386 			obj.note = note;
   4387 		}
   4388 	}
   4389 	
   4390 	// Tags
   4391 	obj.tags = [];
   4392 	var tags = this.getTags();
   4393 	for (let i=0; i<tags.length; i++) {
   4394 		obj.tags.push(tags[i]);
   4395 	}
   4396 	
   4397 	// Collections
   4398 	if (this.isTopLevelItem()) {
   4399 		obj.collections = this.getCollections().map(function (id) {
   4400 			var { libraryID, key } = this.ContainerObjectsClass.getLibraryAndKeyFromID(id);
   4401 			if (!key) {
   4402 				throw new Error("Item collection " + id + " not found");
   4403 			}
   4404 			return key;
   4405 		}.bind(this));
   4406 	}
   4407 	
   4408 	// My Publications
   4409 	if (this._inPublications
   4410 			// Include in 'full' mode, but only in My Library
   4411 			|| (mode == 'full' && this.library && this.library.libraryType == 'user')) {
   4412 		obj.inPublications = this._inPublications;
   4413 	}
   4414 	
   4415 	// Deleted
   4416 	let deleted = this.deleted;
   4417 	if (deleted || mode == 'full') {
   4418 		// Match what APIv3 returns, though it would be good to change this
   4419 		obj.deleted = deleted ? 1 : 0;
   4420 	}
   4421 	
   4422 	// Relations
   4423 	obj.relations = this.getRelations()
   4424 	
   4425 	if (obj.accessDate) obj.accessDate = Zotero.Date.sqlToISO8601(obj.accessDate);
   4426 	
   4427 	if (this.dateAdded) {
   4428 		obj.dateAdded = Zotero.Date.sqlToISO8601(this.dateAdded);
   4429 	}
   4430 	if (this.dateModified) {
   4431 		obj.dateModified = Zotero.Date.sqlToISO8601(this.dateModified);
   4432 	}
   4433 	
   4434 	var json = this._postToJSON(env);
   4435 	if (options.skipStorageProperties) {
   4436 		delete json.md5;
   4437 		delete json.mtime;
   4438 	}
   4439 	return json;
   4440 }
   4441 
   4442 
   4443 Zotero.Item.prototype.toResponseJSON = function (options = {}) {
   4444 	// Default to showing synced storage properties, since that's what the API does, and this function
   4445 	// is generally used to emulate the API
   4446 	if (options.syncedStorageProperties === undefined) {
   4447 		options.syncedStorageProperties = true;
   4448 	}
   4449 	
   4450 	var json = this.constructor._super.prototype.toResponseJSON.call(this, options);
   4451 	
   4452 	// creatorSummary
   4453 	var firstCreator = this.getField('firstCreator');
   4454 	if (firstCreator) {
   4455 		json.meta.creatorSummary = firstCreator;
   4456 	}
   4457 	// parsedDate
   4458 	var parsedDate = Zotero.Date.multipartToSQL(this.getField('date', true, true));
   4459 	if (parsedDate) {
   4460 		// 0000?
   4461 		json.meta.parsedDate = parsedDate;
   4462 	}
   4463 	// numChildren
   4464 	if (this.isRegularItem()) {
   4465 		json.meta.numChildren = this.numChildren();
   4466 	}
   4467 	return json;
   4468 };
   4469 
   4470 
   4471 //////////////////////////////////////////////////////////////////////////////
   4472 //
   4473 // Asynchronous load methods
   4474 //
   4475 //////////////////////////////////////////////////////////////////////////////
   4476 
   4477 
   4478 /**
   4479  * Return an item in the specified library equivalent to this item
   4480  *
   4481  * @return {Promise<Zotero.Item>}
   4482  */
   4483 Zotero.Item.prototype.getLinkedItem = function (libraryID, bidirectional) {
   4484 	return this._getLinkedObject(libraryID, bidirectional);
   4485 };
   4486 
   4487 
   4488 /**
   4489  * Add a linked-object relation pointing to the given item
   4490  *
   4491  * Does not require a separate save()
   4492  *
   4493  * @return {Promise}
   4494  */
   4495 Zotero.Item.prototype.addLinkedItem = Zotero.Promise.coroutine(function* (item) {
   4496 	return this._addLinkedObject(item);
   4497 });
   4498 
   4499 
   4500 //////////////////////////////////////////////////////////////////////////////
   4501 //
   4502 // Private methods
   4503 //
   4504 //////////////////////////////////////////////////////////////////////////////
   4505 /**
   4506  * Returns related items this item points to
   4507  *
   4508  * @return {String[]} - Keys of related items
   4509  */
   4510 Zotero.Item.prototype._getRelatedItems = function () {
   4511 	this._requireData('relations');
   4512 	
   4513 	var predicate = Zotero.Relations.relatedItemPredicate;
   4514 	
   4515 	var relatedItemURIs = this.getRelationsByPredicate(predicate);
   4516 	
   4517 	// Pull out object values from related-item relations, turn into items, and pull out keys
   4518 	var keys = [];
   4519 	for (let i=0; i<relatedItemURIs.length; i++) {
   4520 		let {libraryID, key} = Zotero.URI.getURIItemLibraryKey(relatedItemURIs[i]);
   4521 		if (key) {
   4522 			keys.push(key);
   4523 		}
   4524 	}
   4525 	return keys;
   4526 }
   4527 
   4528 
   4529 /**
   4530  * @return {Object} Return a copy of the creators, with additional 'id' properties
   4531  */
   4532 Zotero.Item.prototype._getOldCreators = function () {
   4533 	var oldCreators = {};
   4534 	for (i=0; i<this._creators.length; i++) {
   4535 		let old = {};
   4536 		for (let field in this._creators[i]) {
   4537 			old[field] = this._creators[i][field];
   4538 		}
   4539 		// Add 'id' property for efficient DB updates
   4540 		old.id = this._creatorIDs[i];
   4541 		oldCreators[i] = old;
   4542 	}
   4543 	return oldCreators;
   4544 }