<commit_before>/*
    ***** BEGIN LICENSE BLOCK *****
    
    Copyright © 2009 Center for History and New Media
                     George Mason University, Fairfax, Virginia, USA
                     http://zotero.org
    
    This file is part of Zotero.
    
    Zotero is free software: you can redistribute it and/or modify
    it under the terms of the GNU Affero General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.
    
    Zotero is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU Affero General Public License for more details.
    
    You should have received a copy of the GNU Affero General Public License
    along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
    
    ***** END LICENSE BLOCK *****
*/


Zotero.DataObjects = function () {
	if (!this._ZDO_object) throw new Error('this._ZDO_object must be set before calling Zotero.DataObjects constructor');
	
	if (!this._ZDO_objects) {
		this._ZDO_objects = Zotero.DataObjectUtilities.getObjectTypePlural(this._ZDO_object);
	}
	if (!this._ZDO_Object) {
		this._ZDO_Object = this._ZDO_object.substr(0, 1).toUpperCase()
			+ this._ZDO_object.substr(1);
	}
	if (!this._ZDO_Objects) {
		this._ZDO_Objects = this._ZDO_objects.substr(0, 1).toUpperCase()
			+ this._ZDO_objects.substr(1);
	}
	
	if (!this._ZDO_id) {
		this._ZDO_id = this._ZDO_object + 'ID';
	}
	
	if (!this._ZDO_table) {
		this._ZDO_table = this._ZDO_objects;
	}
	
	if (!this.ObjectClass) {
		this.ObjectClass = Zotero[this._ZDO_Object];
	}
	
	this._objectCache = {};
	this._objectKeys = {};
	this._objectIDs = {};
	this._loadedLibraries = {};
	this._loadPromise = null;
}

Zotero.DataObjects.prototype._ZDO_idOnly = false;

// Public properties
Zotero.defineProperty(Zotero.DataObjects.prototype, 'idColumn', {
	get: function() this._ZDO_id
});
Zotero.defineProperty(Zotero.DataObjects.prototype, 'table', {
	get: function() this._ZDO_table
});

Zotero.defineProperty(Zotero.DataObjects.prototype, 'relationsTable', {
	get: function() this._ZDO_object + 'Relations'
});

Zotero.defineProperty(Zotero.DataObjects.prototype, 'primaryFields', {
	get: function () Object.keys(this._primaryDataSQLParts)
}, {lazy: true});

Zotero.defineProperty(Zotero.DataObjects.prototype, "_primaryDataSQLWhere", {
	value: "WHERE 1"
});

Zotero.defineProperty(Zotero.DataObjects.prototype, 'primaryDataSQLFrom', {
	get: function() " " + this._primaryDataSQLFrom + " " + this._primaryDataSQLWhere
}, {lateInit: true});

Zotero.DataObjects.prototype.init = function() {
	return this._loadIDsAndKeys();
}


Zotero.DataObjects.prototype.isPrimaryField = function (field) {
	return this.primaryFields.indexOf(field) != -1;
}


/**
 * Retrieves one or more already-loaded items
 *
 * If an item hasn't been loaded, an error is thrown
 *
 * @param {Array|Integer} ids  An individual object id or an array of object ids
 * @return {Zotero.[Object]|Array<Zotero.[Object]>} A Zotero.[Object], if a scalar id was passed;
 *                                          otherwise, an array of Zotero.[Object]
 */
Zotero.DataObjects.prototype.get = function (ids) {
	if (Array.isArray(ids)) {
		var singleObject = false;
	}
	else {
		var singleObject = true;
		ids = [ids];
	}
	
	var toReturn = [];
	
	for (let i=0; i<ids.length; i++) {
		let id = ids[i];
		// Check if already loaded
		if (!this._objectCache[id]) {
			// If unloaded id is registered, throw an error
			if (this._objectKeys[id]) {
				throw new Zotero.Exception.UnloadedDataException(
					this._ZDO_Object + " " + id + " not yet loaded"
				);
			}
			// Otherwise ignore (which means returning false for a single id)
			else {
				continue;
			}
		}
		toReturn.push(this._objectCache[id]);
	}
	
	// If single id, return the object directly
	if (singleObject) {
		return toReturn.length ? toReturn[0] : false;
	}
	
	return toReturn;
};
	
	
/**
 * Retrieves (and loads, if necessary) one or more items
 *
 * @param {Array|Integer} ids  An individual object id or an array of object ids
 * @param {Object} [options]
 * @param {Boolean} [options.noCache=false] - Don't add object to cache after loading
 * @return {Promise<Zotero.DataObject|Zotero.DataObject[]>} - A promise for either a data object,
 *     if a scalar id was passed, or an array of data objects, if an array of ids was passed
 */
Zotero.DataObjects.prototype.getAsync = Zotero.Promise.coroutine(function* (ids, options) {
	var toLoad = [];
	var toReturn = [];
	
	if (!ids) {
		throw new Error("No arguments provided");
	}
	
	if (options && typeof options != 'object') {
		throw new Error(`'options' must be an object, ${typeof options} given`);
	}
	
	if (Array.isArray(ids)) {
		var singleObject = false;
	}
	else {
		var singleObject = true;
		ids = [ids];
	}
	
	for (let i=0; i<ids.length; i++) {
		let id = ids[i];
		// Check if already loaded
		if (this._objectCache[id]) {
			toReturn.push(this._objectCache[id]);
		}
		else {
			toLoad.push(id);
		}
	}
	
	// New object to load
	if (toLoad.length) {
		// Serialize loads
		if (this._loadPromise && this._loadPromise.isPending()) {
			yield this._loadPromise;
		}
		let deferred = Zotero.Promise.defer();
		this._loadPromise = deferred.promise;
		
		let loaded = yield this._load(null, toLoad, options);
		for (let i=0; i<toLoad.length; i++) {
			let id = toLoad[i];
			let obj = loaded[id];
			if (!obj) {
				Zotero.debug(this._ZDO_Object + " " + id + " doesn't exist", 2);
				continue;
			}
			toReturn.push(obj);
		}
		deferred.resolve();
	}
	
	// If single id, return the object directly
	if (singleObject) {
		return toReturn.length ? toReturn[0] : false;
	}
	
	return toReturn;
});


/**
 * Get all loaded objects
 *
 * @return {Zotero.DataObject[]}
 */
Zotero.DataObjects.prototype.getLoaded = function () {
	return Object.keys(this._objectCache).map(id => this._objectCache[id]);
}


Zotero.DataObjects.prototype.getAllKeys = function (libraryID) {
	var sql = "SELECT key FROM " + this._ZDO_table + " WHERE libraryID=?";
	return Zotero.DB.columnQueryAsync(sql, [libraryID]);
};


/**
 * @deprecated - use .libraryKey
 */
Zotero.DataObjects.prototype.makeLibraryKeyHash = function (libraryID, key) {
	Zotero.debug("WARNING: " + this._ZDO_Objects + ".makeLibraryKeyHash() is deprecated -- use .libraryKey instead");
	return libraryID + '_' + key;
}


/**
 * @deprecated - use .libraryKey
 */
Zotero.DataObjects.prototype.getLibraryKeyHash = function (obj) {
	Zotero.debug("WARNING: " + this._ZDO_Objects + ".getLibraryKeyHash() is deprecated -- use .libraryKey instead");
	return this.makeLibraryKeyHash(obj.libraryID, obj.key);
}


Zotero.DataObjects.prototype.parseLibraryKey = function (libraryKey) {
	var [libraryID, key] = libraryKey.split('/');
	return {
		libraryID: parseInt(libraryID),
		key: key
	};
}


/**
 * @deprecated - Use Zotero.DataObjects.parseLibraryKey()
 */
Zotero.DataObjects.prototype.parseLibraryKeyHash = function (libraryKey) {
	Zotero.debug("WARNING: " + this._ZDO_Objects + ".parseLibraryKeyHash() is deprecated -- use .parseLibraryKey() instead");
	var [libraryID, key] = libraryKey.split('_');
	if (!key) {
		return false;
	}
	return {
		libraryID: parseInt(libraryID),
		key: key
	};
}


