www

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

dataObject.js (36758B)


      1 /*
      2     ***** BEGIN LICENSE BLOCK *****
      3     
      4     Copyright © 2013 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  * @property {String} (readOnly) objectType
     28  * @property {String} (readOnly) libraryKey
     29  * @property {String|false|undefined} parentKey - False if no parent, or undefined if not
     30  *                                                applicable (e.g. search objects)
     31  * @property {Integer|false|undefined} parentID - False if no parent, or undefined if not
     32  *                                                applicable (e.g. search objects)
     33  */
     34 
     35 Zotero.DataObject = function () {
     36 	let objectType = this._objectType;
     37 	this._ObjectType = objectType[0].toUpperCase() + objectType.substr(1);
     38 	this._objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
     39 	this._ObjectTypePlural = this._objectTypePlural[0].toUpperCase() + this._objectTypePlural.substr(1);
     40 	this._ObjectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
     41 	
     42 	this._id = null;
     43 	this._libraryID = null;
     44 	this._key = null;
     45 	this._dateAdded = null;
     46 	this._dateModified = null;
     47 	this._version = null;
     48 	this._synced = null;
     49 	this._identified = false;
     50 	this._parentID = null;
     51 	this._parentKey = null;
     52 	
     53 	this._relations = [];
     54 	
     55 	// Set in dataObjects.js
     56 	this._inCache = false;
     57 	
     58 	this._loaded = {};
     59 	this._skipDataTypeLoad = {};
     60 	this._markAllDataTypeLoadStates(false);
     61 	
     62 	this._clearChanged();
     63 };
     64 
     65 Zotero.DataObject.prototype._objectType = 'dataObject';
     66 Zotero.DataObject.prototype._dataTypes = ['primaryData'];
     67 
     68 Zotero.defineProperty(Zotero.DataObject.prototype, 'objectType', {
     69 	get: function() { return this._objectType; }
     70 });
     71 Zotero.defineProperty(Zotero.DataObject.prototype, 'id', {
     72 	get: function() { return this._id; }
     73 });
     74 Zotero.defineProperty(Zotero.DataObject.prototype, 'libraryID', {
     75 	get: function() { return this._libraryID; }
     76 });
     77 Zotero.defineProperty(Zotero.DataObject.prototype, 'library', {
     78 	get: function () {
     79 		return Zotero.Libraries.get(this._libraryID);
     80 	}
     81 });
     82 Zotero.defineProperty(Zotero.DataObject.prototype, 'key', {
     83 	get: function() { return this._key; }
     84 });
     85 Zotero.defineProperty(Zotero.DataObject.prototype, 'libraryKey', {
     86 	get: function() { return this._libraryID + "/" + this._key; }
     87 });
     88 Zotero.defineProperty(Zotero.DataObject.prototype, 'parentKey', {
     89 	get: function () { return this._getParentKey(); },
     90 	set: function(v) { return this._setParentKey(v); }
     91 });
     92 Zotero.defineProperty(Zotero.DataObject.prototype, 'parentID', {
     93 	get: function() { return this._getParentID(); },
     94 	set: function(v) { return this._setParentID(v); }
     95 });
     96 
     97 Zotero.defineProperty(Zotero.DataObject.prototype, '_canHaveParent', {
     98 	value: true
     99 });
    100 
    101 Zotero.defineProperty(Zotero.DataObject.prototype, 'ObjectsClass', {
    102 	get: function() { return this._ObjectsClass; }
    103 });
    104 
    105 
    106 Zotero.DataObject.prototype._get = function (field) {
    107 	if (field != 'id') this._disabledCheck();
    108 	
    109 	if (this['_' + field] !== null) {
    110 		return this['_' + field];
    111 	}
    112 	if (field != 'libraryID' && field != 'key' && field != 'id') {
    113 		this._requireData('primaryData');
    114 	}
    115 	return null;
    116 }
    117 
    118 
    119 Zotero.DataObject.prototype._set = function (field, value) {
    120 	this._disabledCheck();
    121 	
    122 	if (field == 'id' || field == 'libraryID' || field == 'key') {
    123 		return this._setIdentifier(field, value);
    124 	}
    125 	
    126 	this._requireData('primaryData');
    127 	
    128 	switch (field) {
    129 		case 'name':
    130 			value = value.trim().normalize();
    131 			break;
    132 		
    133 		case 'version':
    134 			value = parseInt(value);
    135 			break;
    136 		
    137 		case 'synced':
    138 			value = !!value;
    139 			break;
    140 	}
    141 	
    142 	if (this['_' + field] != value || field == 'synced') {
    143 		this._markFieldChange(field, this['_' + field]);
    144 		if (!this._changed.primaryData) {
    145 			this._changed.primaryData = {};
    146 		}
    147 		this._changed.primaryData[field] = true;
    148 		
    149 		switch (field) {
    150 			default:
    151 				this['_' + field] = value;
    152 		}
    153 	}
    154 }
    155 
    156 
    157 Zotero.DataObject.prototype._setIdentifier = function (field, value) {
    158 	switch (field) {
    159 	case 'id':
    160 		value = Zotero.DataObjectUtilities.checkDataID(value);
    161 		if (this._id) {
    162 			if (value === this._id) {
    163 				return;
    164 			}
    165 			throw new Error("ID cannot be changed");
    166 		}
    167 		if (this._key) {
    168 			throw new Error("Cannot set id if key is already set");
    169 		}
    170 		break;
    171 		
    172 	case 'libraryID':
    173 		value = Zotero.DataObjectUtilities.checkLibraryID(value);
    174 		break;
    175 		
    176 	case 'key':
    177 		if (this._libraryID === null) {
    178 			throw new Error("libraryID must be set before key");
    179 		}
    180 		value = Zotero.DataObjectUtilities.checkKey(value);
    181 		if (this._key) {
    182 			if (value === this._key) {
    183 				return;
    184 			}
    185 			throw new Error("Key cannot be changed");
    186 		}
    187 		if (this._id) {
    188 			throw new Error("Cannot set key if id is already set");
    189 		}
    190 	}
    191 	
    192 	if (value === this['_' + field]) {
    193 		return;
    194 	}
    195 	
    196 	// If primary data is loaded, the only allowed identifier change is libraryID, and then only
    197 	// for unidentified objects, and then only either if a libraryID isn't yet set (because
    198 	// primary data gets marked as loaded when fields are set for new items, but some methods
    199 	// (setCollections(), save()) automatically set the user library ID after that if none is
    200 	// specified), or for searches (for the sake of the library switcher in the advanced search
    201 	// window, though that could probably be rewritten)
    202 	if (this._loaded.primaryData) {
    203 		if (!(!this._identified && field == 'libraryID'
    204 				&& (!this._libraryID || this._objectType == 'search'))) {
    205 			throw new Error("Cannot change " + field + " after object is already loaded");
    206 		}
    207 	}
    208 	
    209 	if (field == 'id' || field == 'key') {
    210 		this._identified = true;
    211 	}
    212 	
    213 	this['_' + field] = value;
    214 }
    215 
    216 
    217 /**
    218  * Get the id of the parent object
    219  *
    220  * @return {Integer|false|undefined}  The id of the parent object, false if none, or undefined
    221  *                                      on object types to which it doesn't apply (e.g., searches)
    222  */
    223 Zotero.DataObject.prototype._getParentID = function () {
    224 	if (this._parentID !== null) {
    225 		return this._parentID;
    226 	}
    227 	if (!this._parentKey) {
    228 		if (this._objectType == 'search') {
    229 			return undefined;
    230 		}
    231 		return false;
    232 	}
    233 	return this._parentID = this.ObjectsClass.getIDFromLibraryAndKey(this._libraryID, this._parentKey);
    234 }
    235 
    236 
    237 /**
    238  * Set the id of the parent object
    239  *
    240  * @param {Number|false} [id=false]
    241  * @return {Boolean} True if changed, false if stayed the same
    242  */
    243 Zotero.DataObject.prototype._setParentID = function (id) {
    244 	return this._setParentKey(
    245 		id
    246 		? this.ObjectsClass.getLibraryAndKeyFromID(Zotero.DataObjectUtilities.checkDataID(id)).key
    247 		: false
    248 	);
    249 }
    250 
    251 
    252 Zotero.DataObject.prototype._getParentKey = function () {
    253 	if (!this._canHaveParent) {
    254 		return undefined;
    255 	}
    256 	return this._parentKey ? this._parentKey : false
    257 }
    258 
    259 /**
    260  * Set the key of the parent object
    261  *
    262  * @param {String|false} [key=false]
    263  * @return {Boolean} True if changed, false if stayed the same
    264  */
    265 Zotero.DataObject.prototype._setParentKey = function(key) {
    266 	if (!this._canHaveParent) {
    267 		throw new Error("Cannot set parent key for " + this._objectType);
    268 	}
    269 	
    270 	key = Zotero.DataObjectUtilities.checkKey(key) || false;
    271 	
    272 	if (key === this._parentKey || (!this._parentKey && !key)) {
    273 		return false;
    274 	}
    275 	this._markFieldChange('parentKey', this._parentKey);
    276 	this._changed.parentKey = true;
    277 	this._parentKey = key;
    278 	this._parentID = null;
    279 	return true;
    280 }
    281 
    282 //
    283 // Relations
    284 //
    285 /**
    286  * Returns all relations of the object
    287  *
    288  * @return {Object} - Object with predicates as keys and arrays of values
    289  */
    290 Zotero.DataObject.prototype.getRelations = function () {
    291 	this._requireData('relations');
    292 	
    293 	var relations = {};
    294 	for (let i=0; i<this._relations.length; i++) {
    295 		let rel = this._relations[i];
    296 		// Relations are stored internally as predicate-object pairs
    297 		let p = rel[0];
    298 		if (!relations[p]) {
    299 			relations[p] = [];
    300 		}
    301 		relations[p].push(rel[1]);
    302 	}
    303 	return relations;
    304 }
    305 
    306 
    307 /**
    308  * Returns all relations of the object with a given predicate
    309  *
    310  * @return {String[]} - URIs linked to this object with the given predicate
    311  */
    312 Zotero.DataObject.prototype.getRelationsByPredicate = function (predicate) {
    313 	this._requireData('relations');
    314 	
    315 	if (!predicate) {
    316 		throw new Error("Predicate not provided");
    317 	}
    318 	
    319 	var relations = [];
    320 	for (let i=0; i<this._relations.length; i++) {
    321 		let rel = this._relations[i];
    322 		// Relations are stored internally as predicate-object pairs
    323 		let p = rel[0];
    324 		if (p !== predicate) {
    325 			continue;
    326 		}
    327 		relations.push(rel[1]);
    328 	}
    329 	return relations;
    330 }
    331 
    332 
    333 /**
    334  * @return {Boolean} - True if the relation has been queued, false if it already exists
    335  */
    336 Zotero.DataObject.prototype.addRelation = function (predicate, object) {
    337 	this._requireData('relations');
    338 	
    339 	if (!predicate) {
    340 		throw new Error("Predicate not provided");
    341 	}
    342 	if (!object) {
    343 		throw new Error("Object not provided");
    344 	}
    345 	
    346 	for (let i = 0; i < this._relations.length; i++) {
    347 		let rel = this._relations[i];
    348 		if (rel[0] == predicate && rel[1] == object) {
    349 			Zotero.debug("Relation " + predicate + " - " + object + " already exists for "
    350 				+ this._objectType + " " + this.libraryKey);
    351 			return false;
    352 		}
    353 	}
    354 	
    355 	this._markFieldChange('relations', this._relations);
    356 	this._changed.relations = true;
    357 	this._relations.push([predicate, object]);
    358 	return true;
    359 }
    360 
    361 
    362 Zotero.DataObject.prototype.hasRelation = function (predicate, object) {
    363 	this._requireData('relations');
    364 	
    365 	for (let i = 0; i < this._relations.length; i++) {
    366 		let rel = this._relations[i];
    367 		if (rel[0] == predicate && rel[1] == object) {
    368 			return true
    369 		}
    370 	}
    371 	return false;
    372 }
    373 
    374 
    375 Zotero.DataObject.prototype.removeRelation = function (predicate, object) {
    376 	this._requireData('relations');
    377 	
    378 	for (let i = 0; i < this._relations.length; i++) {
    379 		let rel = this._relations[i];
    380 		if (rel[0] == predicate && rel[1] == object) {
    381 			Zotero.debug("Removing relation " + predicate + " - " + object + " from "
    382 				+ this._objectType + " " + this.libraryKey);
    383 			this._markFieldChange('relations', this._relations);
    384 			this._changed.relations = true;
    385 			this._relations.splice(i, 1);
    386 			return true;
    387 		}
    388 	}
    389 	
    390 	Zotero.debug("Relation " + predicate + " - " + object + " did not exist for "
    391 		+ this._objectType + " " + this.libraryKey);
    392 	return false;
    393 }
    394 
    395 
    396 /**
    397  * Updates the object's relations
    398  *
    399  * @param {Object} newRelations Object with predicates as keys and URI[] as values
    400  * @return {Boolean} True if changed, false if stayed the same
    401  */
    402 Zotero.DataObject.prototype.setRelations = function (newRelations) {
    403 	this._requireData('relations');
    404 	
    405 	if (typeof newRelations != 'object') {
    406 		throw new Error(`Relations must be an object (${typeof newRelations} given)`);
    407 	}
    408 	
    409 	var oldRelations = this._relations;
    410 	
    411 	// Limit predicates to letters and colons for now
    412 	for (let p in newRelations) {
    413 		if (!/^[a-z]+:[a-z]+$/i.test(p)) {
    414 			throw new Error(`Invalid relation predicate '${p}'`);
    415 		}
    416 	}
    417 	
    418 	// Relations are stored internally as a flat array with individual predicate-object pairs,
    419 	// so convert the incoming relations to that
    420 	var newRelationsFlat = this.ObjectsClass.flattenRelations(newRelations);
    421 	
    422 	var changed = false;
    423 	if (oldRelations.length != newRelationsFlat.length) {
    424 		changed = true;
    425 	}
    426 	else {
    427 		let sortFunc = function (a, b) {
    428 			if (a[0] < b[0]) return -1;
    429 			if (a[0] > b[0]) return 1;
    430 			if (a[1] < b[1]) return -1;
    431 			if (a[1] > b[1]) return 1;
    432 			return 0;
    433 		};
    434 		oldRelations.sort(sortFunc);
    435 		newRelationsFlat.sort(sortFunc);
    436 		
    437 		for (let i=0; i<oldRelations.length; i++) {
    438 			if (oldRelations[i][0] != newRelationsFlat[i][0]
    439 					|| oldRelations[i][1] != newRelationsFlat[i][1]) {
    440 				changed = true;
    441 				break;
    442 			}
    443 		}
    444 	}
    445 	
    446 	if (!changed) {
    447 		Zotero.debug("Relations have not changed for " + this._objectType + " " + this.libraryKey, 4);
    448 		return false;
    449 	}
    450 	
    451 	this._markFieldChange('relations', this._relations);
    452 	this._changed.relations = true;
    453 	this._relations = newRelationsFlat;
    454 	return true;
    455 }
    456 
    457 
    458 /**
    459  * Return an object in the specified library equivalent to this object
    460  *
    461  * Use Zotero.Collection.getLinkedCollection() and Zotero.Item.getLinkedItem() instead of
    462  * calling this directly.
    463  *
    464  * @param {Integer} [libraryID]
    465  * @return {Promise<Zotero.DataObject|false>} Linked object, or false if not found
    466  */
    467 Zotero.DataObject.prototype._getLinkedObject = Zotero.Promise.coroutine(function* (libraryID, bidirectional) {
    468 	if (!libraryID) {
    469 		throw new Error("libraryID not provided");
    470 	}
    471 	
    472 	if (libraryID == this._libraryID) {
    473 		throw new Error(this._ObjectType + " is already in library " + libraryID);
    474 	}
    475 	
    476 	var predicate = Zotero.Relations.linkedObjectPredicate;
    477 	var libraryObjectPrefix = Zotero.URI.getLibraryURI(libraryID)
    478 		+ "/" + this._objectTypePlural + "/";
    479 	
    480 	// Try the relations with this as a subject
    481 	var uris = this.getRelationsByPredicate(predicate);
    482 	for (let i = 0; i < uris.length; i++) {
    483 		let uri = uris[i];
    484 		if (uri.startsWith(libraryObjectPrefix)) {
    485 			let obj = yield Zotero.URI['getURI' + this._ObjectType](uri);
    486 			if (!obj) {
    487 				Zotero.debug("Referenced linked " + this._objectType + " '" + uri + "' not found "
    488 					+ "in Zotero." + this._ObjectType + "::getLinked" + this._ObjectType + "()", 2);
    489 				continue;
    490 			}
    491 			return obj;
    492 		}
    493 	}
    494 	
    495 	// Then try relations with this as an object
    496 	if (bidirectional) {
    497 		var thisURI = Zotero.URI['get' + this._ObjectType + 'URI'](this);
    498 		var objects = Zotero.Relations.getByPredicateAndObject(
    499 			this._objectType, predicate, thisURI
    500 		);
    501 		for (let i = 0; i < objects.length; i++) {
    502 			let obj = objects[i];
    503 			if (obj.objectType != this._objectType) {
    504 				Zotero.logError("Found linked object of different type "
    505 					+ "(expected " + this._objectType + ", found " + obj.objectType + ")");
    506 				continue;
    507 			}
    508 			if (obj.libraryID == libraryID) {
    509 				return obj;
    510 			}
    511 		}
    512 	}
    513 	
    514 	return false;
    515 });
    516 
    517 
    518 /**
    519  * Add a linked-item relation to a pair of objects
    520  *
    521  * A separate save() is not required.
    522  *
    523  * @param {Zotero.DataObject} object
    524  * @param {Promise<Boolean>}
    525  */
    526 Zotero.DataObject.prototype._addLinkedObject = Zotero.Promise.coroutine(function* (object) {
    527 	if (object.libraryID == this._libraryID) {
    528 		throw new Error("Can't add linked " + this._objectType + " in same library");
    529 	}
    530 	
    531 	var predicate = Zotero.Relations.linkedObjectPredicate;
    532 	var thisURI = Zotero.URI['get' + this._ObjectType + 'URI'](this);
    533 	var objectURI = Zotero.URI['get' + this._ObjectType + 'URI'](object);
    534 	
    535 	var exists = this.hasRelation(predicate, objectURI);
    536 	if (exists) {
    537 		Zotero.debug(this._ObjectTypePlural + " " + this.libraryKey
    538 			+ " and " + object.libraryKey + " are already linked");
    539 		return false;
    540 	}
    541 	
    542 	// If one of the items is a personal library, store relation with that. Otherwise, use
    543 	// current item's library (which in calling code is the new, copied item, since that's what
    544 	// the user definitely has access to).
    545 	var userLibraryID = Zotero.Libraries.userLibraryID;
    546 	if (this.libraryID == userLibraryID || object.libraryID != userLibraryID) {
    547 		this.addRelation(predicate, objectURI);
    548 		yield this.save({
    549 			skipDateModifiedUpdate: true,
    550 			skipSelect: true
    551 		});
    552 	}
    553 	else {
    554 		object.addRelation(predicate, thisURI);
    555 		yield object.save({
    556 			skipDateModifiedUpdate: true,
    557 			skipSelect: true
    558 		});
    559 	}
    560 	
    561 	return true;
    562 });
    563 
    564 
    565 //
    566 // Bulk data loading functions
    567 //
    568 // These are called by Zotero.DataObjects.prototype.loadDataType().
    569 //
    570 Zotero.DataObject.prototype.loadPrimaryData = Zotero.Promise.coroutine(function* (reload, failOnMissing) {
    571 	if (this._loaded.primaryData && !reload) return;
    572 	
    573 	var id = this._id;
    574 	var key = this._key;
    575 	var libraryID = this._libraryID;
    576 	
    577 	if (!id && !key) {
    578 		throw new Error('ID or key not set in Zotero.' + this._ObjectType + '.loadPrimaryData()');
    579 	}
    580 	
    581 	var columns = [], join = [], where = [];
    582 	var primaryFields = this.ObjectsClass.primaryFields;
    583 	var idField = this.ObjectsClass.idColumn;
    584 	for (let i=0; i<primaryFields.length; i++) {
    585 		let field = primaryFields[i];
    586 		// If field not already set
    587 		if (field == idField || this['_' + field] === null || reload) {
    588 			columns.push(this.ObjectsClass.getPrimaryDataSQLPart(field));
    589 		}
    590 	}
    591 	if (!columns.length) {
    592 		return;
    593 	}
    594 	
    595 	// This should match Zotero.*.primaryDataSQL, but without
    596 	// necessarily including all columns
    597 	var sql = "SELECT " + columns.join(", ") + this.ObjectsClass.primaryDataSQLFrom;
    598 	if (id) {
    599 		sql += " AND O." + idField + "=? ";
    600 		var params = id;
    601 	}
    602 	else {
    603 		sql += " AND O.key=? AND O.libraryID=? ";
    604 		var params = [key, libraryID];
    605 	}
    606 	sql += (where.length ? ' AND ' + where.join(' AND ') : '');
    607 	var row = yield Zotero.DB.rowQueryAsync(sql, params);
    608 	
    609 	if (!row) {
    610 		if (failOnMissing) {
    611 			throw new Error(this._ObjectType + " " + (id ? id : libraryID + "/" + key)
    612 				+ " not found in Zotero." + this._ObjectType + ".loadPrimaryData()");
    613 		}
    614 		this._clearChanged('primaryData');
    615 		
    616 		// If object doesn't exist, mark all data types as loaded
    617 		this._markAllDataTypeLoadStates(true);
    618 		
    619 		return;
    620 	}
    621 	
    622 	this.loadFromRow(row, reload);
    623 });
    624 
    625 
    626 /**
    627  * Reloads loaded, changed data
    628  *
    629  * @param {String[]} [dataTypes] - Data types to reload, or all loaded types if not provide
    630  * @param {Boolean} [reloadUnchanged=false] - Reload even data that hasn't changed internally.
    631  *                                            This should be set to true for data that was
    632  *                                            changed externally (e.g., globally renamed tags).
    633  */
    634 Zotero.DataObject.prototype.reload = Zotero.Promise.coroutine(function* (dataTypes, reloadUnchanged) {
    635 	if (!this._id) {
    636 		return;
    637 	}
    638 	
    639 	if (!dataTypes) {
    640 		dataTypes = Object.keys(this._loaded).filter(type => this._loaded[type]);
    641 	}
    642 	
    643 	if (dataTypes && dataTypes.length) {
    644 		for (let i=0; i<dataTypes.length; i++) {
    645 			let dataType = dataTypes[i];
    646 			if (!this._loaded[dataType] || this._skipDataTypeLoad[dataType]
    647 					|| (!reloadUnchanged && !this._changed[dataType] && !this._dataTypesToReload.has(dataType))) {
    648 				continue;
    649 			}
    650 			yield this.loadDataType(dataType, true);
    651 			this._dataTypesToReload.delete(dataType);
    652 		}
    653 	}
    654 });
    655 
    656 /**
    657  * Checks whether a given data type has been loaded
    658  *
    659  * @param {String} [dataType=primaryData] Data type to check
    660  * @throws {Zotero.DataObjects.UnloadedDataException} If not loaded, unless the
    661  *   data has not yet been "identified"
    662  */
    663 Zotero.DataObject.prototype._requireData = function (dataType) {
    664 	if (this._loaded[dataType] === undefined) {
    665 		throw new Error(dataType + " is not a valid data type for " + this._ObjectType + " objects");
    666 	}
    667 	
    668 	if (dataType != 'primaryData') {
    669 		this._requireData('primaryData');
    670 	}
    671 	
    672 	if (!this._identified) {
    673 		this._loaded[dataType] = true;
    674 	}
    675 	else if (!this._loaded[dataType]) {
    676 		throw new Zotero.Exception.UnloadedDataException(
    677 			"'" + dataType + "' not loaded for " + this._objectType + " ("
    678 				+ this._id + "/" + this._libraryID + "/" + this._key + ")",
    679 			dataType
    680 		);
    681 	}
    682 }
    683 
    684 
    685 /**
    686  * Loads data for a given data type
    687  * @param {String} dataType
    688  * @param {Boolean} reload
    689  * @param {Promise}
    690  */
    691 Zotero.DataObject.prototype.loadDataType = function (dataType, reload) {
    692 	return this._ObjectsClass._loadDataTypeInLibrary(dataType, this.libraryID, [this.id]);
    693 }
    694 
    695 Zotero.DataObject.prototype.loadAllData = Zotero.Promise.coroutine(function* (reload) {
    696 	for (let i=0; i<this._dataTypes.length; i++) {
    697 		let type = this._dataTypes[i];
    698 		if (!this._skipDataTypeLoad[type]) {
    699 			yield this.loadDataType(type, reload);
    700 		}
    701 	}
    702 });
    703 
    704 Zotero.DataObject.prototype._markAllDataTypeLoadStates = function (loaded) {
    705 	for (let i = 0; i < this._dataTypes.length; i++) {
    706 		this._loaded[this._dataTypes[i]] = loaded;
    707 	}
    708 }
    709 
    710 /**
    711  * Save old version of data that's being changed, to pass to the notifier
    712  * @param {String} field
    713  * @param {} oldValue
    714  */
    715 Zotero.DataObject.prototype._markFieldChange = function (field, oldValue) {
    716 	// New method (changedData)
    717 	if (field == 'tags') {
    718 		if (Array.isArray(oldValue)) {
    719 			this._changedData[field] = [...oldValue];
    720 		}
    721 		else {
    722 			this._changedData[field] = oldValue;
    723 		}
    724 		return;
    725 	}
    726 	
    727 	// Only save if object already exists and field not already changed
    728 	if (!this.id || this._previousData[field] !== undefined) {
    729 		return;
    730 	}
    731 	if (Array.isArray(oldValue)) {
    732 		this._previousData[field] = [];
    733 		Object.assign(this._previousData[field], oldValue)
    734 	}
    735 	else {
    736 		this._previousData[field] = oldValue;
    737 	}
    738 }
    739 
    740 
    741 Zotero.DataObject.prototype.hasChanged = function() {
    742 	var changed = Object.keys(this._changed).filter(dataType => this._changed[dataType])
    743 		.concat(
    744 			Object.keys(this._changedData).filter(dataType => this._changedData[dataType])
    745 		);
    746 	if (changed.length == 1
    747 			&& changed[0] == 'primaryData'
    748 			&& Object.keys(this._changed.primaryData).length == 1
    749 			&& this._changed.primaryData.synced
    750 			&& this._previousData.synced == this._synced) {
    751 		return false;
    752 	}
    753 	return !!changed.length;
    754 }
    755 
    756 
    757 /**
    758  * Clears log of changed values
    759  * @param {String} [dataType] data type/field to clear. Defaults to clearing everything
    760  */
    761 Zotero.DataObject.prototype._clearChanged = function (dataType) {
    762 	if (dataType) {
    763 		delete this._changed[dataType];
    764 		delete this._previousData[dataType];
    765 		delete this._changedData[dataType];
    766 	}
    767 	else {
    768 		this._changed = {};
    769 		this._previousData = {};
    770 		this._changedData = {};
    771 		this._dataTypesToReload = new Set();
    772 	}
    773 }
    774 
    775 /**
    776  * Clears field change log
    777  * @param {String} field
    778  */
    779 Zotero.DataObject.prototype._clearFieldChange = function (field) {
    780 	delete this._previousData[field];
    781 	delete this._changedData[field];
    782 }
    783 
    784 
    785 /**
    786  * Mark a data type as requiring a reload when the current save finishes. The changed state is cleared
    787  * before the new data is saved to the database (so that further updates during the save process don't
    788  * get lost), so we need to separately keep track of what changed.
    789  */
    790 Zotero.DataObject.prototype._markForReload = function (dataType) {
    791 	this._dataTypesToReload.add(dataType);
    792 }
    793 
    794 
    795 Zotero.DataObject.prototype.isEditable = function () {
    796 	return Zotero.Libraries.get(this.libraryID).editable;
    797 }
    798 
    799 
    800 Zotero.DataObject.prototype.editCheck = function () {
    801 	let library = Zotero.Libraries.get(this.libraryID);
    802 	if ((this._objectType == 'collection' || this._objectType == 'search')
    803 			&& library.libraryType == 'publications') {
    804 		throw new Error(this._ObjectTypePlural + " cannot be added to My Publications");
    805 	}
    806 	
    807 	if (library.libraryType == 'feed') {
    808 		return;
    809 	}
    810 	
    811 	if (!this.isEditable()) {
    812 		throw new Error("Cannot edit " + this._objectType + " in read-only library "
    813 			+ Zotero.Libraries.get(this.libraryID).name);
    814 	}
    815 }
    816 
    817 /**
    818  * Save changes to database
    819  *
    820  * @param {Object} [options]
    821  * @param {Boolean} [options.skipCache] - Don't save add new object to the cache; if set, object
    822  *                                         is disabled after save
    823  * @param {Boolean} [options.skipDateModifiedUpdate]
    824  * @param {Boolean} [options.skipClientDateModifiedUpdate]
    825  * @param {Boolean} [options.skipNotifier] - Don't trigger Zotero.Notifier events
    826  * @param {Boolean} [options.skipSelect] - Don't select object automatically in trees
    827  * @param {Boolean} [options.skipSyncedUpdate] - Don't automatically set 'synced' to false
    828  * @return {Promise<Integer|Boolean>}  Promise for itemID of new item,
    829  *                                     TRUE on item update, or FALSE if item was unchanged
    830  */
    831 Zotero.DataObject.prototype.save = Zotero.Promise.coroutine(function* (options = {}) {
    832 	var env = {
    833 		options: Object.assign({}, options),
    834 		transactionOptions: {}
    835 	};
    836 	
    837 	if (!env.options.tx && !Zotero.DB.inTransaction()) {
    838 		Zotero.logError("save() called on Zotero." + this._ObjectType + " without a wrapping "
    839 			+ "transaction -- use saveTx() instead");
    840 		Zotero.debug((new Error).stack, 2);
    841 		env.options.tx = true;
    842 	}
    843 	
    844 	if (env.options.skipAll) {
    845 		[
    846 			'skipDateModifiedUpdate',
    847 			'skipClientDateModifiedUpdate',
    848 			'skipSyncedUpdate',
    849 			'skipEditCheck',
    850 			'skipNotifier',
    851 			'skipSelect'
    852 		].forEach(x => env.options[x] = true);
    853 	}
    854 	
    855 	var proceed = yield this._initSave(env);
    856 	if (!proceed) return false;
    857 	
    858 	if (env.isNew) {
    859 		Zotero.debug('Saving data for new ' + this._objectType + ' to database', 4);
    860 	}
    861 	else {
    862 		Zotero.debug('Updating database with new ' + this._objectType + ' data', 4);
    863 	}
    864 	
    865 	try {
    866 		if (Zotero.DataObject.prototype._finalizeSave == this._finalizeSave) {
    867 			throw new Error("_finalizeSave not implemented for Zotero." + this._ObjectType);
    868 		}
    869 		
    870 		env.notifierData = {};
    871 		// Pass along any 'notifierData' values
    872 		if (env.options.notifierData) {
    873 			Object.assign(env.notifierData, env.options.notifierData);
    874 		}
    875 		if (env.options.skipSelect) {
    876 			env.notifierData.skipSelect = true;
    877 		}
    878 		if (!env.isNew) {
    879 			env.changed = this._previousData;
    880 		}
    881 		
    882 		// Create transaction
    883 		let result
    884 		if (env.options.tx) {
    885 			result = yield Zotero.DB.executeTransaction(function* () {
    886 				Zotero.DataObject.prototype._saveData.call(this, env);
    887 				yield this._saveData(env);
    888 				yield Zotero.DataObject.prototype._finalizeSave.call(this, env);
    889 				return this._finalizeSave(env);
    890 			}.bind(this), env.transactionOptions);
    891 		}
    892 		// Use existing transaction
    893 		else {
    894 			Zotero.DB.requireTransaction();
    895 			Zotero.DataObject.prototype._saveData.call(this, env);
    896 			yield this._saveData(env);
    897 			yield Zotero.DataObject.prototype._finalizeSave.call(this, env);
    898 			result = this._finalizeSave(env);
    899 		}
    900 		this._postSave(env);
    901 		return result;
    902 	}
    903 	catch(e) {
    904 		return this._recoverFromSaveError(env, e)
    905 		.catch(function(e2) {
    906 			Zotero.debug(e2, 1);
    907 		})
    908 		.then(function() {
    909 			if (env.options.errorHandler) {
    910 				env.options.errorHandler(e);
    911 			}
    912 			else {
    913 				Zotero.logError(e);
    914 			}
    915 			throw e;
    916 		})
    917 	}
    918 });
    919 
    920 
    921 Zotero.DataObject.prototype.saveTx = function (options = {}) {
    922 	options = Object.assign({}, options);
    923 	options.tx = true;
    924 	return this.save(options);
    925 }
    926 
    927 
    928 Zotero.DataObject.prototype._initSave = Zotero.Promise.coroutine(function* (env) {
    929 	// Default to user library if not specified
    930 	if (this.libraryID === null) {
    931 		this._libraryID = Zotero.Libraries.userLibraryID;
    932 	}
    933 	
    934 	env.isNew = !this.id;
    935 	
    936 	if (!env.options.skipEditCheck) {
    937 		this.editCheck();
    938 	}
    939 	
    940 	let targetLib = Zotero.Libraries.get(this.libraryID);
    941 	if (!targetLib.isChildObjectAllowed(this._objectType)) {
    942 		throw new Error("Cannot add " + this._objectType + " to a " + targetLib.libraryType + " library");
    943 	}
    944 	
    945 	if (!this.hasChanged()) {
    946 		Zotero.debug(this._ObjectType + ' ' + this.id + ' has not changed', 4);
    947 		return false;
    948 	}
    949 	
    950 	// Undo registerObject() on failure
    951 	if (env.isNew) {
    952 		var func = function () {
    953 			this.ObjectsClass.unload(this._id);
    954 		}.bind(this);
    955 		if (env.options.tx) {
    956 			env.transactionOptions.onRollback = func;
    957 		}
    958 		else {
    959 			Zotero.DB.addCurrentCallback("rollback", func);
    960 		}
    961 	}
    962 	
    963 	env.relationsToRegister = [];
    964 	env.relationsToUnregister = [];
    965 	
    966 	return true;
    967 });
    968 
    969 Zotero.DataObject.prototype._saveData = function (env) {
    970 	var libraryID = env.libraryID = this.libraryID || Zotero.Libraries.userLibraryID;
    971 	var key = env.key = this._key = this.key ? this.key : this._generateKey();
    972 	
    973 	env.sqlColumns = [];
    974 	env.sqlValues = [];
    975 	
    976 	if (env.isNew) {
    977 		env.sqlColumns.push(
    978 			'libraryID',
    979 			'key'
    980 		);
    981 		env.sqlValues.push(
    982 			libraryID,
    983 			key
    984 		);
    985 	}
    986 	
    987 	if (this._changed.primaryData && this._changed.primaryData.version) {
    988 		env.sqlColumns.push('version');
    989 		env.sqlValues.push(this.version || 0);
    990 	}
    991 	
    992 	if (this._changed.primaryData && this._changed.primaryData.synced) {
    993 		env.sqlColumns.push('synced');
    994 		env.sqlValues.push(this.synced ? 1 : 0);
    995 	}
    996 	// Set synced to 0 by default
    997 	else if (!env.isNew && !env.options.skipSyncedUpdate) {
    998 		env.sqlColumns.push('synced');
    999 		env.sqlValues.push(0);
   1000 	}
   1001 	
   1002 	if (env.isNew || !env.options.skipClientDateModifiedUpdate) {
   1003 		env.sqlColumns.push('clientDateModified');
   1004 		env.sqlValues.push(Zotero.DB.transactionDateTime);
   1005 	}
   1006 };
   1007 
   1008 Zotero.DataObject.prototype._finalizeSave = Zotero.Promise.coroutine(function* (env) {
   1009 	// Relations
   1010 	if (this._changed.relations) {
   1011 		let toAdd, toRemove;
   1012 		// Convert to individual JSON objects, diff, and convert back
   1013 		if (this._previousData.relations) {
   1014 			let oldRelationsJSON = this._previousData.relations.map(x => JSON.stringify(x));
   1015 			let newRelationsJSON = this._relations.map(x => JSON.stringify(x));
   1016 			toAdd = Zotero.Utilities.arrayDiff(newRelationsJSON, oldRelationsJSON)
   1017 				.map(x => JSON.parse(x));
   1018 			toRemove = Zotero.Utilities.arrayDiff(oldRelationsJSON, newRelationsJSON)
   1019 				.map(x => JSON.parse(x));
   1020 		}
   1021 		else {
   1022 			toAdd = this._relations;
   1023 			toRemove = [];
   1024 		}
   1025 		
   1026 		if (toAdd.length) {
   1027 			let sql = "INSERT INTO " + this._objectType + "Relations "
   1028 				+ "(" + this._ObjectsClass.idColumn + ", predicateID, object) VALUES ";
   1029 			// Convert predicates to ids
   1030 			for (let i = 0; i < toAdd.length; i++) {
   1031 				toAdd[i][0] = yield Zotero.RelationPredicates.add(toAdd[i][0]);
   1032 				env.relationsToRegister.push([toAdd[i][0], toAdd[i][1]]);
   1033 			}
   1034 			yield Zotero.DB.queryAsync(
   1035 				sql + toAdd.map(x => "(?, ?, ?)").join(", "),
   1036 				toAdd.map(x => [this.id, x[0], x[1]])
   1037 				.reduce((x, y) => x.concat(y))
   1038 			);
   1039 		}
   1040 		
   1041 		if (toRemove.length) {
   1042 			for (let i = 0; i < toRemove.length; i++) {
   1043 				let sql = "DELETE FROM " + this._objectType + "Relations "
   1044 					+ "WHERE " + this._ObjectsClass.idColumn + "=? AND predicateID=? AND object=?";
   1045 				yield Zotero.DB.queryAsync(
   1046 					sql,
   1047 					[
   1048 						this.id,
   1049 						(yield Zotero.RelationPredicates.add(toRemove[i][0])),
   1050 						toRemove[i][1]
   1051 					]
   1052 				);
   1053 				env.relationsToUnregister.push([toRemove[i][0], toRemove[i][1]]);
   1054 			}
   1055 		}
   1056 	}
   1057 	
   1058 	if (env.isNew) {
   1059 		if (!env.skipCache) {
   1060 			// Register this object's identifiers in Zotero.DataObjects. This has to happen here so
   1061 			// that the object exists for the reload() in objects' finalizeSave methods.
   1062 			this.ObjectsClass.registerObject(this);
   1063 		}
   1064 		// If object isn't being reloaded, disable it, since its data may be out of date
   1065 		else {
   1066 			this._disabled = true;
   1067 		}
   1068 	}
   1069 	else if (env.skipCache) {
   1070 		Zotero.logError("skipCache is only for new objects");
   1071 	}
   1072 });
   1073 
   1074 
   1075 /**
   1076  * Actions to perform after DB transaction
   1077  */
   1078 Zotero.DataObject.prototype._postSave = function (env) {
   1079 	for (let i = 0; i < env.relationsToRegister.length; i++) {
   1080 		let rel = env.relationsToRegister[i];
   1081 		Zotero.debug(rel);
   1082 		Zotero.Relations.register(this._objectType, this.id, rel[0], rel[1]);
   1083 	}
   1084 	for (let i = 0; i < env.relationsToUnregister.length; i++) {
   1085 		let rel = env.relationsToUnregister[i];
   1086 		Zotero.Relations.unregister(this._objectType, this.id, rel[0], rel[1]);
   1087 	}
   1088 };
   1089 
   1090 
   1091 Zotero.DataObject.prototype._recoverFromSaveError = Zotero.Promise.coroutine(function* (env) {
   1092 	yield this.reload(null, true);
   1093 	this._clearChanged();
   1094 });
   1095 
   1096 
   1097 /**
   1098  * Update object version, efficiently
   1099  *
   1100  * Used by sync code
   1101  *
   1102  * @param {Integer} version
   1103  * @param {Boolean} [skipDB=false]
   1104  */
   1105 Zotero.DataObject.prototype.updateVersion = Zotero.Promise.coroutine(function* (version, skipDB) {
   1106 	if (!this.id) {
   1107 		throw new Error("Cannot update version of unsaved " + this._objectType);
   1108 	}
   1109 	if (version != parseInt(version)) {
   1110 		throw new Error("'version' must be an integer");
   1111 	}
   1112 	
   1113 	this._version = parseInt(version);
   1114 	
   1115 	if (!skipDB) {
   1116 		var cl = this.ObjectsClass;
   1117 		var sql = "UPDATE " + cl.table + " SET version=? WHERE " + cl.idColumn + "=?";
   1118 		yield Zotero.DB.queryAsync(sql, [parseInt(version), this.id]);
   1119 	}
   1120 	
   1121 	if (this._changed.primaryData && this._changed.primaryData.version) {
   1122 		if (Objects.keys(this._changed.primaryData).length == 1) {
   1123 			delete this._changed.primaryData;
   1124 		}
   1125 		else {
   1126 			delete this._changed.primaryData.version;
   1127 		}
   1128 	}
   1129 });
   1130 
   1131 /**
   1132  * Update object sync status, efficiently
   1133  *
   1134  * Used by sync code
   1135  *
   1136  * @param {Boolean} synced
   1137  * @param {Boolean} [skipDB=false]
   1138  */
   1139 Zotero.DataObject.prototype.updateSynced = Zotero.Promise.coroutine(function* (synced, skipDB) {
   1140 	if (!this.id) {
   1141 		throw new Error("Cannot update sync status of unsaved " + this._objectType);
   1142 	}
   1143 	if (typeof synced != 'boolean') {
   1144 		throw new Error("'synced' must be a boolean");
   1145 	}
   1146 	
   1147 	this._synced = synced;
   1148 	
   1149 	if (!skipDB) {
   1150 		var cl = this.ObjectsClass;
   1151 		var sql = "UPDATE " + cl.table + " SET synced=? WHERE " + cl.idColumn + "=?";
   1152 		yield Zotero.DB.queryAsync(sql, [synced ? 1 : 0, this.id]);
   1153 	}
   1154 	
   1155 	if (this._changed.primaryData && this._changed.primaryData.synced) {
   1156 		if (Object.keys(this._changed.primaryData).length == 1) {
   1157 			delete this._changed.primaryData;
   1158 		}
   1159 		else {
   1160 			delete this._changed.primaryData.synced;
   1161 		}
   1162 	}
   1163 });
   1164 
   1165 /**
   1166  * Delete object from database
   1167  *
   1168  * @param {Object} [options]
   1169  * @param {Boolean} [options.deleteItems] - Move descendant items to trash (Collection only)
   1170  * @param {Boolean} [options.skipDeleteLog] - Don't add to sync delete log
   1171  */
   1172 Zotero.DataObject.prototype.erase = Zotero.Promise.coroutine(function* (options = {}) {
   1173 	if (!options || typeof options != 'object') {
   1174 		throw new Error("'options' must be an object (" + typeof options + ")");
   1175 	}
   1176 	
   1177 	var env = {
   1178 		options: Object.assign({}, options)
   1179 	};
   1180 	
   1181 	if (!env.options.tx && !Zotero.DB.inTransaction()) {
   1182 		Zotero.logError("erase() called on Zotero." + this._ObjectType + " without a wrapping "
   1183 			+ "transaction -- use eraseTx() instead");
   1184 		Zotero.debug((new Error).stack, 2);
   1185 		env.options.tx = true;
   1186 	}
   1187 	
   1188 	let proceed = yield this._initErase(env);
   1189 	if (!proceed) return false;
   1190 	
   1191 	Zotero.debug('Deleting ' + this.objectType + ' ' + this.id);
   1192 	
   1193 	if (env.options.tx) {
   1194 		return Zotero.DB.executeTransaction(function* () {
   1195 			yield this._eraseData(env);
   1196 			yield this._finalizeErase(env);
   1197 		}.bind(this))
   1198 	}
   1199 	else {
   1200 		Zotero.DB.requireTransaction();
   1201 		yield this._eraseData(env);
   1202 		yield this._finalizeErase(env);
   1203 	}
   1204 });
   1205 
   1206 Zotero.DataObject.prototype.eraseTx = function (options) {
   1207 	options = options || {};
   1208 	options.tx = true;
   1209 	return this.erase(options);
   1210 };
   1211 
   1212 Zotero.DataObject.prototype._initErase = Zotero.Promise.method(function (env) {
   1213 	env.notifierData = {};
   1214 	env.notifierData[this.id] = {
   1215 		libraryID: this.libraryID,
   1216 		key: this.key
   1217 	};
   1218 	
   1219 	if (!env.options.skipEditCheck) this.editCheck();
   1220 	
   1221 	if (env.options.skipDeleteLog) {
   1222 		env.notifierData[this.id].skipDeleteLog = true;
   1223 	}
   1224 	
   1225 	return true;
   1226 });
   1227 
   1228 Zotero.DataObject.prototype._finalizeErase = Zotero.Promise.coroutine(function* (env) {
   1229 	// Delete versions from sync cache
   1230 	if (this._objectType != 'feedItem') {
   1231 		yield Zotero.Sync.Data.Local.deleteCacheObjectVersions(
   1232 			this.objectType, this._libraryID, this._key
   1233 		);
   1234 	}
   1235 	
   1236 	Zotero.DB.addCurrentCallback("commit", function () {
   1237 		this.ObjectsClass.unload(env.deletedObjectIDs || this.id);
   1238 	}.bind(this));
   1239 	
   1240 	if (!env.options.skipNotifier) {
   1241 		Zotero.Notifier.queue(
   1242 			'delete',
   1243 			this._objectType,
   1244 			Object.keys(env.notifierData).map(id => parseInt(id)),
   1245 			env.notifierData,
   1246 			env.options.notifierQueue
   1247 		);
   1248 	}
   1249 });
   1250 
   1251 
   1252 Zotero.DataObject.prototype.toResponseJSON = function (options = {}) {
   1253 	// TODO: library block?
   1254 	
   1255 	var json = {
   1256 		key: this.key,
   1257 		version: this.version,
   1258 		meta: {},
   1259 		data: this.toJSON(options)
   1260 	};
   1261 	if (options.version) {
   1262 		json.version = json.data.version = options.version;
   1263 	}
   1264 	return json;
   1265 }
   1266 
   1267 
   1268 Zotero.DataObject.prototype._preToJSON = function (options) {
   1269 	var env = { options };
   1270 	env.mode = options.mode || 'new';
   1271 	if (env.mode == 'patch') {
   1272 		if (!options.patchBase) {
   1273 			throw new Error("Cannot use patch mode if patchBase not provided");
   1274 		}
   1275 	}
   1276 	else if (options.patchBase) {
   1277 		if (options.mode) {
   1278 			Zotero.debug("Zotero.Item.toJSON: ignoring provided patchBase in " + env.mode + " mode", 2);
   1279 		}
   1280 		// If patchBase provided and no explicit mode, use 'patch'
   1281 		else {
   1282 			env.mode = 'patch';
   1283 		}
   1284 	}
   1285 	return env;
   1286 }
   1287 
   1288 Zotero.DataObject.prototype._postToJSON = function (env) {
   1289 	if (env.mode == 'patch') {
   1290 		env.obj = Zotero.DataObjectUtilities.patch(env.options.patchBase, env.obj);
   1291 	}
   1292 	if (env.options.includeVersion === false) {
   1293 		delete env.obj.version;
   1294 	}
   1295 	return env.obj;
   1296 }
   1297 
   1298 
   1299 /**
   1300  * Generates data object key
   1301  * @return {String} key
   1302  */
   1303 Zotero.DataObject.prototype._generateKey = function () {
   1304 	return Zotero.Utilities.generateObjectKey();
   1305 }
   1306 
   1307 Zotero.DataObject.prototype._disabledCheck = function () {
   1308 	if (this._disabled) {
   1309 		Zotero.logError(this._ObjectType + " is disabled -- "
   1310 			+ "use Zotero." + this._ObjectTypePlural  + ".getAsync()");
   1311 	}
   1312 }