www

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

items.js (44951B)


      1 /*
      2     ***** BEGIN LICENSE BLOCK *****
      3     
      4     Copyright © 2009 Center for History and New Media
      5                      George Mason University, Fairfax, Virginia, USA
      6                      http://zotero.org
      7     
      8     This file is part of Zotero.
      9     
     10     Zotero is free software: you can redistribute it and/or modify
     11     it under the terms of the GNU Affero General Public License as published by
     12     the Free Software Foundation, either version 3 of the License, or
     13     (at your option) any later version.
     14     
     15     Zotero is distributed in the hope that it will be useful,
     16     but WITHOUT ANY WARRANTY; without even the implied warranty of
     17     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     18     GNU Affero General Public License for more details.
     19     
     20     You should have received a copy of the GNU Affero General Public License
     21     along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
     22     
     23     ***** END LICENSE BLOCK *****
     24 */
     25 
     26 
     27 /*
     28  * Primary interface for accessing Zotero items
     29  */
     30 Zotero.Items = function() {
     31 	this.constructor = null;
     32 	
     33 	this._ZDO_object = 'item';
     34 	
     35 	// This needs to wait until all Zotero components are loaded to initialize,
     36 	// but otherwise it can be just a simple property
     37 	Zotero.defineProperty(this, "_primaryDataSQLParts", {
     38 		get: function () {
     39 			return {
     40 				itemID: "O.itemID",
     41 				itemTypeID: "O.itemTypeID",
     42 				dateAdded: "O.dateAdded",
     43 				dateModified: "O.dateModified",
     44 				libraryID: "O.libraryID",
     45 				key: "O.key",
     46 				version: "O.version",
     47 				synced: "O.synced",
     48 				
     49 				firstCreator: _getFirstCreatorSQL(),
     50 				sortCreator: _getSortCreatorSQL(),
     51 				
     52 				deleted: "DI.itemID IS NOT NULL AS deleted",
     53 				inPublications: "PI.itemID IS NOT NULL AS inPublications",
     54 				
     55 				parentID: "(CASE O.itemTypeID WHEN 14 THEN IAP.itemID WHEN 1 THEN INoP.itemID END) AS parentID",
     56 				parentKey: "(CASE O.itemTypeID WHEN 14 THEN IAP.key WHEN 1 THEN INoP.key END) AS parentKey",
     57 				
     58 				attachmentCharset: "CS.charset AS attachmentCharset",
     59 				attachmentLinkMode: "IA.linkMode AS attachmentLinkMode",
     60 				attachmentContentType: "IA.contentType AS attachmentContentType",
     61 				attachmentPath: "IA.path AS attachmentPath",
     62 				attachmentSyncState: "IA.syncState AS attachmentSyncState",
     63 				attachmentSyncedModificationTime: "IA.storageModTime AS attachmentSyncedModificationTime",
     64 				attachmentSyncedHash: "IA.storageHash AS attachmentSyncedHash"
     65 			};
     66 		}
     67 	}, {lazy: true});
     68 	
     69 	
     70 	this._primaryDataSQLFrom = "FROM items O "
     71 		+ "LEFT JOIN itemAttachments IA USING (itemID) "
     72 		+ "LEFT JOIN items IAP ON (IA.parentItemID=IAP.itemID) "
     73 		+ "LEFT JOIN itemNotes INo ON (O.itemID=INo.itemID) "
     74 		+ "LEFT JOIN items INoP ON (INo.parentItemID=INoP.itemID) "
     75 		+ "LEFT JOIN deletedItems DI ON (O.itemID=DI.itemID) "
     76 		+ "LEFT JOIN publicationsItems PI ON (O.itemID=PI.itemID) "
     77 		+ "LEFT JOIN charsets CS ON (IA.charsetID=CS.charsetID)";
     78 	
     79 	this._relationsTable = "itemRelations";
     80 	
     81 	
     82 	/**
     83 	 * @param {Integer} libraryID
     84 	 * @return {Promise<Boolean>} - True if library has items in trash, false otherwise
     85 	 */
     86 	this.hasDeleted = Zotero.Promise.coroutine(function* (libraryID) {
     87 		var sql = "SELECT COUNT(*) > 0 FROM items JOIN deletedItems USING (itemID) WHERE libraryID=?";
     88 		return !!(yield Zotero.DB.valueQueryAsync(sql, [libraryID]));
     89 	});
     90 	
     91 	
     92 	/**
     93 	 * Return items marked as deleted
     94 	 *
     95 	 * @param {Integer} libraryID - Library to search
     96 	 * @param {Boolean} [asIDs] - Return itemIDs instead of Zotero.Item objects
     97 	 * @return {Promise<Zotero.Item[]|Integer[]>}
     98 	 */
     99 	this.getDeleted = Zotero.Promise.coroutine(function* (libraryID, asIDs, days) {
    100 		var sql = "SELECT itemID FROM items JOIN deletedItems USING (itemID) "
    101 				+ "WHERE libraryID=?";
    102 		if (days) {
    103 			sql += " AND dateDeleted<=DATE('NOW', '-" + parseInt(days) + " DAYS')";
    104 		}
    105 		var ids = yield Zotero.DB.columnQueryAsync(sql, [libraryID]);
    106 		if (!ids.length) {
    107 			return [];
    108 		}
    109 		if (asIDs) {
    110 			return ids;
    111 		}
    112 		return this.getAsync(ids);
    113 	});
    114 	
    115 	
    116 	/**
    117 	 * Returns all items in a given library
    118 	 *
    119 	 * @param  {Integer}  libraryID
    120 	 * @param  {Boolean}  [onlyTopLevel=false]   If true, don't include child items
    121 	 * @param  {Boolean}  [includeDeleted=false] If true, include deleted items
    122 	 * @param  {Boolean}  [asIDs=false] 		 If true, resolves only with IDs
    123 	 * @return {Promise<Array<Zotero.Item|Integer>>}
    124 	 */
    125 	this.getAll = Zotero.Promise.coroutine(function* (libraryID, onlyTopLevel, includeDeleted, asIDs=false) {
    126 		var sql = 'SELECT A.itemID FROM items A';
    127 		if (onlyTopLevel) {
    128 			sql += ' LEFT JOIN itemNotes B USING (itemID) '
    129 			+ 'LEFT JOIN itemAttachments C ON (C.itemID=A.itemID) '
    130 			+ 'WHERE B.parentItemID IS NULL AND C.parentItemID IS NULL';
    131 		}
    132 		else {
    133 			sql += " WHERE 1";
    134 		}
    135 		if (!includeDeleted) {
    136 			sql += " AND A.itemID NOT IN (SELECT itemID FROM deletedItems)";
    137 		}
    138 		sql += " AND libraryID=?";
    139 		var ids = yield Zotero.DB.columnQueryAsync(sql, libraryID);
    140 		if (asIDs) {
    141 			return ids;
    142 		}
    143 		return this.getAsync(ids);
    144 	});
    145 	
    146 	
    147 	/**
    148 	 * Return item data in web API format
    149 	 *
    150 	 * var data = Zotero.Items.getAPIData(0, 'collections/NF3GJ38A/items');
    151 	 *
    152 	 * @param {Number} libraryID
    153 	 * @param {String} [apiPath='items'] - Web API style
    154 	 * @return {Promise<String>}.
    155 	 */
    156 	this.getAPIData = Zotero.Promise.coroutine(function* (libraryID, apiPath) {
    157 		var gen = this.getAPIDataGenerator(...arguments);
    158 		var data = "";
    159 		while (true) {
    160 			var result = gen.next();
    161 			if (result.done) {
    162 				break;
    163 			}
    164 			var val = yield result.value;
    165 			if (typeof val == 'string') {
    166 				data += val;
    167 			}
    168 			else if (val === undefined) {
    169 				continue;
    170 			}
    171 			else {
    172 				throw new Error("Invalid return value from generator");
    173 			}
    174 		}
    175 		return data;
    176 	});
    177 	
    178 	
    179 	/**
    180 	 * Zotero.Utilities.Internal.getAsyncInputStream-compatible generator that yields item data
    181 	 * in web API format as strings
    182 	 *
    183 	 * @param {Object} params - Request parameters from Zotero.API.parsePath()
    184 	 */
    185 	this.apiDataGenerator = function* (params) {
    186 		Zotero.debug(params);
    187 		var s = new Zotero.Search;
    188 		s.addCondition('libraryID', 'is', params.libraryID);
    189 		if (params.scopeObject == 'collections') {
    190 			s.addCondition('collection', 'is', params.scopeObjectKey);
    191 		}
    192 		s.addCondition('title', 'contains', 'test');
    193 		var ids = yield s.search();
    194 		
    195 		yield '[\n';
    196 		
    197 		for (let i=0; i<ids.length; i++) {
    198 			let prefix = i > 0 ? ',\n' : '';
    199 			let item = yield this.getAsync(ids[i], { noCache: true });
    200 			var json = item.toResponseJSON();
    201 			yield prefix + JSON.stringify(json, null, 4);
    202 		}
    203 		
    204 		yield '\n]';
    205 	};
    206 	
    207 	
    208 	//
    209 	// Bulk data loading functions
    210 	//
    211 	// These are called by Zotero.DataObjects.prototype._loadDataType().
    212 	//
    213 	this._loadItemData = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL) {
    214 		var missingItems = {};
    215 		var itemFieldsCached = {};
    216 		
    217 		var sql = "SELECT itemID, fieldID, value FROM items "
    218 			+ "JOIN itemData USING (itemID) "
    219 			+ "JOIN itemDataValues USING (valueID) WHERE libraryID=? AND itemTypeID!=?" + idSQL;
    220 		var params = [libraryID, Zotero.ItemTypes.getID('note')];
    221 		yield Zotero.DB.queryAsync(
    222 			sql,
    223 			params,
    224 			{
    225 				noCache: ids.length != 1,
    226 				onRow: function (row) {
    227 					let itemID = row.getResultByIndex(0);
    228 					let fieldID = row.getResultByIndex(1);
    229 					let value = row.getResultByIndex(2);
    230 					
    231 					//Zotero.debug('Setting field ' + fieldID + ' for item ' + itemID);
    232 					if (this._objectCache[itemID]) {
    233 						if (value === null) {
    234 							value = false;
    235 						}
    236 						this._objectCache[itemID].setField(fieldID, value, true);
    237 					}
    238 					else {
    239 						if (!missingItems[itemID]) {
    240 							missingItems[itemID] = true;
    241 							Zotero.logError("itemData row references nonexistent item " + itemID);
    242 						}
    243 					}
    244 					if (!itemFieldsCached[itemID]) {
    245 						itemFieldsCached[itemID] = {};
    246 					}
    247 					itemFieldsCached[itemID][fieldID] = true;
    248 				}.bind(this)
    249 			}
    250 		);
    251 		
    252 		var sql = "SELECT itemID FROM items WHERE libraryID=?" + idSQL;
    253 		var params = [libraryID];
    254 		var allItemIDs = [];
    255 		yield Zotero.DB.queryAsync(
    256 			sql,
    257 			params,
    258 			{
    259 				noCache: ids.length != 1,
    260 				onRow: function (row) {
    261 					let itemID = row.getResultByIndex(0);
    262 					let item = this._objectCache[itemID];
    263 					
    264 					// Set nonexistent fields in the cache list to false (instead of null)
    265 					let fieldIDs = Zotero.ItemFields.getItemTypeFields(item.itemTypeID);
    266 					for (let j=0; j<fieldIDs.length; j++) {
    267 						let fieldID = fieldIDs[j];
    268 						if (!itemFieldsCached[itemID] || !itemFieldsCached[itemID][fieldID]) {
    269 							//Zotero.debug('Setting field ' + fieldID + ' to false for item ' + itemID);
    270 							item.setField(fieldID, false, true);
    271 						}
    272 					}
    273 					
    274 					allItemIDs.push(itemID);
    275 				}.bind(this)
    276 			}
    277 		);
    278 		
    279 		
    280 		var titleFieldID = Zotero.ItemFields.getID('title');
    281 		
    282 		// Note titles
    283 		var sql = "SELECT itemID, title FROM items JOIN itemNotes USING (itemID) "
    284 			+ "WHERE libraryID=? AND itemID NOT IN (SELECT itemID FROM itemAttachments)" + idSQL;
    285 		var params = [libraryID];
    286 		
    287 		yield Zotero.DB.queryAsync(
    288 			sql,
    289 			params,
    290 			{
    291 				onRow: function (row) {
    292 					let itemID = row.getResultByIndex(0);
    293 					let title = row.getResultByIndex(1);
    294 					
    295 					//Zotero.debug('Setting title for note ' + row.itemID);
    296 					if (this._objectCache[itemID]) {
    297 						this._objectCache[itemID].setField(titleFieldID, title, true);
    298 					}
    299 					else {
    300 						if (!missingItems[itemID]) {
    301 							missingItems[itemID] = true;
    302 							Zotero.logError("itemData row references nonexistent item " + itemID);
    303 						}
    304 					}
    305 				}.bind(this)
    306 			}
    307 		);
    308 		
    309 		for (let i=0; i<allItemIDs.length; i++) {
    310 			let itemID = allItemIDs[i];
    311 			let item = this._objectCache[itemID];
    312 			
    313 			// Mark as loaded
    314 			item._loaded.itemData = true;
    315 			item._clearChanged('itemData');
    316 			
    317 			// Display titles
    318 			try {
    319 				item.updateDisplayTitle()
    320 			}
    321 			catch (e) {
    322 				// A few item types need creators to be loaded. Instead of making
    323 				// updateDisplayTitle() async and loading conditionally, just catch the error
    324 				// and load on demand
    325 				if (e instanceof Zotero.Exception.UnloadedDataException) {
    326 					yield item.loadDataType('creators');
    327 					item.updateDisplayTitle()
    328 				}
    329 				else {
    330 					throw e;
    331 				}
    332 			}
    333 		}
    334 	});
    335 	
    336 	
    337 	this._loadCreators = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL) {
    338 		var sql = 'SELECT itemID, creatorID, creatorTypeID, orderIndex '
    339 			+ 'FROM items LEFT JOIN itemCreators USING (itemID) '
    340 			+ 'WHERE libraryID=?' + idSQL + " ORDER BY itemID, orderIndex";
    341 		var params = [libraryID];
    342 		var rows = yield Zotero.DB.queryAsync(sql, params);
    343 		
    344 		// Mark creator indexes above the number of creators as changed,
    345 		// so that they're cleared if the item is saved
    346 		var fixIncorrectIndexes = function (item, numCreators, maxOrderIndex) {
    347 			Zotero.debug("Fixing incorrect creator indexes for item " + item.libraryKey
    348 				+ " (" + numCreators + ", " + maxOrderIndex + ")", 2);
    349 			var i = numCreators;
    350 			if (!item._changed.creators) {
    351 				item._changed.creators = {};
    352 			}
    353 			while (i <= maxOrderIndex) {
    354 				item._changed.creators[i] = true;
    355 				i++;
    356 			}
    357 		};
    358 		
    359 		var lastItemID;
    360 		var item;
    361 		var index = 0;
    362 		var maxOrderIndex = -1;
    363 		for (let i = 0; i < rows.length; i++) {
    364 			let row = rows[i];
    365 			let itemID = row.itemID;
    366 			
    367 			if (itemID != lastItemID) {
    368 				if (!this._objectCache[itemID]) {
    369 					throw new Error("Item " + itemID + " not loaded");
    370 				}
    371 				item = this._objectCache[itemID];
    372 				
    373 				item._creators = [];
    374 				item._creatorIDs = [];
    375 				item._loaded.creators = true;
    376 				item._clearChanged('creators');
    377 				
    378 				if (!row.creatorID) {
    379 					lastItemID = row.itemID;
    380 					continue;
    381 				}
    382 				
    383 				if (index <= maxOrderIndex) {
    384 					fixIncorrectIndexes(item, index, maxOrderIndex);
    385 				}
    386 				
    387 				index = 0;
    388 				maxOrderIndex = -1;
    389 			}
    390 			
    391 			lastItemID = row.itemID;
    392 			
    393 			if (row.orderIndex > maxOrderIndex) {
    394 				maxOrderIndex = row.orderIndex;
    395 			}
    396 			
    397 			let creatorData = Zotero.Creators.get(row.creatorID);
    398 			creatorData.creatorTypeID = row.creatorTypeID;
    399 			item._creators[index] = creatorData;
    400 			item._creatorIDs[index] = row.creatorID;
    401 			index++;
    402 		}
    403 		
    404 		if (index <= maxOrderIndex) {
    405 			fixIncorrectIndexes(item, index, maxOrderIndex);
    406 		}
    407 	});
    408 	
    409 	
    410 	this._loadNotes = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL) {
    411 		var notesToUpdate = [];
    412 		
    413 		var sql = "SELECT itemID, note FROM items "
    414 			+ "JOIN itemNotes USING (itemID) "
    415 			+ "WHERE libraryID=?" + idSQL;
    416 		var params = [libraryID];
    417 		yield Zotero.DB.queryAsync(
    418 			sql,
    419 			params,
    420 			{
    421 				noCache: ids.length != 1,
    422 				onRow: function (row) {
    423 					let itemID = row.getResultByIndex(0);
    424 					let item = this._objectCache[itemID];
    425 					if (!item) {
    426 						throw new Error("Item " + itemID + " not found");
    427 					}
    428 					let note = row.getResultByIndex(1);
    429 					
    430 					// Convert non-HTML notes on-the-fly
    431 					if (note !== "") {
    432 						if (typeof note == 'number') {
    433 							note = '' + note;
    434 						}
    435 						if (typeof note == 'string') {
    436 							if (!note.substr(0, 36).match(/^<div class="zotero-note znv[0-9]+">/)) {
    437 								note = Zotero.Utilities.htmlSpecialChars(note);
    438 								note = Zotero.Notes.notePrefix + '<p>'
    439 									+ note.replace(/\n/g, '</p><p>')
    440 									.replace(/\t/g, '&nbsp;&nbsp;&nbsp;&nbsp;')
    441 									.replace(/  /g, '&nbsp;&nbsp;')
    442 									+ '</p>' + Zotero.Notes.noteSuffix;
    443 								note = note.replace(/<p>\s*<\/p>/g, '<p>&nbsp;</p>');
    444 								notesToUpdate.push([item.id, note]);
    445 							}
    446 							
    447 							// Don't include <div> wrapper when returning value
    448 							let startLen = note.substr(0, 36).match(/^<div class="zotero-note znv[0-9]+">/)[0].length;
    449 							let endLen = 6; // "</div>".length
    450 							note = note.substr(startLen, note.length - startLen - endLen);
    451 						}
    452 						// Clear null notes
    453 						else {
    454 							note = '';
    455 							notesToUpdate.push([item.id, '']);
    456 						}
    457 					}
    458 					
    459 					item._noteText = note ? note : '';
    460 					item._loaded.note = true;
    461 					item._clearChanged('note');
    462 				}.bind(this)
    463 			}
    464 		);
    465 		
    466 		if (notesToUpdate.length) {
    467 			yield Zotero.DB.executeTransaction(function* () {
    468 				for (let i = 0; i < notesToUpdate.length; i++) {
    469 					let row = notesToUpdate[i];
    470 					let sql = "UPDATE itemNotes SET note=? WHERE itemID=?";
    471 					yield Zotero.DB.queryAsync(sql, [row[1], row[0]]);
    472 				}
    473 			}.bind(this));
    474 		}
    475 		
    476 		// Mark notes and attachments without notes as loaded
    477 		sql = "SELECT itemID FROM items WHERE libraryID=?" + idSQL
    478 			+ " AND itemTypeID IN (?, ?) AND itemID NOT IN (SELECT itemID FROM itemNotes)";
    479 		params = [libraryID, Zotero.ItemTypes.getID('note'), Zotero.ItemTypes.getID('attachment')];
    480 		yield Zotero.DB.queryAsync(
    481 			sql,
    482 			params,
    483 			{
    484 				noCache: ids.length != 1,
    485 				onRow: function (row) {
    486 					let itemID = row.getResultByIndex(0);
    487 					let item = this._objectCache[itemID];
    488 					if (!item) {
    489 						throw new Error("Item " + itemID + " not loaded");
    490 					}
    491 					
    492 					item._noteText = '';
    493 					item._loaded.note = true;
    494 					item._clearChanged('note');
    495 				}.bind(this)
    496 			}
    497 		);
    498 	});
    499 	
    500 	
    501 	this._loadChildItems = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL) {
    502 		var params = [libraryID];
    503 		var rows = [];
    504 		var onRow = function (row, setFunc) {
    505 			var itemID = row.getResultByIndex(0);
    506 			
    507 			// If we've finished a set of rows for an item, process them
    508 			if (lastItemID && itemID !== lastItemID) {
    509 				setFunc(lastItemID, rows);
    510 				rows = [];
    511 			}
    512 			
    513 			lastItemID = itemID;
    514 			rows.push({
    515 				itemID: row.getResultByIndex(1),
    516 				title: row.getResultByIndex(2),
    517 				trashed: row.getResultByIndex(3)
    518 			});
    519 		};
    520 		
    521 		var sql = "SELECT parentItemID, A.itemID, value AS title, "
    522 			+ "CASE WHEN DI.itemID IS NULL THEN 0 ELSE 1 END AS trashed "
    523 			+ "FROM itemAttachments A "
    524 			+ "JOIN items I ON (A.parentItemID=I.itemID) "
    525 			+ "LEFT JOIN itemData ID ON (fieldID=110 AND A.itemID=ID.itemID) "
    526 			+ "LEFT JOIN itemDataValues IDV USING (valueID) "
    527 			+ "LEFT JOIN deletedItems DI USING (itemID) "
    528 			+ "WHERE libraryID=?"
    529 			+ (ids.length ? " AND parentItemID IN (" + ids.map(id => parseInt(id)).join(", ") + ")" : "")
    530 			+ " ORDER BY parentItemID";
    531 		// Since we do the sort here and cache these results, a restart will be required
    532 		// if this pref (off by default) is turned on, but that's OK
    533 		if (Zotero.Prefs.get('sortAttachmentsChronologically')) {
    534 			sql +=  ", dateAdded";
    535 		}
    536 		var setAttachmentItem = function (itemID, rows) {
    537 			var item = this._objectCache[itemID];
    538 			if (!item) {
    539 				throw new Error("Item " + itemID + " not loaded");
    540 			}
    541 			
    542 			item._attachments = {
    543 				rows,
    544 				chronologicalWithTrashed: null,
    545 				chronologicalWithoutTrashed: null,
    546 				alphabeticalWithTrashed: null,
    547 				alphabeticalWithoutTrashed: null
    548 			};
    549 		}.bind(this);
    550 		var lastItemID = null;
    551 		yield Zotero.DB.queryAsync(
    552 			sql,
    553 			params,
    554 			{
    555 				noCache: ids.length != 1,
    556 				onRow: function (row) {
    557 					onRow(row, setAttachmentItem);
    558 				}
    559 			}
    560 		);
    561 		// Process unprocessed rows
    562 		if (lastItemID) {
    563 			setAttachmentItem(lastItemID, rows);
    564 		}
    565 		// Otherwise clear existing entries for passed items
    566 		else if (ids.length) {
    567 			ids.forEach(id => setAttachmentItem(id, []));
    568 		}
    569 		
    570 		//
    571 		// Notes
    572 		//
    573 		sql = "SELECT parentItemID, N.itemID, title, "
    574 			+ "CASE WHEN DI.itemID IS NULL THEN 0 ELSE 1 END AS trashed "
    575 			+ "FROM itemNotes N "
    576 			+ "JOIN items I ON (N.parentItemID=I.itemID) "
    577 			+ "LEFT JOIN deletedItems DI USING (itemID) "
    578 			+ "WHERE libraryID=?"
    579 			+ (ids.length ? " AND parentItemID IN (" + ids.map(id => parseInt(id)).join(", ") + ")" : "")
    580 			+ " ORDER BY parentItemID";
    581 		if (Zotero.Prefs.get('sortNotesChronologically')) {
    582 			sql +=  ", dateAdded";
    583 		}
    584 		var setNoteItem = function (itemID, rows) {
    585 			var item = this._objectCache[itemID];
    586 			if (!item) {
    587 				throw new Error("Item " + itemID + " not loaded");
    588 			}
    589 			
    590 			item._notes = {
    591 				rows,
    592 				rowsEmbedded: null,
    593 				chronologicalWithTrashed: null,
    594 				chronologicalWithoutTrashed: null,
    595 				alphabeticalWithTrashed: null,
    596 				alphabeticalWithoutTrashed: null,
    597 				numWithTrashed: null,
    598 				numWithoutTrashed: null,
    599 				numWithTrashedWithEmbedded: null,
    600 				numWithoutTrashedWithoutEmbedded: null
    601 			};
    602 		}.bind(this);
    603 		lastItemID = null;
    604 		rows = [];
    605 		yield Zotero.DB.queryAsync(
    606 			sql,
    607 			params,
    608 			{
    609 				noCache: ids.length != 1,
    610 				onRow: function (row) {
    611 					onRow(row, setNoteItem);
    612 				}
    613 			}
    614 		);
    615 		// Process unprocessed rows
    616 		if (lastItemID) {
    617 			setNoteItem(lastItemID, rows);
    618 		}
    619 		// Otherwise clear existing entries for passed items
    620 		else if (ids.length) {
    621 			ids.forEach(id => setNoteItem(id, []));
    622 		}
    623 		
    624 		// Mark all top-level items as having child items loaded
    625 		sql = "SELECT itemID FROM items I WHERE libraryID=?" + idSQL + " AND itemID NOT IN "
    626 			+ "(SELECT itemID FROM itemAttachments UNION SELECT itemID FROM itemNotes)";
    627 		yield Zotero.DB.queryAsync(
    628 			sql,
    629 			params,
    630 			{
    631 				noCache: ids.length != 1,
    632 				onRow: function (row) {
    633 					var itemID = row.getResultByIndex(0);
    634 					var item = this._objectCache[itemID];
    635 					if (!item) {
    636 						throw new Error("Item " + itemID + " not loaded");
    637 					}
    638 					item._loaded.childItems = true;
    639 					item._clearChanged('childItems');
    640 				}.bind(this)
    641 			}
    642 		);
    643 	});
    644 	
    645 	
    646 	this._loadTags = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL) {
    647 		var sql = "SELECT itemID, name, type FROM items "
    648 			+ "LEFT JOIN itemTags USING (itemID) "
    649 			+ "LEFT JOIN tags USING (tagID) WHERE libraryID=?" + idSQL;
    650 		var params = [libraryID];
    651 		
    652 		var lastItemID;
    653 		var rows = [];
    654 		var setRows = function (itemID, rows) {
    655 			var item = this._objectCache[itemID];
    656 			if (!item) {
    657 				throw new Error("Item " + itemID + " not found");
    658 			}
    659 			
    660 			item._tags = [];
    661 			for (let i = 0; i < rows.length; i++) {
    662 				let row = rows[i];
    663 				item._tags.push(Zotero.Tags.cleanData(row));
    664 			}
    665 			
    666 			item._loaded.tags = true;
    667 		}.bind(this);
    668 		
    669 		yield Zotero.DB.queryAsync(
    670 			sql,
    671 			params,
    672 			{
    673 				noCache: ids.length != 1,
    674 				onRow: function (row) {
    675 					let itemID = row.getResultByIndex(0);
    676 					
    677 					if (lastItemID && itemID !== lastItemID) {
    678 						setRows(lastItemID, rows);
    679 						rows = [];
    680 					}
    681 					
    682 					lastItemID = itemID;
    683 					
    684 					// Item has no tags
    685 					let tag = row.getResultByIndex(1);
    686 					if (tag === null) {
    687 						return;
    688 					}
    689 					
    690 					rows.push({
    691 						tag: tag,
    692 						type: row.getResultByIndex(2)
    693 					});
    694 				}.bind(this)
    695 			}
    696 		);
    697 		if (lastItemID) {
    698 			setRows(lastItemID, rows);
    699 		}
    700 	});
    701 	
    702 	
    703 	this._loadCollections = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL) {
    704 		var sql = "SELECT itemID, collectionID FROM items "
    705 			+ "LEFT JOIN collectionItems USING (itemID) "
    706 			+ "WHERE libraryID=?" + idSQL;
    707 		var params = [libraryID];
    708 		
    709 		var lastItemID;
    710 		var rows = [];
    711 		var setRows = function (itemID, rows) {
    712 			var item = this._objectCache[itemID];
    713 			if (!item) {
    714 				throw new Error("Item " + itemID + " not found");
    715 			}
    716 			
    717 			item._collections = rows;
    718 			item._loaded.collections = true;
    719 			item._clearChanged('collections');
    720 		}.bind(this);
    721 		
    722 		yield Zotero.DB.queryAsync(
    723 			sql,
    724 			params,
    725 			{
    726 				noCache: ids.length != 1,
    727 				onRow: function (row) {
    728 					let itemID = row.getResultByIndex(0);
    729 					
    730 					if (lastItemID && itemID !== lastItemID) {
    731 						setRows(lastItemID, rows);
    732 						rows = [];
    733 					}
    734 					
    735 					lastItemID = itemID;
    736 					let collectionID = row.getResultByIndex(1);
    737 					// No collections
    738 					if (collectionID === null) {
    739 						return;
    740 					}
    741 					rows.push(collectionID);
    742 				}.bind(this)
    743 			}
    744 		);
    745 		if (lastItemID) {
    746 			setRows(lastItemID, rows);
    747 		}
    748 	});
    749 	
    750 	
    751 	this.merge = function (item, otherItems) {
    752 		Zotero.debug("Merging items");
    753 		
    754 		return Zotero.DB.executeTransaction(function* () {
    755 			var otherItemIDs = [];
    756 			var itemURI = Zotero.URI.getItemURI(item);
    757 			
    758 			var replPred = Zotero.Relations.replacedItemPredicate;
    759 			var toSave = {};
    760 			toSave[item.id] = item;
    761 			
    762 			for (let otherItem of otherItems) {
    763 				let otherItemURI = Zotero.URI.getItemURI(otherItem);
    764 				
    765 				// Move child items to master
    766 				var ids = otherItem.getAttachments(true).concat(otherItem.getNotes(true));
    767 				for (let id of ids) {
    768 					var attachment = yield this.getAsync(id);
    769 					
    770 					// TODO: Skip identical children?
    771 					
    772 					attachment.parentID = item.id;
    773 					yield attachment.save();
    774 				}
    775 				
    776 				// Add relations to master
    777 				let oldRelations = otherItem.getRelations();
    778 				for (let pred in oldRelations) {
    779 					oldRelations[pred].forEach(obj => item.addRelation(pred, obj));
    780 				}
    781 				
    782 				// Remove merge-tracking relations from other item, so that there aren't two
    783 				// subjects for a given deleted object
    784 				let replItems = otherItem.getRelationsByPredicate(replPred);
    785 				for (let replItem of replItems) {
    786 					otherItem.removeRelation(replPred, replItem);
    787 				}
    788 				
    789 				// Update relations on items in the library that point to the other item
    790 				// to point to the master instead
    791 				let rels = yield Zotero.Relations.getByObject('item', otherItemURI);
    792 				for (let rel of rels) {
    793 					// Skip merge-tracking relations, which are dealt with above
    794 					if (rel.predicate == replPred) continue;
    795 					// Skip items in other libraries. They might not be editable, and even
    796 					// if they are, merging items in one library shouldn't affect another library,
    797 					// so those will follow the merge-tracking relations and can optimize their
    798 					// path if they're resaved.
    799 					if (rel.subject.libraryID != item.libraryID) continue;
    800 					rel.subject.removeRelation(rel.predicate, otherItemURI);
    801 					rel.subject.addRelation(rel.predicate, itemURI);
    802 					if (!toSave[rel.subject.id]) {
    803 						toSave[rel.subject.id] = rel.subject;
    804 					}
    805 				}
    806 				
    807 				// All other operations are additive only and do not affect the,
    808 				// old item, which will be put in the trash
    809 				
    810 				// Add collections to master
    811 				otherItem.getCollections().forEach(id => item.addToCollection(id));
    812 				
    813 				// Add tags to master
    814 				var tags = otherItem.getTags();
    815 				for (let j = 0; j < tags.length; j++) {
    816 					item.addTag(tags[j].tag);
    817 				}
    818 				
    819 				// Add relation to track merge
    820 				item.addRelation(replPred, otherItemURI);
    821 				
    822 				// Trash other item
    823 				otherItem.deleted = true;
    824 				yield otherItem.save();
    825 			}
    826 			
    827 			for (let i in toSave) {
    828 				yield toSave[i].save();
    829 			}
    830 			
    831 			// Hack to remove master item from duplicates view without recalculating duplicates
    832 			Zotero.Notifier.trigger('removeDuplicatesMaster', 'item', item.id);
    833 		}.bind(this));
    834 	};
    835 	
    836 	
    837 	this.trash = Zotero.Promise.coroutine(function* (ids) {
    838 		Zotero.DB.requireTransaction();
    839 		
    840 		var libraryIDs = new Set();
    841 		ids = Zotero.flattenArguments(ids);
    842 		var items = [];
    843 		for (let id of ids) {
    844 			let item = this.get(id);
    845 			if (!item) {
    846 				Zotero.debug('Item ' + id + ' does not exist in Items.trash()!', 1);
    847 				Zotero.Notifier.queue('trash', 'item', id);
    848 				continue;
    849 			}
    850 			
    851 			if (!item.isEditable()) {
    852 				throw new Error(item._ObjectType + " " + item.libraryKey + " is not editable");
    853 			}
    854 			
    855 			if (!Zotero.Libraries.get(item.libraryID).hasTrash) {
    856 				throw new Error(Zotero.Libraries.getName(item.libraryID) + " does not have a trash");
    857 			}
    858 			
    859 			items.push(item);
    860 			libraryIDs.add(item.libraryID);
    861 		}
    862 		
    863 		var parentItemIDs = new Set();
    864 		items.forEach(item => {
    865 			item.setDeleted(true);
    866 			item.synced = false;
    867 			if (item.parentItemID) {
    868 				parentItemIDs.add(item.parentItemID);
    869 			}
    870 		});
    871 		yield Zotero.Utilities.Internal.forEachChunkAsync(ids, 250, Zotero.Promise.coroutine(function* (chunk) {
    872 			yield Zotero.DB.queryAsync(
    873 				"UPDATE items SET synced=0, clientDateModified=CURRENT_TIMESTAMP "
    874 					+ `WHERE itemID IN (${chunk.map(id => parseInt(id)).join(", ")})`
    875 			);
    876 			yield Zotero.DB.queryAsync(
    877 				"INSERT OR IGNORE INTO deletedItems (itemID) VALUES "
    878 					+ chunk.map(id => "(" + id + ")").join(", ")
    879 			);
    880 		}.bind(this)));
    881 		
    882 		// Keep in sync with Zotero.Item::saveData()
    883 		for (let parentItemID of parentItemIDs) {
    884 			let parentItem = yield Zotero.Items.getAsync(parentItemID);
    885 			yield parentItem.reload(['primaryData', 'childItems'], true);
    886 		}
    887 		Zotero.Notifier.queue('modify', 'item', ids);
    888 		Zotero.Notifier.queue('trash', 'item', ids);
    889 		Array.from(libraryIDs).forEach(libraryID => {
    890 			Zotero.Notifier.queue('refresh', 'trash', libraryID);
    891 		});
    892 	});
    893 	
    894 	
    895 	this.trashTx = function (ids) {
    896 		return Zotero.DB.executeTransaction(function* () {
    897 			return this.trash(ids);
    898 		}.bind(this));
    899 	}
    900 	
    901 	
    902 	/**
    903 	 * @param {Integer} libraryID - Library to delete from
    904 	 * @param {Object} [options]
    905 	 * @param {Function} [options.onProgress] - fn(progress, progressMax)
    906 	 * @param {Integer} [options.days] - Only delete items deleted more than this many days ago
    907 	 * @param {Integer} [options.limit] - Number of items to delete
    908 	 */
    909 	this.emptyTrash = async function (libraryID, options = {}) {
    910 		if (arguments.length > 2 || typeof arguments[1] == 'number') {
    911 			Zotero.warn("Zotero.Items.emptyTrash() has changed -- update your code");
    912 			options.days = arguments[1];
    913 			options.limit = arguments[2];
    914 		}
    915 		
    916 		if (!libraryID) {
    917 			throw new Error("Library ID not provided");
    918 		}
    919 		
    920 		var t = new Date();
    921 		
    922 		var deleted = await this.getDeleted(libraryID, false, options.days);
    923 		
    924 		if (options.limit) {
    925 			deleted = deleted.slice(0, options.limit);
    926 		}
    927 		
    928 		var processed = 0;
    929 		if (deleted.length) {
    930 			let toDelete = {
    931 				top: [],
    932 				child: []
    933 			};
    934 			deleted.forEach((item) => {
    935 				item.isTopLevelItem() ? toDelete.top.push(item.id) : toDelete.child.push(item.id)
    936 			});
    937 			
    938 			// Show progress meter during deletions
    939 			let eraseOptions = options.onProgress
    940 				? {
    941 					onProgress: function (progress, progressMax) {
    942 						options.onProgress(processed + progress, deleted.length);
    943 					}
    944 				}
    945 				: undefined;
    946 			for (let x of ['top', 'child']) {
    947 				await Zotero.Utilities.Internal.forEachChunkAsync(
    948 					toDelete[x],
    949 					1000,
    950 					async function (chunk) {
    951 						await this.erase(chunk, eraseOptions);
    952 						processed += chunk.length;
    953 					}.bind(this)
    954 				);
    955 			}
    956 			Zotero.debug("Emptied " + deleted.length + " item(s) from trash in " + (new Date() - t) + " ms");
    957 		}
    958 		
    959 		return deleted.length;
    960 	};
    961 	
    962 	
    963 	/**
    964 	 * Start idle observer to delete trashed items older than a certain number of days
    965 	 */
    966 	this._emptyTrashIdleObserver = null;
    967 	this._emptyTrashTimeoutID = null;
    968 	this.startEmptyTrashTimer = function () {
    969 		this._emptyTrashIdleObserver = {
    970 			observe: (subject, topic, data) => {
    971 				if (topic == 'idle' || topic == 'timer-callback') {
    972 					var days = Zotero.Prefs.get('trashAutoEmptyDays');
    973 					if (!days) {
    974 						return;
    975 					}
    976 					
    977 					// TODO: empty group trashes if permissions
    978 					
    979 					// Delete a few items a time
    980 					//
    981 					// TODO: increase number after dealing with slow
    982 					// tag.getLinkedItems() call during deletes
    983 					let num = 50;
    984 					this.emptyTrash(
    985 						Zotero.Libraries.userLibraryID,
    986 						{
    987 							days,
    988 							limit: num
    989 						}
    990 					)
    991 					.then((deleted) => {
    992 						if (!deleted) {
    993 							this._emptyTrashTimeoutID = null;
    994 							return;
    995 						}
    996 						
    997 						// Set a timer to do more every few seconds
    998 						this._emptyTrashTimeoutID = setTimeout(() => {
    999 							this._emptyTrashIdleObserver.observe(null, 'timer-callback', null);
   1000 						}, 2500);
   1001 					});
   1002 				}
   1003 				// When no longer idle, cancel timer
   1004 				else if (topic == 'back') {
   1005 					if (this._emptyTrashTimeoutID) {
   1006 						clearTimeout(this._emptyTrashTimeoutID);
   1007 						this._emptyTrashTimeoutID = null;
   1008 					}
   1009 				}
   1010 			}
   1011 		};
   1012 		
   1013 		var idleService = Components.classes["@mozilla.org/widget/idleservice;1"].
   1014 							getService(Components.interfaces.nsIIdleService);
   1015 		idleService.addIdleObserver(this._emptyTrashIdleObserver, 305);
   1016 	}
   1017 	
   1018 	
   1019 	this.addToPublications = function (items, options = {}) {
   1020 		if (!items.length) return;
   1021 		
   1022 		return Zotero.DB.executeTransaction(function* () {
   1023 			var timestamp = Zotero.DB.transactionTimestamp;
   1024 			
   1025 			var allItems = [...items];
   1026 			
   1027 			if (options.license) {
   1028 				for (let item of items) {
   1029 					if (!options.keepRights || !item.getField('rights')) {
   1030 						item.setField('rights', options.licenseName);
   1031 					}
   1032 				}
   1033 			}
   1034 			
   1035 			if (options.childNotes) {
   1036 				for (let item of items) {
   1037 					item.getNotes().forEach(id => allItems.push(Zotero.Items.get(id)));
   1038 				}
   1039 			}
   1040 			
   1041 			if (options.childFileAttachments || options.childLinks) {
   1042 				for (let item of items) {
   1043 					item.getAttachments().forEach(id => {
   1044 						var attachment = Zotero.Items.get(id);
   1045 						var linkMode = attachment.attachmentLinkMode;
   1046 						
   1047 						if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
   1048 							Zotero.debug("Skipping child linked file attachment on drag");
   1049 							return;
   1050 						}
   1051 						if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
   1052 							if (!options.childLinks) {
   1053 								Zotero.debug("Skipping child link attachment on drag");
   1054 								return;
   1055 							}
   1056 						}
   1057 						else if (!options.childFileAttachments) {
   1058 							Zotero.debug("Skipping child file attachment on drag");
   1059 							return;
   1060 						}
   1061 						allItems.push(attachment);
   1062 					});
   1063 				}
   1064 			}
   1065 			
   1066 			yield Zotero.Utilities.Internal.forEachChunkAsync(allItems, 250, Zotero.Promise.coroutine(function* (chunk) {
   1067 				for (let item of chunk) {
   1068 					item.setPublications(true);
   1069 					item.synced = false;
   1070 				}
   1071 				let ids = chunk.map(item => item.id);
   1072 				yield Zotero.DB.queryAsync(
   1073 					`UPDATE items SET synced=0, clientDateModified=? WHERE itemID IN (${ids.join(", ")})`,
   1074 					timestamp
   1075 				);
   1076 				yield Zotero.DB.queryAsync(
   1077 					`INSERT OR IGNORE INTO publicationsItems VALUES (${ids.join("), (")})`
   1078 				);
   1079 			}.bind(this)));
   1080 			Zotero.Notifier.queue('modify', 'item', allItems.map(item => item.id));
   1081 		}.bind(this));
   1082 	};
   1083 	
   1084 	
   1085 	this.removeFromPublications = function (items) {
   1086 		return Zotero.DB.executeTransaction(function* () {
   1087 			let allItems = [];
   1088 			for (let item of items) {
   1089 				if (!item.inPublications) {
   1090 					throw new Error(`Item ${item.libraryKey} is not in My Publications`);
   1091 				}
   1092 				
   1093 				// Remove all child items too
   1094 				if (item.isRegularItem()) {
   1095 					allItems.push(...this.get(item.getNotes(true).concat(item.getAttachments(true))));
   1096 				}
   1097 				
   1098 				allItems.push(item);
   1099 			}
   1100 			
   1101 			allItems.forEach(item => {
   1102 				item.setPublications(false);
   1103 				item.synced = false;
   1104 			});
   1105 			
   1106 			var timestamp = Zotero.DB.transactionTimestamp;
   1107 			yield Zotero.Utilities.Internal.forEachChunkAsync(allItems, 250, Zotero.Promise.coroutine(function* (chunk) {
   1108 				let idStr = chunk.map(item => item.id).join(", ");
   1109 				yield Zotero.DB.queryAsync(
   1110 					`UPDATE items SET synced=0, clientDateModified=? WHERE itemID IN (${idStr})`,
   1111 					timestamp
   1112 				);
   1113 				yield Zotero.DB.queryAsync(`DELETE FROM publicationsItems WHERE itemID IN (${idStr})`);
   1114 			}.bind(this)));
   1115 			Zotero.Notifier.queue('modify', 'item', items.map(item => item.id));
   1116 		}.bind(this));
   1117 	};
   1118 	
   1119 	
   1120 	/**
   1121 	 * Purge unused data values
   1122 	 */
   1123 	this.purge = Zotero.Promise.coroutine(function* () {
   1124 		Zotero.DB.requireTransaction();
   1125 		
   1126 		if (!Zotero.Prefs.get('purge.items')) {
   1127 			return;
   1128 		}
   1129 		
   1130 		var sql = "DELETE FROM itemDataValues WHERE valueID NOT IN "
   1131 					+ "(SELECT valueID FROM itemData)";
   1132 		yield Zotero.DB.queryAsync(sql);
   1133 		
   1134 		Zotero.Prefs.set('purge.items', false)
   1135 	});
   1136 	
   1137 	
   1138 	
   1139 	/**
   1140 	 * Given API JSON for an item, return the best single first creator, regardless of creator order
   1141 	 *
   1142 	 * Note that this is just a single creator, not the firstCreator field return from the
   1143 	 * Zotero.Item::firstCreator property or this.getFirstCreatorFromData()
   1144 	 *
   1145 	 * @return {Object|false} - Creator in API JSON format, or false
   1146 	 */
   1147 	this.getFirstCreatorFromJSON = function (json) {
   1148 		var primaryCreatorType = Zotero.CreatorTypes.getName(
   1149 			Zotero.CreatorTypes.getPrimaryIDForType(
   1150 				Zotero.ItemTypes.getID(json.itemType)
   1151 			)
   1152 		);
   1153 		let firstCreator = json.creators.find(creator => {
   1154 			return creator.creatorType == primaryCreatorType || creator.creatorType == 'author';
   1155 		});
   1156 		if (!firstCreator) {
   1157 			firstCreator = json.creators.find(creator => creator.creatorType == 'editor');
   1158 		}
   1159 		if (!firstCreator) {
   1160 			return false;
   1161 		}
   1162 		return firstCreator;
   1163 	};
   1164 	
   1165 	
   1166 	/**
   1167 	 * Return a firstCreator string from internal creators data (from Zotero.Item::getCreators()).
   1168 	 *
   1169 	 * Used in Zotero.Item::getField() for unsaved items
   1170 	 *
   1171 	 * @param {Integer} itemTypeID
   1172 	 * @param {Object} creatorData
   1173 	 * @return {String}
   1174 	 */
   1175 	this.getFirstCreatorFromData = function (itemTypeID, creatorsData) {
   1176 		if (creatorsData.length === 0) {
   1177 			return "";
   1178 		}
   1179 		
   1180 		var validCreatorTypes = [
   1181 			Zotero.CreatorTypes.getPrimaryIDForType(itemTypeID),
   1182 			Zotero.CreatorTypes.getID('editor'),
   1183 			Zotero.CreatorTypes.getID('contributor')
   1184 		];
   1185 	
   1186 		for (let creatorTypeID of validCreatorTypes) {
   1187 			let matches = creatorsData.filter(data => data.creatorTypeID == creatorTypeID)
   1188 			if (!matches.length) {
   1189 				continue;
   1190 			}
   1191 			if (matches.length === 1) {
   1192 				return matches[0].lastName;
   1193 			}
   1194 			if (matches.length === 2) {
   1195 				let a = matches[0];
   1196 				let b = matches[1];
   1197 				return a.lastName + " " + Zotero.getString('general.and') + " " + b.lastName;
   1198 			}
   1199 			if (matches.length >= 3) {
   1200 				return matches[0].lastName + " " + Zotero.getString('general.etAl');
   1201 			}
   1202 		}
   1203 		
   1204 		return "";
   1205 	};
   1206 	
   1207 	
   1208 	/**
   1209 	 * Returns an array of items with children of selected parents removed
   1210 	 *
   1211 	 * @return {Zotero.Item[]}
   1212 	 */
   1213 	this.keepParents = function (items) {
   1214 		var parentItems = new Set(
   1215 			items
   1216 				.filter(item => item.isTopLevelItem())
   1217 				.map(item => item.id)
   1218 		);
   1219 		return items.filter(item => {
   1220 			var parentItemID = item.parentItemID;
   1221 			// Not a child item or not a child of one of the passed items
   1222 			return !parentItemID || !parentItems.has(parentItemID);
   1223 		});
   1224 	}
   1225 	
   1226 	
   1227 	/*
   1228 	 * Generate SQL to retrieve firstCreator field
   1229 	 *
   1230 	 * Why do we do this entirely in SQL? Because we're crazy. Crazy like foxes.
   1231 	 */
   1232 	var _firstCreatorSQL = '';
   1233 	function _getFirstCreatorSQL() {
   1234 		if (_firstCreatorSQL) {
   1235 			return _firstCreatorSQL;
   1236 		}
   1237 		
   1238 		/* This whole block is to get the firstCreator */
   1239 		var localizedAnd = Zotero.getString('general.and');
   1240 		var localizedEtAl = Zotero.getString('general.etAl'); 
   1241 		var sql = "COALESCE(" +
   1242 			// First try for primary creator types
   1243 			"CASE (" +
   1244 				"SELECT COUNT(*) FROM itemCreators IC " +
   1245 				"LEFT JOIN itemTypeCreatorTypes ITCT " +
   1246 				"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
   1247 				"WHERE itemID=O.itemID AND primaryField=1" +
   1248 			") " +
   1249 			"WHEN 0 THEN NULL " +
   1250 			"WHEN 1 THEN (" +
   1251 				"SELECT lastName FROM itemCreators IC NATURAL JOIN creators " +
   1252 				"LEFT JOIN itemTypeCreatorTypes ITCT " +
   1253 				"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
   1254 				"WHERE itemID=O.itemID AND primaryField=1" +
   1255 			") " +
   1256 			"WHEN 2 THEN (" +
   1257 				"SELECT " +
   1258 				"(SELECT lastName FROM itemCreators IC NATURAL JOIN creators " +
   1259 				"LEFT JOIN itemTypeCreatorTypes ITCT " +
   1260 				"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
   1261 				"WHERE itemID=O.itemID AND primaryField=1 ORDER BY orderIndex LIMIT 1)" +
   1262 				" || ' " + localizedAnd + " ' || " +
   1263 				"(SELECT lastName FROM itemCreators IC NATURAL JOIN creators " +
   1264 				"LEFT JOIN itemTypeCreatorTypes ITCT " +
   1265 				"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
   1266 				"WHERE itemID=O.itemID AND primaryField=1 ORDER BY orderIndex LIMIT 1,1)" +
   1267 			") " +
   1268 			"ELSE (" +
   1269 				"SELECT " +
   1270 				"(SELECT lastName FROM itemCreators IC NATURAL JOIN creators " +
   1271 				"LEFT JOIN itemTypeCreatorTypes ITCT " +
   1272 				"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
   1273 				"WHERE itemID=O.itemID AND primaryField=1 ORDER BY orderIndex LIMIT 1)" +
   1274 				" || ' " + localizedEtAl + "' " + 
   1275 			") " +
   1276 			"END, " +
   1277 			
   1278 			// Then try editors
   1279 			"CASE (" +
   1280 				"SELECT COUNT(*) FROM itemCreators WHERE itemID=O.itemID AND creatorTypeID IN (3)" +
   1281 			") " +
   1282 			"WHEN 0 THEN NULL " +
   1283 			"WHEN 1 THEN (" +
   1284 				"SELECT lastName FROM itemCreators NATURAL JOIN creators " +
   1285 				"WHERE itemID=O.itemID AND creatorTypeID IN (3)" +
   1286 			") " +
   1287 			"WHEN 2 THEN (" +
   1288 				"SELECT " +
   1289 				"(SELECT lastName FROM itemCreators NATURAL JOIN creators " +
   1290 				"WHERE itemID=O.itemID AND creatorTypeID IN (3) ORDER BY orderIndex LIMIT 1)" +
   1291 				" || ' " + localizedAnd + " ' || " +
   1292 				"(SELECT lastName FROM itemCreators NATURAL JOIN creators " +
   1293 				"WHERE itemID=O.itemID AND creatorTypeID IN (3) ORDER BY orderIndex LIMIT 1,1) " +
   1294 			") " +
   1295 			"ELSE (" +
   1296 				"SELECT " +
   1297 				"(SELECT lastName FROM itemCreators NATURAL JOIN creators " +
   1298 				"WHERE itemID=O.itemID AND creatorTypeID IN (3) ORDER BY orderIndex LIMIT 1)" +
   1299 				" || ' " + localizedEtAl + "' " +
   1300 			") " +
   1301 			"END, " +
   1302 			
   1303 			// Then try contributors
   1304 			"CASE (" +
   1305 				"SELECT COUNT(*) FROM itemCreators WHERE itemID=O.itemID AND creatorTypeID IN (2)" +
   1306 			") " +
   1307 			"WHEN 0 THEN NULL " +
   1308 			"WHEN 1 THEN (" +
   1309 				"SELECT lastName FROM itemCreators NATURAL JOIN creators " +
   1310 				"WHERE itemID=O.itemID AND creatorTypeID IN (2)" +
   1311 			") " +
   1312 			"WHEN 2 THEN (" +
   1313 				"SELECT " +
   1314 				"(SELECT lastName FROM itemCreators NATURAL JOIN creators " +
   1315 				"WHERE itemID=O.itemID AND creatorTypeID IN (2) ORDER BY orderIndex LIMIT 1)" +
   1316 				" || ' " + localizedAnd + " ' || " +
   1317 				"(SELECT lastName FROM itemCreators NATURAL JOIN creators " +
   1318 				"WHERE itemID=O.itemID AND creatorTypeID IN (2) ORDER BY orderIndex LIMIT 1,1) " +
   1319 			") " +
   1320 			"ELSE (" +
   1321 				"SELECT " +
   1322 				"(SELECT lastName FROM itemCreators NATURAL JOIN creators " +
   1323 				"WHERE itemID=O.itemID AND creatorTypeID IN (2) ORDER BY orderIndex LIMIT 1)" +
   1324 				" || ' " + localizedEtAl + "' " + 
   1325 			") " +
   1326 			"END" +
   1327 		") AS firstCreator";
   1328 		
   1329 		_firstCreatorSQL = sql;
   1330 		return sql;
   1331 	}
   1332 	
   1333 	
   1334 	/*
   1335 	 * Generate SQL to retrieve sortCreator field
   1336 	 */
   1337 	var _sortCreatorSQL = '';
   1338 	function _getSortCreatorSQL() {
   1339 		if (_sortCreatorSQL) {
   1340 			return _sortCreatorSQL;
   1341 		}
   1342 		
   1343 		var nameSQL = "lastName || ' ' || firstName ";
   1344 		
   1345 		var sql = "COALESCE(" +
   1346 			// First try for primary creator types
   1347 			"CASE (" +
   1348 				"SELECT COUNT(*) FROM itemCreators IC " +
   1349 				"LEFT JOIN itemTypeCreatorTypes ITCT " +
   1350 				"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
   1351 				"WHERE itemID=O.itemID AND primaryField=1" +
   1352 			") " +
   1353 			"WHEN 0 THEN NULL " +
   1354 			"WHEN 1 THEN (" +
   1355 				"SELECT " + nameSQL + "FROM itemCreators IC NATURAL JOIN creators " +
   1356 				"LEFT JOIN itemTypeCreatorTypes ITCT " +
   1357 				"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
   1358 				"WHERE itemID=O.itemID AND primaryField=1" +
   1359 			") " +
   1360 			"WHEN 2 THEN (" +
   1361 				"SELECT " +
   1362 				"(SELECT " + nameSQL + " FROM itemCreators IC NATURAL JOIN creators " +
   1363 				"LEFT JOIN itemTypeCreatorTypes ITCT " +
   1364 				"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
   1365 				"WHERE itemID=O.itemID AND primaryField=1 ORDER BY orderIndex LIMIT 1)" +
   1366 				" || ' ' || " +
   1367 				"(SELECT " + nameSQL + " FROM itemCreators IC NATURAL JOIN creators " +
   1368 				"LEFT JOIN itemTypeCreatorTypes ITCT " +
   1369 				"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
   1370 				"WHERE itemID=O.itemID AND primaryField=1 ORDER BY orderIndex LIMIT 1,1)" +
   1371 			") " +
   1372 			"ELSE (" +
   1373 				"SELECT " +
   1374 				"(SELECT " + nameSQL + " FROM itemCreators IC NATURAL JOIN creators " +
   1375 				"LEFT JOIN itemTypeCreatorTypes ITCT " +
   1376 				"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
   1377 				"WHERE itemID=O.itemID AND primaryField=1 ORDER BY orderIndex LIMIT 1)" +
   1378 				" || ' ' || " +
   1379 				"(SELECT " + nameSQL + " FROM itemCreators IC NATURAL JOIN creators " +
   1380 				"LEFT JOIN itemTypeCreatorTypes ITCT " +
   1381 				"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
   1382 				"WHERE itemID=O.itemID AND primaryField=1 ORDER BY orderIndex LIMIT 1,1)" +
   1383 				" || ' ' || " +
   1384 				"(SELECT " + nameSQL + " FROM itemCreators IC NATURAL JOIN creators " +
   1385 				"LEFT JOIN itemTypeCreatorTypes ITCT " +
   1386 				"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
   1387 				"WHERE itemID=O.itemID AND primaryField=1 ORDER BY orderIndex LIMIT 2,1)" +
   1388 			") " +
   1389 			"END, " +
   1390 			
   1391 			// Then try editors
   1392 			"CASE (" +
   1393 				"SELECT COUNT(*) FROM itemCreators WHERE itemID=O.itemID AND creatorTypeID IN (3)" +
   1394 			") " +
   1395 			"WHEN 0 THEN NULL " +
   1396 			"WHEN 1 THEN (" +
   1397 				"SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators " +
   1398 				"WHERE itemID=O.itemID AND creatorTypeID IN (3)" +
   1399 			") " +
   1400 			"WHEN 2 THEN (" +
   1401 				"SELECT " +
   1402 				"(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators " +
   1403 				"WHERE itemID=O.itemID AND creatorTypeID IN (3) ORDER BY orderIndex LIMIT 1)" +
   1404 				" || ' ' || " +
   1405 				"(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators " +
   1406 				"WHERE itemID=O.itemID AND creatorTypeID IN (3) ORDER BY orderIndex LIMIT 1,1) " +
   1407 			") " +
   1408 			"ELSE (" +
   1409 				"SELECT " +
   1410 				"(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators " +
   1411 				"WHERE itemID=O.itemID AND creatorTypeID IN (3) ORDER BY orderIndex LIMIT 1)" +
   1412 				" || ' ' || " +
   1413 				"(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators " +
   1414 				"WHERE itemID=O.itemID AND creatorTypeID IN (3) ORDER BY orderIndex LIMIT 1,1)" +
   1415 				" || ' ' || " +
   1416 				"(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators " +
   1417 				"WHERE itemID=O.itemID AND creatorTypeID IN (3) ORDER BY orderIndex LIMIT 2,1)" +
   1418 			") " +
   1419 			"END, " +
   1420 			
   1421 			// Then try contributors
   1422 			"CASE (" +
   1423 				"SELECT COUNT(*) FROM itemCreators WHERE itemID=O.itemID AND creatorTypeID IN (2)" +
   1424 			") " +
   1425 			"WHEN 0 THEN NULL " +
   1426 			"WHEN 1 THEN (" +
   1427 				"SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators " +
   1428 				"WHERE itemID=O.itemID AND creatorTypeID IN (2)" +
   1429 			") " +
   1430 			"WHEN 2 THEN (" +
   1431 				"SELECT " +
   1432 				"(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators " +
   1433 				"WHERE itemID=O.itemID AND creatorTypeID IN (2) ORDER BY orderIndex LIMIT 1)" +
   1434 				" || ' ' || " +
   1435 				"(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators " +
   1436 				"WHERE itemID=O.itemID AND creatorTypeID IN (2) ORDER BY orderIndex LIMIT 1,1) " +
   1437 			") " +
   1438 			"ELSE (" +
   1439 				"SELECT " +
   1440 				"(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators " +
   1441 				"WHERE itemID=O.itemID AND creatorTypeID IN (2) ORDER BY orderIndex LIMIT 1)" +
   1442 				" || ' ' || " + 
   1443 				"(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators " +
   1444 				"WHERE itemID=O.itemID AND creatorTypeID IN (2) ORDER BY orderIndex LIMIT 1,1)" +
   1445 				" || ' ' || " + 
   1446 				"(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators " +
   1447 				"WHERE itemID=O.itemID AND creatorTypeID IN (2) ORDER BY orderIndex LIMIT 2,1)" +
   1448 			") " +
   1449 			"END" +
   1450 		") AS sortCreator";
   1451 		
   1452 		_sortCreatorSQL = sql;
   1453 		return sql;
   1454 	}
   1455 	
   1456 	
   1457 	this.getSortTitle = function(title) {
   1458 		if (title === false || title === undefined) {
   1459 			return '';
   1460 		}
   1461 		if (typeof title == 'number') {
   1462 			return title + '';
   1463 		}
   1464 		return title.replace(/^[\[\'\"](.*)[\'\"\]]?$/, '$1')
   1465 	}
   1466 	
   1467 	
   1468 	Zotero.DataObjects.call(this);
   1469 	
   1470 	return this;
   1471 }.bind(Object.create(Zotero.DataObjects.prototype))();