/**
 * Retrieves an object by its libraryID and key
 *
 * @param	{Integer}		libraryID
 * @param	{String}			key
 * @return	{Zotero.DataObject}			Zotero data object, or FALSE if not found
 */
Zotero.DataObjects.prototype.getByLibraryAndKey = function (libraryID, key, options) {
	var id = this.getIDFromLibraryAndKey(libraryID, key);
	if (!id) {
		return false;
	}
	return Zotero[this._ZDO_Objects].get(id, options);
};


/**
 * Asynchronously retrieves an object by its libraryID and key
 *
 * @param {Integer} - libraryID
 * @param {String} - key
 * @param {Object} [options]
 * @param {Boolean} [options.noCache=false] - Don't add object to cache after loading
 * @return {Promise<Zotero.DataObject>} - Promise for a data object, or FALSE if not found
 */
Zotero.DataObjects.prototype.getByLibraryAndKeyAsync = Zotero.Promise.method(function (libraryID, key, options) {
	var id = this.getIDFromLibraryAndKey(libraryID, key);
	if (!id) {
		return false;
	}
	return Zotero[this._ZDO_Objects].getAsync(id, options);
});


Zotero.DataObjects.prototype.exists = function (id) {
	return !!this.getLibraryAndKeyFromID(id);
}


/**
 * @return {Object} Object with 'libraryID' and 'key'
 */
Zotero.DataObjects.prototype.getLibraryAndKeyFromID = function (id) {
	var lk = this._objectKeys[id];
	return lk ? { libraryID: lk[0], key: lk[1] } : false;
}


Zotero.DataObjects.prototype.getIDFromLibraryAndKey = function (libraryID, key) {
	if (!libraryID) {
		throw new Error("libraryID not provided");
	}
	return (this._objectIDs[libraryID] && this._objectIDs[libraryID][key])
		? this._objectIDs[libraryID][key] : false;
}


Zotero.DataObjects.prototype.getOlder = Zotero.Promise.method(function (libraryID, date) {
	if (!date || date.constructor.name != 'Date') {
		throw ("date must be a JS Date in "
			+ "Zotero." + this._ZDO_Objects + ".getOlder()")
	}
	
	var sql = "SELECT ROWID FROM " + this._ZDO_table
		+ " WHERE libraryID=? AND clientDateModified<?";
	return Zotero.DB.columnQueryAsync(sql, [libraryID, Zotero.Date.dateToSQL(date, true)]);
});


Zotero.DataObjects.prototype.getNewer = Zotero.Promise.method(function (libraryID, date, ignoreFutureDates) {
	if (!date || date.constructor.name != 'Date') {
		throw ("date must be a JS Date in "
			+ "Zotero." + this._ZDO_Objects + ".getNewer()")
	}
	
	var sql = "SELECT ROWID FROM " + this._ZDO_table
		+ " WHERE libraryID=? AND clientDateModified>?";
	if (ignoreFutureDates) {
		sql += " AND clientDateModified<=CURRENT_TIMESTAMP";
	}
	return Zotero.DB.columnQueryAsync(sql, [libraryID, Zotero.Date.dateToSQL(date, true)]);
});


/**
 * Gets the latest version for each object of a given type in the given library
 *
 * @return {Promise<Object>} - A promise for an object with object keys as keys and versions
 *                             as properties
 */
Zotero.DataObjects.prototype.getObjectVersions = Zotero.Promise.coroutine(function* (libraryID, keys = null) {
	var versions = {};
	
	if (keys) {
		yield Zotero.Utilities.Internal.forEachChunkAsync(
			keys,
			Zotero.DB.MAX_BOUND_PARAMETERS - 1,
			Zotero.Promise.coroutine(function* (chunk) {
				var sql = "SELECT key, version FROM " + this._ZDO_table
					+ " WHERE libraryID=? AND key IN (" + chunk.map(key => '?').join(', ') + ")";
				var rows = yield Zotero.DB.queryAsync(sql, [libraryID].concat(chunk));
				for (let i = 0; i < rows.length; i++) {
					let row = rows[i];
					versions[row.key] = row.version;
				}
			}.bind(this))
		);
	}
	else {
		let sql = "SELECT key, version FROM " + this._ZDO_table + " WHERE libraryID=?";
		let rows = yield Zotero.DB.queryAsync(sql, [libraryID]);
		for (let i = 0; i < rows.length; i++) {
			let row = rows[i];
			versions[row.key] = row.version;
		}
	}
	
	return versions;
});


/**
 * Bulk-load data type(s) of given objects if not loaded
 *
 * This would generally be used to load necessary data for cross-library search results, since those
 * results might include objects in libraries that haven't yet been loaded.
 *
 * @param {Zotero.DataObject[]} objects
 * @param {String[]} [dataTypes] - Data types to load, defaulting to all types
 * @return {Promise}
 */
Zotero.DataObjects.prototype.loadDataTypes = Zotero.Promise.coroutine(function* (objects, dataTypes) {
	if (!dataTypes) {
		dataTypes = this.ObjectClass.prototype._dataTypes;
	}
	for (let dataType of dataTypes) {
		let typeIDsByLibrary = {};
		for (let obj of objects) {
			if (obj._loaded[dataType]) {
				continue;
			}
			if (!typeIDsByLibrary[obj.libraryID]) {
				typeIDsByLibrary[obj.libraryID] = [];
			}
			typeIDsByLibrary[obj.libraryID].push(obj.id);
		}
		for (let libraryID in typeIDsByLibrary) {
			yield this._loadDataTypeInLibrary(dataType, parseInt(libraryID), typeIDsByLibrary[libraryID]);
		}
	}
});


/**
 * Loads data for a given data type
 * @param {String} dataType
 * @param {Integer} libraryID
 * @param {Integer[]} [ids]
 */
Zotero.DataObjects.prototype._loadDataTypeInLibrary = Zotero.Promise.coroutine(function* (dataType, libraryID, ids) {
	var funcName = "_load" + dataType[0].toUpperCase() + dataType.substr(1)
		// Single data types need an 's' (e.g., 'note' -> 'loadNotes()')
		+ ((dataType.endsWith('s') || dataType.endsWith('Data') ? '' : 's'));
	if (!this[funcName]) {
		throw new Error(`Zotero.${this._ZDO_Objects}.${funcName} is not a function`);
	}
	
	if (ids && ids.length == 0) {
		return;
	}
	
	var t = new Date;
	var libraryName = Zotero.Libraries.get(libraryID).name;
	
	var idSQL = "";
	if (ids) {
		idSQL = " AND " + this.idColumn + " IN (" + ids.map(id => parseInt(id)).join(", ") + ")";
	}
	
	Zotero.debug("Loading " + dataType + " for "
		+ (ids
			? ids.length + " " + (ids.length == 1 ? this._ZDO_object : this._ZDO_objects)
			: this._ZDO_objects)
		+ " in " + libraryName);
	
	yield this[funcName](libraryID, ids ? ids : [], idSQL);
	
	Zotero.debug(`Loaded ${dataType} in ${libraryName} in ${new Date() - t} ms`);
});

Zotero.DataObjects.prototype.loadAll = Zotero.Promise.coroutine(function* (libraryID, ids) {
	var t = new Date();
	var library = Zotero.Libraries.get(libraryID)
	
	Zotero.debug("Loading "
		+ (ids ? ids.length : "all") + " "
		+ (ids && ids.length == 1 ? this._ZDO_object : this._ZDO_objects)
		+ " in " + library.name);
	
	library.setDataLoading(this._ZDO_object);
	
	let dataTypes = this.ObjectClass.prototype._dataTypes;
	for (let i = 0; i < dataTypes.length; i++) {
		yield this._loadDataTypeInLibrary(dataTypes[i], libraryID, ids);
	}
	
	Zotero.debug(`Loaded all ${this._ZDO_objects} in ${library.name} in ${new Date() - t} ms`);
	
	library.setDataLoaded(this._ZDO_object);
});


Zotero.DataObjects.prototype._loadPrimaryData = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL, options) {
	var loaded = {};
	
	// If library isn't an integer (presumably false or null), skip it
	if (parseInt(libraryID) != libraryID) {
		libraryID = false;
	}
	
	var sql = this.primaryDataSQL;
	var params = [];
	if (libraryID !== false) {
		sql += ' AND O.libraryID=?';
		params.push(libraryID);
	}
	if (ids.length) {
		sql += ' AND O.' + this._ZDO_id + ' IN (' + ids.join(',') + ')';
	}
	
	yield Zotero.DB.queryAsync(
		sql,
		params,
		{
			onRow: function (row) {
				var id = row.getResultByName(this._ZDO_id);
				var columns = Object.keys(this._primaryDataSQLParts);
				var rowObj = {};
				for (let i=0; i<columns.length; i++) {
					rowObj[columns[i]] = row.getResultByIndex(i);
				}
				var obj;
				
				// Existing object -- reload in place
				if (this._objectCache[id]) {
					this._objectCache[id].loadFromRow(rowObj, true);
					obj = this._objectCache[id];
				}
				// Object doesn't exist -- create new object and stuff in cache
				else {
					obj = this._getObjectForRow(rowObj);
					obj.loadFromRow(rowObj, true);
					if (!options || !options.noCache) {
						this.registerObject(obj);
					}
				}
				loaded[id] = obj;
			}.bind(this)
		}
	);
	
	if (!ids) {
		this._loadedLibraries[libraryID] = true;
		
		// If loading all objects, remove cached objects that no longer exist
		for (let i in this._objectCache) {
			let obj = this._objectCache[i];
			if (libraryID !== false && obj.libraryID !== libraryID) {
				continue;
			}
			if (!loaded[obj.id]) {
				this.unload(obj.id);
			}
		}
		
		if (this._postLoad) {
			this._postLoad(libraryID, ids);
		}
	}
	
	return loaded;
});


Zotero.DataObjects.prototype._loadRelations = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL) {
	if (!this._relationsTable) {
		throw new Error("Relations not supported for " + this._ZDO_objects);
	}
	
	var sql = "SELECT " + this.idColumn + ", predicate, object "
		+ `FROM ${this.table} LEFT JOIN ${this._relationsTable} USING (${this.idColumn}) `
		+ "LEFT JOIN relationPredicates USING (predicateID) "
		+ "WHERE libraryID=?" + idSQL;
	var params = [libraryID];
	
	var lastID;
	var rows = [];
	var setRows = function (id, rows) {
		var obj = this._objectCache[id];
		if (!obj) {
			throw new Error(this._ZDO_Object + " " + id + " not found");
		}
		
		var relations = {};
		function addRel(predicate, object) {
			if (!relations[predicate]) {
				relations[predicate] = [];
			}
			relations[predicate].push(object);
		}
		
		for (let i = 0; i < rows.length; i++) {
			let row = rows[i];
			addRel(row.predicate, row.object);
		}
		
		/*if (this._objectType == 'item') {
			let getURI = Zotero.URI["get" + this._ObjectType + "URI"].bind(Zotero.URI);
			let objectURI = getURI(this);
			
			// Related items are bidirectional, so include any pointing to this object
			let objects = Zotero.Relations.getByPredicateAndObject(
				Zotero.Relations.relatedItemPredicate, objectURI
			);
			for (let i = 0; i < objects.length; i++) {
				addRel(Zotero.Relations.relatedItemPredicate, getURI(objects[i]));
			}
			
			// Also include any owl:sameAs relations pointing to this object
			objects = Zotero.Relations.getByPredicateAndObject(
				Zotero.Relations.linkedObjectPredicate, objectURI
			);
			for (let i = 0; i < objects.length; i++) {
				addRel(Zotero.Relations.linkedObjectPredicate, getURI(objects[i]));
			}
		}*/
		
		// Relations are stored as predicate-object pairs
		obj._relations = this.flattenRelations(relations);
		obj._loaded.relations = true;
		obj._clearChanged('relations');
	}.bind(this);
	
	yield Zotero.DB.queryAsync(
		sql,
		params,
		{
			noCache: ids.length != 1,
			onRow: function (row) {
				let id = row.getResultByIndex(0);
				
				if (lastID && id !== lastID) {
					setRows(lastID, rows);
					rows = [];
				}
				
				lastID = id;
				let predicate = row.getResultByIndex(1);
				// No relations
				if (predicate === null) {
					return;
				}
				rows.push({
					predicate,
					object: row.getResultByIndex(2)
				});
			}.bind(this)
		}
	);
	
	if (lastID) {
		setRows(lastID, rows);
	}
});


/**
 * Flatten API JSON relations object into an array of unique predicate-object pairs
 *
 * @param {Object} relations - Relations object in API JSON format, with predicates as keys
 *                             and arrays of URIs as objects
 * @return {Array[]} - Predicate-object pairs
 */
Zotero.DataObjects.prototype.flattenRelations = function (relations) {
	var relationsFlat = [];
	for (let predicate in relations) {
		let object = relations[predicate];
		if (Array.isArray(object)) {
			object = Zotero.Utilities.arrayUnique(object);
			for (let i = 0; i < object.length; i++) {
				relationsFlat.push([predicate, object[i]]);
			}
		}
		else if (typeof object == 'string') {
			relationsFlat.push([predicate, object]);
		}
		else {
			Zotero.debug(object, 1);
			throw new Error("Invalid relation value");
		}
	}
	return relationsFlat;
}


/**
 * Reload loaded data of loaded objects
 *
 * @param {Array|Number} ids - An id or array of ids
 * @param {Array} [dataTypes] - Data types to reload (e.g., 'primaryData'), or all loaded
 *                              types if not provided
   * @param {Boolean} [reloadUnchanged=false] - Reload even data that hasn't changed internally.
   *                                            This should be set to true for data that was
   *                                            changed externally (e.g., globally renamed tags).
   */
Zotero.DataObjects.prototype.reload = Zotero.Promise.coroutine(function* (ids, dataTypes, reloadUnchanged) {
	ids = Zotero.flattenArguments(ids);
	
	Zotero.debug('Reloading ' + (dataTypes ? '[' + dataTypes.join(', ') + '] for ' : '')
		+ this._ZDO_objects + ' ' + ids);
	
	// If data types not specified, reload loaded data for each object individually.
	// TODO: optimize
	if (!dataTypes) {
		for (let i=0; i<ids.length; i++) {
			if (this._objectCache[ids[i]]) {
				yield this._objectCache[ids[i]].reload(dataTypes, reloadUnchanged);
			}
		}
		return;
	}
	
	for (let dataType of dataTypes) {
		let typeIDsByLibrary = {};
		for (let id of ids) {
			let obj = this._objectCache[id];
			if (!obj || !obj._loaded[dataType] || obj._skipDataTypeLoad[dataType]
					|| (!reloadUnchanged && !obj._changed[dataType])) {
				continue;
			}
			if (!typeIDsByLibrary[obj.libraryID]) {
				typeIDsByLibrary[obj.libraryID] = [];
			}
			typeIDsByLibrary[obj.libraryID].push(id);
		}
		for (let libraryID in typeIDsByLibrary) {
			yield this._loadDataTypeInLibrary(dataType, parseInt(libraryID), typeIDsByLibrary[libraryID]);
		}
	}
	
	return true;
});


Zotero.DataObjects.prototype.reloadAll = function (libraryID) {
	Zotero.debug("Reloading all " + this._ZDO_objects);
	
	// Remove objects not stored in database
	var sql = "SELECT ROWID FROM " + this._ZDO_table;
	var params = [];
	if (libraryID !== undefined) {
		sql += ' WHERE libraryID=?';
		params.push(libraryID);
	}
	return Zotero.DB.columnQueryAsync(sql, params)
	.then(function (ids) {
		for (var id in this._objectCache) {
			if (!ids || ids.indexOf(parseInt(id)) == -1) {
				delete this._objectCache[id];
			}
		}
		
		// Reload data
		this._loadedLibraries[libraryID] = false;
		return this._load(libraryID);
	});
}


Zotero.DataObjects.prototype.registerObject = function (obj) {
	var id = obj.id;
	var libraryID = obj.libraryID;
	var key = obj.key;
	
	//Zotero.debug("Registering " + this._ZDO_object + " " + id + " as " + libraryID + "/" + key);
	if (!this._objectIDs[libraryID]) {
		this._objectIDs[libraryID] = {};
	}
	this._objectIDs[libraryID][key] = id;
	this._objectKeys[id] = [libraryID, key];
	this._objectCache[id] = obj;
	obj._inCache = true;
}

Zotero.DataObjects.prototype.dropDeadObjectsFromCache = function() {
	let ids = [];
	for (let libraryID in this._objectIDs) {
		if (Zotero.Libraries.exists(libraryID)) continue;
		for (let key in this._objectIDs[libraryID]) {
			ids.push(this._objectIDs[libraryID][key]);
		}
	}
	
	this.unload(ids);
}

/**
 * Clear object from internal array
 *
 * @param	int[]	ids		objectIDs
 */
Zotero.DataObjects.prototype.unload = function () {
	var ids = Zotero.flattenArguments(arguments);
	for (var i=0; i<ids.length; i++) {
		let id = ids[i];
		let {libraryID, key} = this.getLibraryAndKeyFromID(id);
		if (key) {
			delete this._objectIDs[libraryID][key];
			delete this._objectKeys[id];
		}
		delete this._objectCache[id];
	}
}


/**
 * Set the version of objects, efficiently
 *
 * @param {Integer[]} ids - Ids of objects to update
 * @param {Boolean} version
 */
Zotero.DataObjects.prototype.updateVersion = Zotero.Promise.method(function (ids, version) {
	if (version != parseInt(version)) {
		throw new Error("'version' must be an integer ('" + version + "' given)");
	}
	version = parseInt(version);
	
	let sql = "UPDATE " + this.table + " SET version=" + version + " "
		+ "WHERE " + this.idColumn + " IN (";
	return Zotero.Utilities.Internal.forEachChunkAsync(
		ids,
		Zotero.DB.MAX_BOUND_PARAMETERS,
		Zotero.Promise.coroutine(function* (chunk) {
			yield Zotero.DB.queryAsync(sql + chunk.map(() => '?').join(', ') + ')', chunk);
			// Update the internal 'version' property of any loaded objects
			for (let i = 0; i < chunk.length; i++) {
				let id = chunk[i];
				let obj = this._objectCache[id];
				if (obj) {
					obj.updateVersion(version, true);
				}
			}
		}.bind(this))
	);
});


/**
 * Set the sync state of objects, efficiently
 *
 * @param {Integer[]} ids - Ids of objects to update
 * @param {Boolean} synced
 */
Zotero.DataObjects.prototype.updateSynced = Zotero.Promise.method(function (ids, synced) {
	let sql = "UPDATE " + this.table + " SET synced=" + (synced ? 1 : 0) + " "
		+ "WHERE " + this.idColumn + " IN (";
	return Zotero.Utilities.Internal.forEachChunkAsync(
		ids,
		Zotero.DB.MAX_BOUND_PARAMETERS,
		Zotero.Promise.coroutine(function* (chunk) {
			yield Zotero.DB.queryAsync(sql + chunk.map(() => '?').join(', ') + ')', chunk);
			// Update the internal 'synced' property of any loaded objects
			for (let i = 0; i < chunk.length; i++) {
				let id = chunk[i];
				let obj = this._objectCache[id];
				if (obj) {
					obj.updateSynced(!!synced, true);
				}
			}
		}.bind(this))
	);
});


Zotero.DataObjects.prototype.isEditable = function (obj) {
	var libraryID = obj.libraryID;
	if (!libraryID) {
		return true;
	}
	
	if (!Zotero.Libraries.get(libraryID).editable) return false;
	
	if (obj.objectType == 'item' && obj.isAttachment()
		&& (obj.attachmentLinkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL ||
			obj.attachmentLinkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE)
		&& !Zotero.Libraries.get(libraryID).filesEditable
	) {
		return false;
	}
	
	return true;
}

Zotero.defineProperty(Zotero.DataObjects.prototype, "primaryDataSQL", {
	get: function () {
		return "SELECT "
		+ Object.keys(this._primaryDataSQLParts).map((val) => this._primaryDataSQLParts[val]).join(', ')
		+ this.primaryDataSQLFrom;
	}
}, {lazy: true});

Zotero.DataObjects.prototype.getPrimaryDataSQLPart = function (part) {
	var sql = this._primaryDataSQLParts[part];
	if (!sql) {
		throw new Error("Invalid primary data SQL part '" + part + "'");
	}
	return sql;
}


/**
 * Delete one or more objects from the database and caches
 *
 * @param {Integer|Integer[]} ids - Object ids
 * @param {Object} [options] - See Zotero.DataObject.prototype.erase
 * @return {Promise}
 */
Zotero.DataObjects.prototype.erase = Zotero.Promise.coroutine(function* (ids, options = {}) {
	ids = Zotero.flattenArguments(ids);
	yield Zotero.DB.executeTransaction(function* () {
		for (let i = 0; i < ids.length; i++) {
			let obj = yield this.getAsync(ids[i]);
			if (!obj) {
				continue;
			}
			yield obj.erase(options);
		}
		this.unload(ids);
	}.bind(this));
});


// TEMP: remove
Zotero.DataObjects.prototype._load = Zotero.Promise.coroutine(function* (libraryID, ids, options) {
	var loaded = {};

	// If library isn't an integer (presumably false or null), skip it
	if (parseInt(libraryID) != libraryID) {
		libraryID = false;
	}

	if (libraryID === false && !ids) {
		throw new Error("Either libraryID or ids must be provided");
	}

	if (libraryID !== false && this._loadedLibraries[libraryID]) {
		return loaded;
	}

	var sql = this.primaryDataSQL;
	var params = [];
	if (libraryID !== false) {
		sql += ' AND O.libraryID=?';
		params.push(libraryID);
	}
	if (ids) {
		sql += ' AND O.' + this._ZDO_id + ' IN (' + ids.join(',') + ')';
	}

	var t = new Date();
	yield Zotero.DB.queryAsync(
		sql,
		params,
		{
			onRow: function (row) {
				var id = row.getResultByName(this._ZDO_id);
				var columns = Object.keys(this._primaryDataSQLParts);
				var rowObj = {};
				for (let i=0; i<columns.length; i++) {
					rowObj[columns[i]] = row.getResultByIndex(i);
				}
				var obj;

				// Existing object -- reload in place
				if (this._objectCache[id]) {
					this._objectCache[id].loadFromRow(rowObj, true);
					obj = this._objectCache[id];
				}
				// Object doesn't exist -- create new object and stuff in cache
				else {
					obj = this._getObjectForRow(rowObj);
					obj.loadFromRow(rowObj, true);
					if (!options || !options.noCache) {
						this.registerObject(obj);
					}
				}
				loaded[id] = obj;
			}.bind(this)
		}
	);
	Zotero.debug("Loaded " + this._ZDO_objects + " in " + ((new Date) - t) + "ms");

	if (!ids) {
		this._loadedLibraries[libraryID] = true;

		// If loading all objects, remove cached objects that no longer exist
		for (let i in this._objectCache) {
			let obj = this._objectCache[i];
			if (libraryID !== false && obj.libraryID !== libraryID) {
				continue;
			}
			if (!loaded[obj.id]) {
				this.unload(obj.id);
			}
		}

		if (this._postLoad) {
			this._postLoad(libraryID, ids);
		}
	}

	return loaded;
});



Zotero.DataObjects.prototype._getObjectForRow = function(row) {
	return new Zotero[this._ZDO_Object];
};

Zotero.DataObjects.prototype._loadIDsAndKeys = Zotero.Promise.coroutine(function* () {
	var sql = "SELECT ROWID AS id, libraryID, key FROM " + this._ZDO_table;
	var rows = yield Zotero.DB.queryAsync(sql);
	for (let i=0; i<rows.length; i++) {
		let row = rows[i];
		this._objectKeys[row.id] = [row.libraryID, row.key];
		if (!this._objectIDs[row.libraryID]) {
			this._objectIDs[row.libraryID] = {};
		}
		this._objectIDs[row.libraryID][row.key] = row.id;
	}
});
<commit_msg>Throw if invalid object id passed to Zotero.DataObjects::getAsync()<commit_after>/*
    ***** BEGIN LICENSE BLOCK *****
    
    Copyright © 2009 Center for History and New Media
                     George Mason University, Fairfax, Virginia, USA
                     http://zotero.org
    
    This file is part of Zotero.
    
    Zotero is free software: you can redistribute it and/or modify
    it under the terms of the GNU Affero General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.
    
    Zotero is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU Affero General Public License for more details.
    
    You should have received a copy of the GNU Affero General Public License
    along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
    
    ***** END LICENSE BLOCK *****
*/


Zotero.DataObjects = function () {
	if (!this._ZDO_object) throw new Error('this._ZDO_object must be set before calling Zotero.DataObjects constructor');
	
	if (!this._ZDO_objects) {
		this._ZDO_objects = Zotero.DataObjectUtilities.getObjectTypePlural(this._ZDO_object);
	}
	if (!this._ZDO_Object) {
		this._ZDO_Object = this._ZDO_object.substr(0, 1).toUpperCase()
			+ this._ZDO_object.substr(1);
	}
	if (!this._ZDO_Objects) {
		this._ZDO_Objects = this._ZDO_objects.substr(0, 1).toUpperCase()
			+ this._ZDO_objects.substr(1);
	}
	
	if (!this._ZDO_id) {
		this._ZDO_id = this._ZDO_object + 'ID';
	}
	
	if (!this._ZDO_table) {
		this._ZDO_table = this._ZDO_objects;
	}
	
	if (!this.ObjectClass) {
		this.ObjectClass = Zotero[this._ZDO_Object];
	}
	
	this._objectCache = {};
	this._objectKeys = {};
	this._objectIDs = {};
	this._loadedLibraries = {};
	this._loadPromise = null;
}

Zotero.DataObjects.prototype._ZDO_idOnly = false;

// Public properties
Zotero.defineProperty(Zotero.DataObjects.prototype, 'idColumn', {
	get: function() this._ZDO_id
});
Zotero.defineProperty(Zotero.DataObjects.prototype, 'table', {
	get: function() this._ZDO_table
});

Zotero.defineProperty(Zotero.DataObjects.prototype, 'relationsTable', {
	get: function() this._ZDO_object + 'Relations'
});

Zotero.defineProperty(Zotero.DataObjects.prototype, 'primaryFields', {
	get: function () Object.keys(this._primaryDataSQLParts)
}, {lazy: true});

Zotero.defineProperty(Zotero.DataObjects.prototype, "_primaryDataSQLWhere", {
	value: "WHERE 1"
});

Zotero.defineProperty(Zotero.DataObjects.prototype, 'primaryDataSQLFrom', {
	get: function() " " + this._primaryDataSQLFrom + " " + this._primaryDataSQLWhere
}, {lateInit: true});

Zotero.DataObjects.prototype.init = function() {
	return this._loadIDsAndKeys();
}


Zotero.DataObjects.prototype.isPrimaryField = function (field) {
	return this.primaryFields.indexOf(field) != -1;
}


/**
 * Retrieves one or more already-loaded items
 *
 * If an item hasn't been loaded, an error is thrown
 *
 * @param {Array|Integer} ids  An individual object id or an array of object ids
 * @return {Zotero.[Object]|Array<Zotero.[Object]>} A Zotero.[Object], if a scalar id was passed;
 *                                          otherwise, an array of Zotero.[Object]
 */
Zotero.DataObjects.prototype.get = function (ids) {
	if (Array.isArray(ids)) {
		var singleObject = false;
	}
	else {
		var singleObject = true;
		ids = [ids];
	}
	
	var toReturn = [];
	
	for (let i=0; i<ids.length; i++) {
		let id = ids[i];
		// Check if already loaded
		if (!this._objectCache[id]) {
			// If unloaded id is registered, throw an error
			if (this._objectKeys[id]) {
				throw new Zotero.Exception.UnloadedDataException(
					this._ZDO_Object + " " + id + " not yet loaded"
				);
			}
			// Otherwise ignore (which means returning false for a single id)
			else {
				continue;
			}
		}
		toReturn.push(this._objectCache[id]);
	}
	
	// If single id, return the object directly
	if (singleObject) {
		return toReturn.length ? toReturn[0] : false;
	}
	
	return toReturn;
};
	
	
/**
 * Retrieves (and loads, if necessary) one or more items
 *
 * @param {Array|Integer} ids  An individual object id or an array of object ids
 * @param {Object} [options]
 * @param {Boolean} [options.noCache=false] - Don't add object to cache after loading
 * @return {Promise<Zotero.DataObject|Zotero.DataObject[]>} - A promise for either a data object,
 *     if a scalar id was passed, or an array of data objects, if an array of ids was passed
 */
Zotero.DataObjects.prototype.getAsync = Zotero.Promise.coroutine(function* (ids, options) {
	var toLoad = [];
	var toReturn = [];
	
	if (!ids) {
		throw new Error("No arguments provided");
	}
	
	if (options && typeof options != 'object') {
		throw new Error(`'options' must be an object, ${typeof options} given`);
	}
	
	if (Array.isArray(ids)) {
		var singleObject = false;
	}
	else {
		var singleObject = true;
		ids = [ids];
	}
	
	for (let i=0; i<ids.length; i++) {
		let id = ids[i];
		// Check if already loaded
		if (this._objectCache[id]) {
			toReturn.push(this._objectCache[id]);
		}
		else {
			if (!ids.every(id => Number.isInteger(id))) {
				throw new Error(`Invalid ${this._ZDO_object} ID '${id}'`);
			}
			toLoad.push(id);
		}
	}
	
	// New object to load
	if (toLoad.length) {
		// Serialize loads
		if (this._loadPromise && this._loadPromise.isPending()) {
			yield this._loadPromise;
		}
		let deferred = Zotero.Promise.defer();
		this._loadPromise = deferred.promise;
		
		let loaded = yield this._load(null, toLoad, options);
		for (let i=0; i<toLoad.length; i++) {
			let id = toLoad[i];
			let obj = loaded[id];
			if (!obj) {
				Zotero.debug(this._ZDO_Object + " " + id + " doesn't exist", 2);
				continue;
			}
			toReturn.push(obj);
		}
		deferred.resolve();
	}
	
	// If single id, return the object directly
	if (singleObject) {
		return toReturn.length ? toReturn[0] : false;
	}
	
	return toReturn;
});


/**
 * Get all loaded objects
 *
 * @return {Zotero.DataObject[]}
 */
Zotero.DataObjects.prototype.getLoaded = function () {
	return Object.keys(this._objectCache).map(id => this._objectCache[id]);
}


Zotero.DataObjects.prototype.getAllKeys = function (libraryID) {
	var sql = "SELECT key FROM " + this._ZDO_table + " WHERE libraryID=?";
	return Zotero.DB.columnQueryAsync(sql, [libraryID]);
};


/**
 * @deprecated - use .libraryKey
 */
Zotero.DataObjects.prototype.makeLibraryKeyHash = function (libraryID, key) {
	Zotero.debug("WARNING: " + this._ZDO_Objects + ".makeLibraryKeyHash() is deprecated -- use .libraryKey instead");
	return libraryID + '_' + key;
}


/**
 * @deprecated - use .libraryKey
 */
Zotero.DataObjects.prototype.getLibraryKeyHash = function (obj) {
	Zotero.debug("WARNING: " + this._ZDO_Objects + ".getLibraryKeyHash() is deprecated -- use .libraryKey instead");
	return this.makeLibraryKeyHash(obj.libraryID, obj.key);
}


Zotero.DataObjects.prototype.parseLibraryKey = function (libraryKey) {
	var [libraryID, key] = libraryKey.split('/');
	return {
		libraryID: parseInt(libraryID),
		key: key
	};
}


/**
 * @deprecated - Use Zotero.DataObjects.parseLibraryKey()
 */
Zotero.DataObjects.prototype.parseLibraryKeyHash = function (libraryKey) {
	Zotero.debug("WARNING: " + this._ZDO_Objects + ".parseLibraryKeyHash() is deprecated -- use .parseLibraryKey() instead");
	var [libraryID, key] = libraryKey.split('_');
	if (!key) {
		return false;
	}
	return {
		libraryID: parseInt(libraryID),
		key: key
	};
}


/**
 * Retrieves an object by its libraryID and key
 *
 * @param	{Integer}		libraryID
 * @param	{String}			key
 * @return	{Zotero.DataObject}			Zotero data object, or FALSE if not found
 */
Zotero.DataObjects.prototype.getByLibraryAndKey = function (libraryID, key, options) {
	var id = this.getIDFromLibraryAndKey(libraryID, key);
	if (!id) {
		return false;
	}
	return Zotero[this._ZDO_Objects].get(id, options);
};


/**
 * Asynchronously retrieves an object by its libraryID and key
 *
 * @param {Integer} - libraryID
 * @param {String} - key
 * @param {Object} [options]
 * @param {Boolean} [options.noCache=false] - Don't add object to cache after loading
 * @return {Promise<Zotero.DataObject>} - Promise for a data object, or FALSE if not found
 */
Zotero.DataObjects.prototype.getByLibraryAndKeyAsync = Zotero.Promise.method(function (libraryID, key, options) {
	var id = this.getIDFromLibraryAndKey(libraryID, key);
	if (!id) {
		return false;
	}
	return Zotero[this._ZDO_Objects].getAsync(id, options);
});


Zotero.DataObjects.prototype.exists = function (id) {
	return !!this.getLibraryAndKeyFromID(id);
}


/**
 * @return {Object} Object with 'libraryID' and 'key'
 */
Zotero.DataObjects.prototype.getLibraryAndKeyFromID = function (id) {
	var lk = this._objectKeys[id];
	return lk ? { libraryID: lk[0], key: lk[1] } : false;
}


Zotero.DataObjects.prototype.getIDFromLibraryAndKey = function (libraryID, key) {
	if (!libraryID) {
		throw new Error("libraryID not provided");
	}
	return (this._objectIDs[libraryID] && this._objectIDs[libraryID][key])
		? this._objectIDs[libraryID][key] : false;
}


Zotero.DataObjects.prototype.getOlder = Zotero.Promise.method(function (libraryID, date) {
	if (!date || date.constructor.name != 'Date') {
		throw ("date must be a JS Date in "
			+ "Zotero." + this._ZDO_Objects + ".getOlder()")
	}
	
	var sql = "SELECT ROWID FROM " + this._ZDO_table
		+ " WHERE libraryID=? AND clientDateModified<?";
	return Zotero.DB.columnQueryAsync(sql, [libraryID, Zotero.Date.dateToSQL(date, true)]);
});


Zotero.DataObjects.prototype.getNewer = Zotero.Promise.method(function (libraryID, date, ignoreFutureDates) {
	if (!date || date.constructor.name != 'Date') {
		throw ("date must be a JS Date in "
			+ "Zotero." + this._ZDO_Objects + ".getNewer()")
	}
	
	var sql = "SELECT ROWID FROM " + this._ZDO_table
		+ " WHERE libraryID=? AND clientDateModified>?";
	if (ignoreFutureDates) {
		sql += " AND clientDateModified<=CURRENT_TIMESTAMP";
	}
	return Zotero.DB.columnQueryAsync(sql, [libraryID, Zotero.Date.dateToSQL(date, true)]);
});


/**
 * Gets the latest version for each object of a given type in the given library
 *
 * @return {Promise<Object>} - A promise for an object with object keys as keys and versions
 *                             as properties
 */
Zotero.DataObjects.prototype.getObjectVersions = Zotero.Promise.coroutine(function* (libraryID, keys = null) {
	var versions = {};
	
	if (keys) {
		yield Zotero.Utilities.Internal.forEachChunkAsync(
			keys,
			Zotero.DB.MAX_BOUND_PARAMETERS - 1,
			Zotero.Promise.coroutine(function* (chunk) {
				var sql = "SELECT key, version FROM " + this._ZDO_table
					+ " WHERE libraryID=? AND key IN (" + chunk.map(key => '?').join(', ') + ")";
				var rows = yield Zotero.DB.queryAsync(sql, [libraryID].concat(chunk));
				for (let i = 0; i < rows.length; i++) {
					let row = rows[i];
					versions[row.key] = row.version;
				}
			}.bind(this))
		);
	}
	else {
		let sql = "SELECT key, version FROM " + this._ZDO_table + " WHERE libraryID=?";
		let rows = yield Zotero.DB.queryAsync(sql, [libraryID]);
		for (let i = 0; i < rows.length; i++) {
			let row = rows[i];
			versions[row.key] = row.version;
		}
	}
	
	return versions;
});


/**
 * Bulk-load data type(s) of given objects if not loaded
 *
 * This would generally be used to load necessary data for cross-library search results, since those
 * results might include objects in libraries that haven't yet been loaded.
 *
 * @param {Zotero.DataObject[]} objects
 * @param {String[]} [dataTypes] - Data types to load, defaulting to all types
 * @return {Promise}
 */
Zotero.DataObjects.prototype.loadDataTypes = Zotero.Promise.coroutine(function* (objects, dataTypes) {
	if (!dataTypes) {
		dataTypes = this.ObjectClass.prototype._dataTypes;
	}
	for (let dataType of dataTypes) {
		let typeIDsByLibrary = {};
		for (let obj of objects) {
			if (obj._loaded[dataType]) {
				continue;
			}
			if (!typeIDsByLibrary[obj.libraryID]) {
				typeIDsByLibrary[obj.libraryID] = [];
			}
			typeIDsByLibrary[obj.libraryID].push(obj.id);
		}
		for (let libraryID in typeIDsByLibrary) {
			yield this._loadDataTypeInLibrary(dataType, parseInt(libraryID), typeIDsByLibrary[libraryID]);
		}
	}
});


/**
 * Loads data for a given data type
 * @param {String} dataType
 * @param {Integer} libraryID
 * @param {Integer[]} [ids]
 */
Zotero.DataObjects.prototype._loadDataTypeInLibrary = Zotero.Promise.coroutine(function* (dataType, libraryID, ids) {
	var funcName = "_load" + dataType[0].toUpperCase() + dataType.substr(1)
		// Single data types need an 's' (e.g., 'note' -> 'loadNotes()')
		+ ((dataType.endsWith('s') || dataType.endsWith('Data') ? '' : 's'));
	if (!this[funcName]) {
		throw new Error(`Zotero.${this._ZDO_Objects}.${funcName} is not a function`);
	}
	
	if (ids && ids.length == 0) {
		return;
	}
	
	var t = new Date;
	var libraryName = Zotero.Libraries.get(libraryID).name;
	
	var idSQL = "";
	if (ids) {
		idSQL = " AND " + this.idColumn + " IN (" + ids.map(id => parseInt(id)).join(", ") + ")";
	}
	
	Zotero.debug("Loading " + dataType + " for "
		+ (ids
			? ids.length + " " + (ids.length == 1 ? this._ZDO_object : this._ZDO_objects)
			: this._ZDO_objects)
		+ " in " + libraryName);
	
	yield this[funcName](libraryID, ids ? ids : [], idSQL);
	
	Zotero.debug(`Loaded ${dataType} in ${libraryName} in ${new Date() - t} ms`);
});

Zotero.DataObjects.prototype.loadAll = Zotero.Promise.coroutine(function* (libraryID, ids) {
	var t = new Date();
	var library = Zotero.Libraries.get(libraryID)
	
	Zotero.debug("Loading "
		+ (ids ? ids.length : "all") + " "
		+ (ids && ids.length == 1 ? this._ZDO_object : this._ZDO_objects)
		+ " in " + library.name);
	
	library.setDataLoading(this._ZDO_object);
	
	let dataTypes = this.ObjectClass.prototype._dataTypes;
	for (let i = 0; i < dataTypes.length; i++) {
		yield this._loadDataTypeInLibrary(dataTypes[i], libraryID, ids);
	}
	
	Zotero.debug(`Loaded all ${this._ZDO_objects} in ${library.name} in ${new Date() - t} ms`);
	
	library.setDataLoaded(this._ZDO_object);
});


Zotero.DataObjects.prototype._loadPrimaryData = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL, options) {
	var loaded = {};
	
	// If library isn't an integer (presumably false or null), skip it
	if (parseInt(libraryID) != libraryID) {
		libraryID = false;
	}
	
	var sql = this.primaryDataSQL;
	var params = [];
	if (libraryID !== false) {
		sql += ' AND O.libraryID=?';
		params.push(libraryID);
	}
	if (ids.length) {
		sql += ' AND O.' + this._ZDO_id + ' IN (' + ids.join(',') + ')';
	}
	
	yield Zotero.DB.queryAsync(
		sql,
		params,
		{
			onRow: function (row) {
				var id = row.getResultByName(this._ZDO_id);
				var columns = Object.keys(this._primaryDataSQLParts);
				var rowObj = {};
				for (let i=0; i<columns.length; i++) {
					rowObj[columns[i]] = row.getResultByIndex(i);
				}
				var obj;
				
				// Existing object -- reload in place
				if (this._objectCache[id]) {
					this._objectCache[id].loadFromRow(rowObj, true);
					obj = this._objectCache[id];
				}
				// Object doesn't exist -- create new object and stuff in cache
				else {
					obj = this._getObjectForRow(rowObj);
					obj.loadFromRow(rowObj, true);
					if (!options || !options.noCache) {
						this.registerObject(obj);
					}
				}
				loaded[id] = obj;
			}.bind(this)
		}
	);
	
	if (!ids) {
		this._loadedLibraries[libraryID] = true;
		
		// If loading all objects, remove cached objects that no longer exist
		for (let i in this._objectCache) {
			let obj = this._objectCache[i];
			if (libraryID !== false && obj.libraryID !== libraryID) {
				continue;
			}
			if (!loaded[obj.id]) {
				this.unload(obj.id);
			}
		}
		
		if (this._postLoad) {
			this._postLoad(libraryID, ids);
		}
	}
	
	return loaded;
});


Zotero.DataObjects.prototype._loadRelations = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL) {
	if (!this._relationsTable) {
		throw new Error("Relations not supported for " + this._ZDO_objects);
	}
	
	var sql = "SELECT " + this.idColumn + ", predicate, object "
		+ `FROM ${this.table} LEFT JOIN ${this._relationsTable} USING (${this.idColumn}) `
		+ "LEFT JOIN relationPredicates USING (predicateID) "
		+ "WHERE libraryID=?" + idSQL;
	var params = [libraryID];
	
	var lastID;
	var rows = [];
	var setRows = function (id, rows) {
		var obj = this._objectCache[id];
		if (!obj) {
			throw new Error(this._ZDO_Object + " " + id + " not found");
		}
		
		var relations = {};
		function addRel(predicate, object) {
			if (!relations[predicate]) {
				relations[predicate] = [];
			}
			relations[predicate].push(object);
		}
		
		for (let i = 0; i < rows.length; i++) {
			let row = rows[i];
			addRel(row.predicate, row.object);
		}
		
		/*if (this._objectType == 'item') {
			let getURI = Zotero.URI["get" + this._ObjectType + "URI"].bind(Zotero.URI);
			let objectURI = getURI(this);
			
			// Related items are bidirectional, so include any pointing to this object
			let objects = Zotero.Relations.getByPredicateAndObject(
				Zotero.Relations.relatedItemPredicate, objectURI
			);
			for (let i = 0; i < objects.length; i++) {
				addRel(Zotero.Relations.relatedItemPredicate, getURI(objects[i]));
			}
			
			// Also include any owl:sameAs relations pointing to this object
			objects = Zotero.Relations.getByPredicateAndObject(
				Zotero.Relations.linkedObjectPredicate, objectURI
			);
			for (let i = 0; i < objects.length; i++) {
				addRel(Zotero.Relations.linkedObjectPredicate, getURI(objects[i]));
			}
		}*/
		
		// Relations are stored as predicate-object pairs
		obj._relations = this.flattenRelations(relations);
		obj._loaded.relations = true;
		obj._clearChanged('relations');
	}.bind(this);
	
	yield Zotero.DB.queryAsync(
		sql,
		params,
		{
			noCache: ids.length != 1,
			onRow: function (row) {
				let id = row.getResultByIndex(0);
				
				if (lastID && id !== lastID) {
					setRows(lastID, rows);
					rows = [];
				}
				
				lastID = id;
				let predicate = row.getResultByIndex(1);
				// No relations
				if (predicate === null) {
					return;
				}
				rows.push({
					predicate,
					object: row.getResultByIndex(2)
				});
			}.bind(this)
		}
	);
	
	if (lastID) {
		setRows(lastID, rows);
	}
});


/**
 * Flatten API JSON relations object into an array of unique predicate-object pairs
 *
 * @param {Object} relations - Relations object in API JSON format, with predicates as keys
 *                             and arrays of URIs as objects
 * @return {Array[]} - Predicate-object pairs
 */
Zotero.DataObjects.prototype.flattenRelations = function (relations) {
	var relationsFlat = [];
	for (let predicate in relations) {
		let object = relations[predicate];
		if (Array.isArray(object)) {
			object = Zotero.Utilities.arrayUnique(object);
			for (let i = 0; i < object.length; i++) {
				relationsFlat.push([predicate, object[i]]);
			}
		}
		else if (typeof object == 'string') {
			relationsFlat.push([predicate, object]);
		}
		else {
			Zotero.debug(object, 1);
			throw new Error("Invalid relation value");
		}
	}
	return relationsFlat;
}


/**
 * Reload loaded data of loaded objects
 *
 * @param {Array|Number} ids - An id or array of ids
 * @param {Array} [dataTypes] - Data types to reload (e.g., 'primaryData'), or all loaded
 *                              types if not provided
   * @param {Boolean} [reloadUnchanged=false] - Reload even data that hasn't changed internally.
   *                                            This should be set to true for data that was
   *                                            changed externally (e.g., globally renamed tags).
   */
Zotero.DataObjects.prototype.reload = Zotero.Promise.coroutine(function* (ids, dataTypes, reloadUnchanged) {
	ids = Zotero.flattenArguments(ids);
	
	Zotero.debug('Reloading ' + (dataTypes ? '[' + dataTypes.join(', ') + '] for ' : '')
		+ this._ZDO_objects + ' ' + ids);
	
	// If data types not specified, reload loaded data for each object individually.
	// TODO: optimize
	if (!dataTypes) {
		for (let i=0; i<ids.length; i++) {
			if (this._objectCache[ids[i]]) {
				yield this._objectCache[ids[i]].reload(dataTypes, reloadUnchanged);
			}
		}
		return;
	}
	
	for (let dataType of dataTypes) {
		let typeIDsByLibrary = {};
		for (let id of ids) {
			let obj = this._objectCache[id];
			if (!obj || !obj._loaded[dataType] || obj._skipDataTypeLoad[dataType]
					|| (!reloadUnchanged && !obj._changed[dataType])) {
				continue;
			}
			if (!typeIDsByLibrary[obj.libraryID]) {
				typeIDsByLibrary[obj.libraryID] = [];
			}
			typeIDsByLibrary[obj.libraryID].push(id);
		}
		for (let libraryID in typeIDsByLibrary) {
			yield this._loadDataTypeInLibrary(dataType, parseInt(libraryID), typeIDsByLibrary[libraryID]);
		}
	}
	
	return true;
});


Zotero.DataObjects.prototype.reloadAll = function (libraryID) {
	Zotero.debug("Reloading all " + this._ZDO_objects);
	
	// Remove objects not stored in database
	var sql = "SELECT ROWID FROM " + this._ZDO_table;
	var params = [];
	if (libraryID !== undefined) {
		sql += ' WHERE libraryID=?';
		params.push(libraryID);
	}
	return Zotero.DB.columnQueryAsync(sql, params)
	.then(function (ids) {
		for (var id in this._objectCache) {
			if (!ids || ids.indexOf(parseInt(id)) == -1) {
				delete this._objectCache[id];
			}
		}
		
		// Reload data
		this._loadedLibraries[libraryID] = false;
		return this._load(libraryID);
	});
}


Zotero.DataObjects.prototype.registerObject = function (obj) {
	var id = obj.id;
	var libraryID = obj.libraryID;
	var key = obj.key;
	
	//Zotero.debug("Registering " + this._ZDO_object + " " + id + " as " + libraryID + "/" + key);
	if (!this._objectIDs[libraryID]) {
		this._objectIDs[libraryID] = {};
	}
	this._objectIDs[libraryID][key] = id;
	this._objectKeys[id] = [libraryID, key];
	this._objectCache[id] = obj;
	obj._inCache = true;
}

Zotero.DataObjects.prototype.dropDeadObjectsFromCache = function() {
	let ids = [];
	for (let libraryID in this._objectIDs) {
		if (Zotero.Libraries.exists(libraryID)) continue;
		for (let key in this._objectIDs[libraryID]) {
			ids.push(this._objectIDs[libraryID][key]);
		}
	}
	
	this.unload(ids);
}

/**
 * Clear object from internal array
 *
 * @param	int[]	ids		objectIDs
 */
Zotero.DataObjects.prototype.unload = function () {
	var ids = Zotero.flattenArguments(arguments);
	for (var i=0; i<ids.length; i++) {
		let id = ids[i];
		let {libraryID, key} = this.getLibraryAndKeyFromID(id);
		if (key) {
			delete this._objectIDs[libraryID][key];
			delete this._objectKeys[id];
		}
		delete this._objectCache[id];
	}
}


/**
 * Set the version of objects, efficiently
 *
 * @param {Integer[]} ids - Ids of objects to update
 * @param {Boolean} version
 */
Zotero.DataObjects.prototype.updateVersion = Zotero.Promise.method(function (ids, version) {
	if (version != parseInt(version)) {
		throw new Error("'version' must be an integer ('" + version + "' given)");
	}
	version = parseInt(version);
	
	let sql = "UPDATE " + this.table + " SET version=" + version + " "
		+ "WHERE " + this.idColumn + " IN (";
	return Zotero.Utilities.Internal.forEachChunkAsync(
		ids,
		Zotero.DB.MAX_BOUND_PARAMETERS,
		Zotero.Promise.coroutine(function* (chunk) {
			yield Zotero.DB.queryAsync(sql + chunk.map(() => '?').join(', ') + ')', chunk);
			// Update the internal 'version' property of any loaded objects
			for (let i = 0; i < chunk.length; i++) {
				let id = chunk[i];
				let obj = this._objectCache[id];
				if (obj) {
					obj.updateVersion(version, true);
				}
			}
		}.bind(this))
	);
});


/**
 * Set the sync state of objects, efficiently
 *
 * @param {Integer[]} ids - Ids of objects to update
 * @param {Boolean} synced
 */
Zotero.DataObjects.prototype.updateSynced = Zotero.Promise.method(function (ids, synced) {
	let sql = "UPDATE " + this.table + " SET synced=" + (synced ? 1 : 0) + " "
		+ "WHERE " + this.idColumn + " IN (";
	return Zotero.Utilities.Internal.forEachChunkAsync(
		ids,
		Zotero.DB.MAX_BOUND_PARAMETERS,
		Zotero.Promise.coroutine(function* (chunk) {
			yield Zotero.DB.queryAsync(sql + chunk.map(() => '?').join(', ') + ')', chunk);
			// Update the internal 'synced' property of any loaded objects
			for (let i = 0; i < chunk.length; i++) {
				let id = chunk[i];
				let obj = this._objectCache[id];
				if (obj) {
					obj.updateSynced(!!synced, true);
				}
			}
		}.bind(this))
	);
});


Zotero.DataObjects.prototype.isEditable = function (obj) {
	var libraryID = obj.libraryID;
	if (!libraryID) {
		return true;
	}
	
	if (!Zotero.Libraries.get(libraryID).editable) return false;
	
	if (obj.objectType == 'item' && obj.isAttachment()
		&& (obj.attachmentLinkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL ||
			obj.attachmentLinkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE)
		&& !Zotero.Libraries.get(libraryID).filesEditable
	) {
		return false;
	}
	
	return true;
}

Zotero.defineProperty(Zotero.DataObjects.prototype, "primaryDataSQL", {
	get: function () {
		return "SELECT "
		+ Object.keys(this._primaryDataSQLParts).map((val) => this._primaryDataSQLParts[val]).join(', ')
		+ this.primaryDataSQLFrom;
	}
}, {lazy: true});

Zotero.DataObjects.prototype.getPrimaryDataSQLPart = function (part) {
	var sql = this._primaryDataSQLParts[part];
	if (!sql) {
		throw new Error("Invalid primary data SQL part '" + part + "'");
	}
	return sql;
}


/**
 * Delete one or more objects from the database and caches
 *
 * @param {Integer|Integer[]} ids - Object ids
 * @param {Object} [options] - See Zotero.DataObject.prototype.erase
 * @return {Promise}
 */
Zotero.DataObjects.prototype.erase = Zotero.Promise.coroutine(function* (ids, options = {}) {
	ids = Zotero.flattenArguments(ids);
	yield Zotero.DB.executeTransaction(function* () {
		for (let i = 0; i < ids.length; i++) {
			let obj = yield this.getAsync(ids[i]);
			if (!obj) {
				continue;
			}
			yield obj.erase(options);
		}
		this.unload(ids);
	}.bind(this));
});


// TEMP: remove
Zotero.DataObjects.prototype._load = Zotero.Promise.coroutine(function* (libraryID, ids, options) {
	var loaded = {};

	// If library isn't an integer (presumably false or null), skip it
	if (parseInt(libraryID) != libraryID) {
		libraryID = false;
	}

	if (libraryID === false && !ids) {
		throw new Error("Either libraryID or ids must be provided");
	}

	if (libraryID !== false && this._loadedLibraries[libraryID]) {
		return loaded;
	}

	var sql = this.primaryDataSQL;
	var params = [];
	if (libraryID !== false) {
		sql += ' AND O.libraryID=?';
		params.push(libraryID);
	}
	if (ids) {
		sql += ' AND O.' + this._ZDO_id + ' IN (' + ids.join(',') + ')';
	}

	var t = new Date();
	yield Zotero.DB.queryAsync(
		sql,
		params,
		{
			onRow: function (row) {
				var id = row.getResultByName(this._ZDO_id);
				var columns = Object.keys(this._primaryDataSQLParts);
				var rowObj = {};
				for (let i=0; i<columns.length; i++) {
					rowObj[columns[i]] = row.getResultByIndex(i);
				}
				var obj;

				// Existing object -- reload in place
				if (this._objectCache[id]) {
					this._objectCache[id].loadFromRow(rowObj, true);
					obj = this._objectCache[id];
				}
				// Object doesn't exist -- create new object and stuff in cache
				else {
					obj = this._getObjectForRow(rowObj);
					obj.loadFromRow(rowObj, true);
					if (!options || !options.noCache) {
						this.registerObject(obj);
					}
				}
				loaded[id] = obj;
			}.bind(this)
		}
	);
	Zotero.debug("Loaded " + this._ZDO_objects + " in " + ((new Date) - t) + "ms");

	if (!ids) {
		this._loadedLibraries[libraryID] = true;

		// If loading all objects, remove cached objects that no longer exist
		for (let i in this._objectCache) {
			let obj = this._objectCache[i];
			if (libraryID !== false && obj.libraryID !== libraryID) {
				continue;
			}
			if (!loaded[obj.id]) {
				this.unload(obj.id);
			}
		}

		if (this._postLoad) {
			this._postLoad(libraryID, ids);
		}
	}

	return loaded;
});



Zotero.DataObjects.prototype._getObjectForRow = function(row) {
	return new Zotero[this._ZDO_Object];
};

Zotero.DataObjects.prototype._loadIDsAndKeys = Zotero.Promise.coroutine(function* () {
	var sql = "SELECT ROWID AS id, libraryID, key FROM " + this._ZDO_table;
	var rows = yield Zotero.DB.queryAsync(sql);
	for (let i=0; i<rows.length; i++) {
		let row = rows[i];
		this._objectKeys[row.id] = [row.libraryID, row.key];
		if (!this._objectIDs[row.libraryID]) {
			this._objectIDs[row.libraryID] = {};
		}
		this._objectIDs[row.libraryID][row.key] = row.id;
	}
});

{'fraction_non_alphanumeric': 0.1474905511118924, 'fraction_numerical': 0.0017579326711786938, 'mean_word_length': 3.5183478951548848, 'pattern_counts': {'":': 0, '<': 53, '<?xml version=': 0, '>': 30, 'https://': 0, 'lorem ipsum': 0, 'www.': 2, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 14, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '5518598', 'n_tokens_mistral': 21238, 'n_tokens_neox': 19309, 'n_words': 4644}