www

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

search.js (49134B)


      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 Zotero.Search = function(params = {}) {
     27 	Zotero.Search._super.apply(this);
     28 	
     29 	this._name = null;
     30 	
     31 	this._scope = null;
     32 	this._scopeIncludeChildren = null;
     33 	this._sql = null;
     34 	this._sqlParams = false;
     35 	this._maxSearchConditionID = -1;
     36 	this._conditions = {};
     37 	this._hasPrimaryConditions = false;
     38 	
     39 	Zotero.Utilities.assignProps(this, params, ['name', 'libraryID']);
     40 }
     41 
     42 Zotero.extendClass(Zotero.DataObject, Zotero.Search);
     43 
     44 Zotero.Search.prototype._objectType = 'search';
     45 Zotero.Search.prototype._dataTypes = Zotero.Search._super.prototype._dataTypes.concat([
     46 	'conditions'
     47 ]);
     48 
     49 Zotero.Search.prototype.getID = function(){
     50 	Zotero.debug('Zotero.Search.getName() is deprecated -- use Search.id');
     51 	return this._id;
     52 }
     53 
     54 Zotero.Search.prototype.getName = function() {
     55 	Zotero.debug('Zotero.Search.getName() is deprecated -- use Search.name');
     56 	return this.name;
     57 }
     58 
     59 Zotero.Search.prototype.setName = function(val) {
     60 	Zotero.debug('Zotero.Search.setName() is deprecated -- use Search.name');
     61 	this.name = val;
     62 }
     63 
     64 Zotero.defineProperty(Zotero.Search.prototype, 'id', {
     65 	get: function() { return this._get('id'); },
     66 	set: function(val) { return this._set('id', val); }
     67 });
     68 Zotero.defineProperty(Zotero.Search.prototype, 'libraryID', {
     69 	get: function() { return this._get('libraryID'); },
     70 	set: function(val) { return this._set('libraryID', val); }
     71 });
     72 Zotero.defineProperty(Zotero.Search.prototype, 'key', {
     73 	get: function() { return this._get('key'); },
     74 	set: function(val) { return this._set('key', val); }
     75 });
     76 Zotero.defineProperty(Zotero.Search.prototype, 'name', {
     77 	get: function() { return this._get('name'); },
     78 	set: function(val) { return this._set('name', val); }
     79 });
     80 Zotero.defineProperty(Zotero.Search.prototype, 'version', {
     81 	get: function() { return this._get('version'); },
     82 	set: function(val) { return this._set('version', val); }
     83 });
     84 Zotero.defineProperty(Zotero.Search.prototype, 'synced', {
     85 	get: function() { return this._get('synced'); },
     86 	set: function(val) { return this._set('synced', val); }
     87 });
     88 Zotero.defineProperty(Zotero.Search.prototype, 'conditions', {
     89 	get: function() { return this.getConditions(); }
     90 });
     91 Zotero.defineProperty(Zotero.Search.prototype, '_canHaveParent', {
     92 	value: false
     93 });
     94 
     95 Zotero.defineProperty(Zotero.Search.prototype, 'treeViewID', {
     96 	get: function () {
     97 		return "S" + this.id
     98 	}
     99 });
    100 
    101 Zotero.defineProperty(Zotero.Search.prototype, 'treeViewImage', {
    102 	get: function () {
    103 		if (Zotero.isMac) {
    104 			return `chrome://zotero-platform/content/treesource-search${Zotero.hiDPISuffix}.png`;
    105 		}
    106 		return "chrome://zotero/skin/treesource-search" + Zotero.hiDPISuffix + ".png";
    107 	}
    108 });
    109 
    110 Zotero.Search.prototype.loadFromRow = function (row) {
    111 	var primaryFields = this._ObjectsClass.primaryFields;
    112 	for (let i=0; i<primaryFields.length; i++) {
    113 		let col = primaryFields[i];
    114 		try {
    115 			var val = row[col];
    116 		}
    117 		catch (e) {
    118 			Zotero.debug('Skipping missing ' + this._objectType + ' field ' + col);
    119 			continue;
    120 		}
    121 		
    122 		switch (col) {
    123 		case this._ObjectsClass.idColumn:
    124 			col = 'id';
    125 			break;
    126 		
    127 		// Integer
    128 		case 'libraryID':
    129 			val = parseInt(val);
    130 			break;
    131 		
    132 		// Integer or 0
    133 		case 'version':
    134 			val = val ? parseInt(val) : 0;
    135 			break;
    136 		
    137 		// Boolean
    138 		case 'synced':
    139 			val = !!val;
    140 			break;
    141 		
    142 		default:
    143 			val = val || '';
    144 		}
    145 		
    146 		this['_' + col] = val;
    147 	}
    148 	
    149 	this._loaded.primaryData = true;
    150 	this._clearChanged('primaryData');
    151 	this._identified = true;
    152 }
    153 
    154 Zotero.Search.prototype._initSave = Zotero.Promise.coroutine(function* (env) {
    155 	if (!this.name) {
    156 		throw new Error('Name not provided for saved search');
    157 	}
    158 	return Zotero.Search._super.prototype._initSave.apply(this, arguments);
    159 });
    160 
    161 Zotero.Search.prototype._saveData = Zotero.Promise.coroutine(function* (env) {
    162 	var isNew = env.isNew;
    163 	var options = env.options;
    164 	
    165 	var searchID = this._id = this.id ? this.id : Zotero.ID.get('savedSearches');
    166 	
    167 	env.sqlColumns.push(
    168 		'savedSearchName'
    169 	);
    170 	env.sqlValues.push(
    171 		{ string: this.name }
    172 	);
    173 	
    174 	if (env.sqlColumns.length) {
    175 		if (isNew) {
    176 			env.sqlColumns.unshift('savedSearchID');
    177 			env.sqlValues.unshift(searchID ? { int: searchID } : null);
    178 			
    179 			let placeholders = env.sqlColumns.map(() => '?').join();
    180 			let sql = "INSERT INTO savedSearches (" + env.sqlColumns.join(', ') + ") "
    181 				+ "VALUES (" + placeholders + ")";
    182 			yield Zotero.DB.queryAsync(sql, env.sqlValues);
    183 		}
    184 		else {
    185 			let sql = 'UPDATE savedSearches SET '
    186 				+ env.sqlColumns.map(x => x + '=?').join(', ') + ' WHERE savedSearchID=?';
    187 			env.sqlValues.push(searchID ? { int: searchID } : null);
    188 			yield Zotero.DB.queryAsync(sql, env.sqlValues);
    189 		}
    190 	}
    191 	
    192 	if (this._changed.conditions) {
    193 		if (!isNew) {
    194 			var sql = "DELETE FROM savedSearchConditions WHERE savedSearchID=?";
    195 			yield Zotero.DB.queryAsync(sql, this.id);
    196 		}
    197 		
    198 		var i = 0;
    199 		var sql = "INSERT INTO savedSearchConditions "
    200 			+ "(savedSearchID, searchConditionID, condition, operator, value, required) "
    201 			+ "VALUES (?,?,?,?,?,?)";
    202 		for (let id in this._conditions) {
    203 			let condition = this._conditions[id];
    204 			
    205 			// Convert condition and mode to "condition[/mode]"
    206 			let conditionString = condition.mode ?
    207 				condition.condition + '/' + condition.mode :
    208 				condition.condition
    209 			
    210 			var sqlParams = [
    211 				searchID,
    212 				i,
    213 				conditionString,
    214 				condition.operator ? condition.operator : null,
    215 				condition.value ? condition.value : null,
    216 				condition.required ? 1 : null
    217 			];
    218 			yield Zotero.DB.queryAsync(sql, sqlParams);
    219 			i++;
    220 		}
    221 	}
    222 });
    223 
    224 Zotero.Search.prototype._finalizeSave = Zotero.Promise.coroutine(function* (env) {
    225 	if (env.isNew) {
    226 		// Update library searches status
    227 		yield Zotero.Libraries.get(this.libraryID).updateSearches();
    228 		
    229 		Zotero.Notifier.queue('add', 'search', this.id, env.notifierData, env.options.notifierQueue);
    230 	}
    231 	else if (!env.options.skipNotifier) {
    232 		Zotero.Notifier.queue('modify', 'search', this.id, env.notifierData, env.options.notifierQueue);
    233 	}
    234 	
    235 	if (env.isNew && Zotero.Libraries.isGroupLibrary(this.libraryID)) {
    236 		var groupID = Zotero.Groups.getGroupIDFromLibraryID(this.libraryID);
    237 		var group = yield Zotero.Groups.get(groupID);
    238 		group.clearSearchCache();
    239 	}
    240 	
    241 	if (!env.skipCache) {
    242 		yield this.reload();
    243 		// If new, there's no other data we don't have, so we can mark everything as loaded
    244 		if (env.isNew) {
    245 			this._markAllDataTypeLoadStates(true);
    246 		}
    247 		this._clearChanged();
    248 	}
    249 	
    250 	return env.isNew ? this.id : true;
    251 });
    252 
    253 
    254 Zotero.Search.prototype.clone = function (libraryID) {
    255 	var s = new Zotero.Search();
    256 	s.libraryID = libraryID === undefined ? this.libraryID : libraryID;
    257 	
    258 	var conditions = this.getConditions();
    259 	
    260 	for (let condition of Object.values(conditions)) {
    261 		var name = condition.mode ?
    262 			condition.condition + '/' + condition.mode :
    263 			condition.condition
    264 			
    265 		s.addCondition(name, condition.operator, condition.value,
    266 			condition.required);
    267 	}
    268 	
    269 	return s;
    270 };
    271 
    272 
    273 Zotero.Search.prototype._eraseData = Zotero.Promise.coroutine(function* (env) {
    274 	Zotero.DB.requireTransaction();
    275 	
    276 	var sql = "DELETE FROM savedSearchConditions WHERE savedSearchID=?";
    277 	yield Zotero.DB.queryAsync(sql, this.id);
    278 	
    279 	var sql = "DELETE FROM savedSearches WHERE savedSearchID=?";
    280 	yield Zotero.DB.queryAsync(sql, this.id);
    281 });
    282 
    283 Zotero.Search.prototype._finalizeErase = Zotero.Promise.coroutine(function* (env) {
    284 	yield Zotero.Search._super.prototype._finalizeErase.call(this, env);
    285 	
    286 	// Update library searches status
    287 	yield Zotero.Libraries.get(this.libraryID).updateSearches();
    288 });
    289 
    290 
    291 Zotero.Search.prototype.addCondition = function (condition, operator, value, required) {
    292 	this._requireData('conditions');
    293 	
    294 	if (!Zotero.SearchConditions.hasOperator(condition, operator)){
    295 		let e = new Error("Invalid operator '" + operator + "' for condition " + condition);
    296 		e.name = "ZoteroUnknownFieldError";
    297 		throw e;
    298 	}
    299 	
    300 	// Shortcut to add a condition on every table -- does not return an id
    301 	if (condition.match(/^quicksearch/)) {
    302 		var parts = Zotero.SearchConditions.parseSearchString(value);
    303 		
    304 		for (let part of parts) {
    305 			this.addCondition('blockStart');
    306 			
    307 			// Allow searching for exact object key
    308 			if (operator == 'contains' && Zotero.Utilities.isValidObjectKey(part.text)) {
    309 				this.addCondition('key', 'is', part.text, false);
    310 			}
    311 			
    312 			if (condition == 'quicksearch-titleCreatorYear') {
    313 				this.addCondition('title', operator, part.text, false);
    314 				this.addCondition('publicationTitle', operator, part.text, false);
    315 				this.addCondition('shortTitle', operator, part.text, false);
    316 				this.addCondition('court', operator, part.text, false);
    317 				this.addCondition('year', operator, part.text, false);
    318 			}
    319 			else {
    320 				this.addCondition('field', operator, part.text, false);
    321 				this.addCondition('tag', operator, part.text, false);
    322 				this.addCondition('note', operator, part.text, false);
    323 			}
    324 			this.addCondition('creator', operator, part.text, false);
    325 			
    326 			if (condition == 'quicksearch-everything') {
    327 				this.addCondition('annotation', operator, part.text, false);
    328 				
    329 				if (part.inQuotes) {
    330 					this.addCondition('fulltextContent', operator, part.text, false);
    331 				}
    332 				else {
    333 					var splits = Zotero.Fulltext.semanticSplitter(part.text);
    334 					for (let split of splits) {
    335 						this.addCondition('fulltextWord', operator, split, false);
    336 					}
    337 				}
    338 			}
    339 			
    340 			this.addCondition('blockEnd');
    341 		}
    342 		
    343 		if (condition == 'quicksearch-titleCreatorYear') {
    344 			this.addCondition('noChildren', 'true');
    345 		}
    346 		
    347 		return false;
    348 	}
    349 	// Shortcut to add a collection (which must be loaded first)
    350 	else if (condition == 'collectionID') {
    351 		let {libraryID, key} = Zotero.Collections.getLibraryAndKeyFromID(value);
    352 		if (!key) {
    353 			let msg = "Collection " + value + " not found";
    354 			Zotero.debug(msg, 2);
    355 			Components.utils.reportError(msg);
    356 			return;
    357 		}
    358 		if (this.libraryID && libraryID != this.libraryID) {
    359 			Zotero.logError(new Error("Collection " + value + " is in different library"));
    360 			return;
    361 		}
    362 		return this.addCondition('collection', operator, key, required);
    363 	}
    364 	// Shortcut to add a saved search (which must be loaded first)
    365 	else if (condition == 'savedSearchID') {
    366 		let {libraryID, key} = Zotero.Searches.getLibraryAndKeyFromID(value);
    367 		if (!key) {
    368 			let msg = "Saved search " + value + " not found";
    369 			Zotero.debug(msg, 2);
    370 			Components.utils.reportError(msg);
    371 			return;
    372 		}
    373 		if (this.libraryID && libraryID != this.libraryID) {
    374 			Zotero.logError(new Error("Collection " + value + " is in different library"));
    375 			return;
    376 		}
    377 		return this.addCondition('savedSearch', operator, key, required);
    378 	}
    379 	// Parse old-style collection/savedSearch conditions ('0_ABCD2345' -> 'ABCD2345')
    380 	else if (condition == 'collection' || condition == 'savedSearch') {
    381 		if (value.includes('_')) {
    382 			Zotero.logError(`'condition' value '${value}' should be an object key`);
    383 			let [_, objKey] = value.split('_');
    384 			value = objKey;
    385 		}
    386 	}
    387 	
    388 	var searchConditionID = ++this._maxSearchConditionID;
    389 	
    390 	let mode;
    391 	[condition, mode] = Zotero.SearchConditions.parseCondition(condition);
    392 	
    393 	if (typeof value == 'string') value = value.normalize();
    394 	
    395 	this._conditions[searchConditionID] = {
    396 		id: searchConditionID,
    397 		condition: condition,
    398 		mode: mode,
    399 		operator: operator,
    400 		value: value,
    401 		required: !!required
    402 	};
    403 	
    404 	this._sql = null;
    405 	this._sqlParams = false;
    406 	this._markFieldChange('conditions', this._conditions);
    407 	this._changed.conditions = true;
    408 	
    409 	return searchConditionID;
    410 }
    411 
    412 
    413 /*
    414  * Sets scope of search to the results of the passed Search object
    415  */
    416 Zotero.Search.prototype.setScope = function (searchObj, includeChildren) {
    417 	this._scope = searchObj;
    418 	this._scopeIncludeChildren = includeChildren;
    419 }
    420 
    421 
    422 /**
    423  * @param {Number} searchConditionID
    424  * @param {String} condition
    425  * @param {String} operator
    426  * @param {String} value
    427  * @param {Boolean} [required]
    428  * @return {Promise}
    429  */
    430 Zotero.Search.prototype.updateCondition = function (searchConditionID, condition, operator, value, required) {
    431 	this._requireData('conditions');
    432 	
    433 	if (typeof this._conditions[searchConditionID] == 'undefined'){
    434 		throw new Error('Invalid searchConditionID ' + searchConditionID);
    435 	}
    436 	
    437 	if (!Zotero.SearchConditions.hasOperator(condition, operator)){
    438 		let e = new Error("Invalid operator '" + operator + "' for condition " + condition);
    439 		e.name = "ZoteroUnknownFieldError";
    440 		throw e;
    441 	}
    442 	
    443 	var [condition, mode] = Zotero.SearchConditions.parseCondition(condition);
    444 	
    445 	if (typeof value == 'string') value = value.normalize();
    446 	
    447 	this._conditions[searchConditionID] = {
    448 		id: parseInt(searchConditionID),
    449 		condition: condition,
    450 		mode: mode,
    451 		operator: operator,
    452 		value: value,
    453 		required: !!required
    454 	};
    455 	
    456 	this._sql = null;
    457 	this._sqlParams = false;
    458 	this._markFieldChange('conditions', this._conditions);
    459 	this._changed.conditions = true;
    460 }
    461 
    462 
    463 Zotero.Search.prototype.removeCondition = function (searchConditionID) {
    464 	this._requireData('conditions');
    465 	
    466 	if (typeof this._conditions[searchConditionID] == 'undefined'){
    467 		throw new Error('Invalid searchConditionID ' + searchConditionID + ' in removeCondition()');
    468 	}
    469 	
    470 	delete this._conditions[searchConditionID];
    471 	this._maxSearchConditionID--;
    472 	this._markFieldChange('conditions', this._conditions);
    473 	this._changed.conditions = true;
    474 }
    475 
    476 
    477 /*
    478  * Returns an array with 'condition', 'operator', 'value', 'required'
    479  * for the given searchConditionID
    480  */
    481 Zotero.Search.prototype.getCondition = function(searchConditionID){
    482 	this._requireData('conditions');
    483 	return this._conditions[searchConditionID];
    484 }
    485 
    486 
    487 /*
    488  * Returns an object of conditions/operator/value sets used in the search,
    489  * indexed by searchConditionID
    490  */
    491 Zotero.Search.prototype.getConditions = function(){
    492 	this._requireData('conditions');
    493 	var conditions = {};
    494 	for (let id in this._conditions) {
    495 		let condition = this._conditions[id];
    496 		conditions[id] = {
    497 			id: id,
    498 			condition: condition.condition,
    499 			mode: condition.mode,
    500 			operator: condition.operator,
    501 			value: condition.value,
    502 			required: condition.required
    503 		};
    504 	}
    505 	return conditions;
    506 }
    507 
    508 
    509 Zotero.Search.prototype.hasPostSearchFilter = function() {
    510 	this._requireData('conditions');
    511 	for (let i of Object.values(this._conditions)) {
    512 		if (i.condition == 'fulltextContent'){
    513 			return true;
    514 		}
    515 	}
    516 	return false;
    517 }
    518 
    519 
    520 /**
    521  * Run the search and return an array of item ids for results
    522  *
    523  * @param {Boolean} [asTempTable=false]
    524  * @return {Promise}
    525  */
    526 Zotero.Search.prototype.search = Zotero.Promise.coroutine(function* (asTempTable) {
    527 	var tmpTable;
    528 	
    529 	// Mark conditions as loaded
    530 	// TODO: Necessary?
    531 	if (!this._identified) {
    532 		this._requireData('conditions');
    533 	}
    534 	try {
    535 		if (!this._sql){
    536 			yield this._buildQuery();
    537 		}
    538 		
    539 		// Default to 'all' mode
    540 		var joinMode = 'all';
    541 		
    542 		// Set some variables for conditions to avoid further lookups
    543 		for (let condition of Object.values(this._conditions)) {
    544 			switch (condition.condition) {
    545 				case 'joinMode':
    546 					if (condition.operator == 'any') {
    547 						joinMode = 'any';
    548 					}
    549 					break;
    550 				
    551 				case 'fulltextContent':
    552 					var fulltextContent = true;
    553 					break;
    554 				
    555 				case 'includeParentsAndChildren':
    556 					if (condition.operator == 'true') {
    557 						var includeParentsAndChildren = true;
    558 					}
    559 					break;
    560 				
    561 				case 'includeParents':
    562 					if (condition.operator == 'true') {
    563 						var includeParents = true;
    564 					}
    565 					break;
    566 				
    567 				case 'includeChildren':
    568 					if (condition.operator == 'true') {
    569 						var includeChildren = true;
    570 					}
    571 					break;
    572 				
    573 				case 'blockStart':
    574 					var hasQuicksearch = true;
    575 					break;
    576 			}
    577 		}
    578 		
    579 		// Run a subsearch to define the superset of possible results
    580 		if (this._scope) {
    581 			// If subsearch has post-search filter, run and insert ids into temp table
    582 			if (this._scope.hasPostSearchFilter()) {
    583 				var ids = yield this._scope.search();
    584 				if (!ids) {
    585 					return [];
    586 				}
    587 				tmpTable = yield Zotero.Search.idsToTempTable(ids);
    588 			}
    589 			// Otherwise, just copy to temp table directly
    590 			else {
    591 				tmpTable = "tmpSearchResults_" + Zotero.randomString(8);
    592 				var sql = "CREATE TEMPORARY TABLE " + tmpTable + " AS "
    593 					+ (yield this._scope.getSQL());
    594 				yield Zotero.DB.queryAsync(sql, yield this._scope.getSQLParams());
    595 				var sql = "CREATE INDEX " + tmpTable + "_itemID ON " + tmpTable + "(itemID)";
    596 				yield Zotero.DB.queryAsync(sql);
    597 			}
    598 			
    599 			// Search ids in temp table
    600 			var sql = "SELECT GROUP_CONCAT(itemID) FROM items WHERE itemID IN (" + this._sql + ") "
    601 				+ "AND ("
    602 				+ "itemID IN (SELECT itemID FROM " + tmpTable + ")";
    603 			
    604 			if (this._scopeIncludeChildren) {
    605 				sql += " OR itemID IN (SELECT itemID FROM itemAttachments"
    606 				+ " WHERE parentItemID IN (SELECT itemID FROM " + tmpTable + ")) OR "
    607 				+ "itemID IN (SELECT itemID FROM itemNotes"
    608 				+ " WHERE parentItemID IN (SELECT itemID FROM " + tmpTable + "))";
    609 			}
    610 			sql += ")";
    611 			
    612 			var res = yield Zotero.DB.valueQueryAsync(sql, this._sqlParams);
    613 			var ids = res ? res.split(",").map(id => parseInt(id)) : [];
    614 			/*
    615 			// DEBUG: Should this be here?
    616 			//
    617 			if (!ids) {
    618 				Zotero.DB.query("DROP TABLE " + tmpTable);
    619 				Zotero.DB.commitTransaction();
    620 				return false;
    621 			}
    622 			*/
    623 		}
    624 		// Or just run main search
    625 		else {
    626 			var ids = yield Zotero.DB.columnQueryAsync(this._sql, this._sqlParams);
    627 		}
    628 		
    629 		//Zotero.debug('IDs from main search or subsearch: ');
    630 		//Zotero.debug(ids);
    631 		
    632 		//Zotero.debug('Join mode: ' + joinMode);
    633 		
    634 		// Filter results with fulltext search
    635 		//
    636 		// If join mode ALL, return the (intersection of main and fulltext word search)
    637 		// filtered by fulltext content
    638 		//
    639 		// If join mode ANY or there's a quicksearch (which we assume
    640 		// fulltextContent is part of), return the union of the main search and
    641 		// (a separate fulltext word search filtered by fulltext content)
    642 		for (let condition of Object.values(this._conditions)){
    643 			if (condition['condition']=='fulltextContent'){
    644 				var fulltextWordIntersectionFilter = (val, index, array) => !!hash[val];
    645 				var fulltextWordIntersectionConditionFilter = function(val, index, array) {
    646 					return hash[val] ?
    647 						(condition.operator == 'contains') :
    648 						(condition.operator == 'doesNotContain');
    649 				};
    650 				
    651 				// Regexp mode -- don't use fulltext word index
    652 				if (condition.mode && condition.mode.startsWith('regexp')) {
    653 					// In an ANY search with other conditions that alter the results, only bother
    654 					// scanning items that haven't already been found by the main search, as long as
    655 					// they're in the right library
    656 					if (joinMode == 'any' && this._hasPrimaryConditions) {
    657 						if (!tmpTable) {
    658 							tmpTable = yield Zotero.Search.idsToTempTable(ids);
    659 						}
    660 						
    661 						var sql = "SELECT GROUP_CONCAT(itemID) FROM items WHERE "
    662 							+ "itemID NOT IN (SELECT itemID FROM " + tmpTable + ")";
    663 						if (this.libraryID) {
    664 							sql += " AND libraryID=" + parseInt(this.libraryID);
    665 						}
    666 						
    667 						var res = yield Zotero.DB.valueQueryAsync(sql);
    668 						var scopeIDs = res ? res.split(",").map(id => parseInt(id)) : [];
    669 					}
    670 					// If an ALL search, scan only items from the main search
    671 					else {
    672 						var scopeIDs = ids;
    673 					}
    674 				}
    675 				// If not regexp mode, run a new search against the fulltext word
    676 				// index for words in this phrase
    677 				else {
    678 					Zotero.debug('Running subsearch against fulltext word index');
    679 					var s = new Zotero.Search();
    680 					if (this.libraryID) {
    681 						s.libraryID = this.libraryID;
    682 					}
    683 					
    684 					// Add any necessary conditions to the fulltext word search --
    685 					// those that are required in an ANY search and any outside the
    686 					// quicksearch in an ALL search
    687 					for (let c of Object.values(this._conditions)) {
    688 						if (c.condition == 'blockStart') {
    689 							var inQS = true;
    690 							continue;
    691 						}
    692 						else if (c.condition == 'blockEnd') {
    693 							inQS = false;
    694 							continue;
    695 						}
    696 						else if (c.condition == 'fulltextContent' || inQS) {
    697 							continue;
    698 						}
    699 						else if (joinMode == 'any' && !c.required) {
    700 							continue;
    701 						}
    702 						s.addCondition(c.condition, c.operator, c.value);
    703 					}
    704 					
    705 					var splits = Zotero.Fulltext.semanticSplitter(condition.value);
    706 					for (let split of splits){
    707 						s.addCondition('fulltextWord', condition.operator, split);
    708 					}
    709 					var fulltextWordIDs = yield s.search();
    710 					
    711 					//Zotero.debug("Fulltext word IDs");
    712 					//Zotero.debug(fulltextWordIDs);
    713 					
    714 					// If ALL mode, set intersection of main search and fulltext word index
    715 					// as the scope for the fulltext content search
    716 					if (joinMode == 'all' && !hasQuicksearch) {
    717 						var hash = {};
    718 						for (let i=0; i<fulltextWordIDs.length; i++) {
    719 							hash[fulltextWordIDs[i]] = true;
    720 						}
    721 						
    722 						if (ids) {
    723 							var scopeIDs = ids.filter(fulltextWordIntersectionFilter);
    724 						}
    725 						else {
    726 							var scopeIDs = [];
    727 						}
    728 					}
    729 					// If ANY mode, just use fulltext word index hits for content search,
    730 					// since the main results will be added in below
    731 					else {
    732 						var scopeIDs = fulltextWordIDs;
    733 					}
    734 				}
    735 				
    736 				if (scopeIDs && scopeIDs.length) {
    737 					var fulltextIDs = yield Zotero.Fulltext.findTextInItems(scopeIDs,
    738 						condition['value'], condition['mode']);
    739 					
    740 					var hash = {};
    741 					for (let i=0; i<fulltextIDs.length; i++) {
    742 						hash[fulltextIDs[i].id] = true;
    743 					}
    744 					
    745 					filteredIDs = scopeIDs.filter(fulltextWordIntersectionConditionFilter);
    746 				}
    747 				else {
    748 					var filteredIDs = [];
    749 				}
    750 				
    751 				//Zotero.debug("Filtered IDs:")
    752 				//Zotero.debug(filteredIDs);
    753 				
    754 				// If join mode ANY, add any new items from the fulltext content
    755 				// search to the main search results
    756 				//
    757 				// We only do this if there are primary conditions that alter the
    758 				// main search, since otherwise all items will match
    759 				if (this._hasPrimaryConditions && (joinMode == 'any' || hasQuicksearch)) {
    760 					//Zotero.debug("Adding filtered IDs to main set");
    761 					for (let i=0; i<filteredIDs.length; i++) {
    762 						let id = filteredIDs[i];
    763 						if (ids.indexOf(id) == -1) {
    764 							ids.push(id);
    765 						}
    766 					}
    767 				}
    768 				else {
    769 					//Zotero.debug("Replacing main set with filtered IDs");
    770 					ids = filteredIDs;
    771 				}
    772 			}
    773 		}
    774 		
    775 		if (this.hasPostSearchFilter() &&
    776 				(includeParentsAndChildren || includeParents || includeChildren)) {
    777 			var tmpTable = yield Zotero.Search.idsToTempTable(ids);
    778 			
    779 			if (includeParentsAndChildren || includeParents) {
    780 				//Zotero.debug("Adding parent items to result set");
    781 				var sql = "SELECT parentItemID FROM itemAttachments "
    782 					+ "WHERE itemID IN (SELECT itemID FROM " + tmpTable + ") "
    783 						+ " AND parentItemID IS NOT NULL "
    784 					+ "UNION SELECT parentItemID FROM itemNotes "
    785 						+ "WHERE itemID IN (SELECT itemID FROM " + tmpTable + ")"
    786 						+ " AND parentItemID IS NOT NULL";
    787 			}
    788 			
    789 			if (includeParentsAndChildren || includeChildren) {
    790 				//Zotero.debug("Adding child items to result set");
    791 				var childrenSQL = "SELECT itemID FROM itemAttachments WHERE "
    792 					+ "parentItemID IN (SELECT itemID FROM " + tmpTable + ") UNION "
    793 					+ "SELECT itemID FROM itemNotes WHERE parentItemID IN "
    794 					+ "(SELECT itemID FROM " + tmpTable + ")";
    795 					
    796 				if (includeParentsAndChildren || includeParents) {
    797 					sql += " UNION " + childrenSQL;
    798 				}
    799 				else {
    800 					sql = childrenSQL;
    801 				}
    802 			}
    803 			
    804 			sql = "SELECT GROUP_CONCAT(itemID) FROM items WHERE itemID IN (" + sql + ")";
    805 			var res = yield Zotero.DB.valueQueryAsync(sql);
    806 			var parentChildIDs = res ? res.split(",").map(id => parseInt(id)) : [];
    807 			
    808 			// Add parents and children to main ids
    809 			for (let id of parentChildIDs) {
    810 				if (!ids.includes(id)) {
    811 					ids.push(id);
    812 				}
    813 			}
    814 		}
    815 	}
    816 	finally {
    817 		if (tmpTable && !asTempTable) {
    818 			yield Zotero.DB.queryAsync("DROP TABLE IF EXISTS " + tmpTable);
    819 		}
    820 	}
    821 	
    822 	//Zotero.debug('Final result set');
    823 	//Zotero.debug(ids);
    824 	
    825 	if (!ids || !ids.length) {
    826 		return [];
    827 	}
    828 	
    829 	if (asTempTable) {
    830 		return Zotero.Search.idsToTempTable(ids);
    831 	}
    832 	return ids;
    833 });
    834 
    835 
    836 /**
    837  * Populate the object's data from an API JSON data object
    838  *
    839  * If this object is identified (has an id or library/key), loadAll() must have been called.
    840  */
    841 Zotero.Search.prototype.fromJSON = function (json) {
    842 	if (!json.name) {
    843 		throw new Error("'name' property not provided for search");
    844 	}
    845 	this.name = json.name;
    846 	
    847 	Object.keys(this.getConditions()).forEach(id => this.removeCondition(id));
    848 	for (let i = 0; i < json.conditions.length; i++) {
    849 		let condition = json.conditions[i];
    850 		this.addCondition(
    851 			condition.condition,
    852 			condition.operator,
    853 			condition.value
    854 		);
    855 	}
    856 }
    857 
    858 
    859 Zotero.Search.prototype.toJSON = function (options = {}) {
    860 	var env = this._preToJSON(options);
    861 	var mode = env.mode;
    862 	
    863 	var obj = env.obj = {};
    864 	obj.key = this.key;
    865 	obj.version = this.version;
    866 	obj.name = this.name;
    867 	var conditions = this.getConditions();
    868 	obj.conditions = Object.keys(conditions)
    869 		.map(x => ({
    870 			condition: conditions[x].condition
    871 				+ (conditions[x].mode !== false ? "/" + conditions[x].mode : ""),
    872 			operator: conditions[x].operator,
    873 			// TODO: Change joinMode to use 'is' + 'any' instead of operator 'any'?
    874 			value: conditions[x].value ? conditions[x].value : ""
    875 		}));
    876 	return this._postToJSON(env);
    877 }
    878 
    879 
    880 /*
    881  * Get the SQL string for the search
    882  */
    883 Zotero.Search.prototype.getSQL = Zotero.Promise.coroutine(function* () {
    884 	if (!this._sql) {
    885 		yield this._buildQuery();
    886 	}
    887 	return this._sql;
    888 });
    889 
    890 
    891 Zotero.Search.prototype.getSQLParams = Zotero.Promise.coroutine(function* () {
    892 	if (!this._sql) {
    893 		yield this._buildQuery();
    894 	}
    895 	return this._sqlParams;
    896 });
    897 
    898 
    899 /*
    900  * Batch insert
    901  */
    902 Zotero.Search.idsToTempTable = Zotero.Promise.coroutine(function* (ids) {
    903 	var tmpTable = "tmpSearchResults_" + Zotero.randomString(8);
    904 	
    905 	Zotero.debug(`Creating ${tmpTable} with ${ids.length} item${ids.length != 1 ? 's' : ''}`);
    906 	var sql = "CREATE TEMPORARY TABLE " + tmpTable;
    907 	if (ids.length) {
    908 		sql += " AS "
    909 		+ "WITH cte(itemID) AS ("
    910 			+ "VALUES " + ids.map(id => "(" + parseInt(id) + ")").join(',')
    911 		+ ") "
    912 		+ "SELECT * FROM cte";
    913 	}
    914 	else {
    915 		sql += " (itemID INTEGER PRIMARY KEY)";
    916 	}
    917 	yield Zotero.DB.queryAsync(sql, false, { debug: false });
    918 	if (ids.length) {
    919 		yield Zotero.DB.queryAsync(`CREATE UNIQUE INDEX ${tmpTable}_itemID ON ${tmpTable}(itemID)`);
    920 	}
    921 	
    922 	return tmpTable;
    923 });
    924 
    925 
    926 /*
    927  * Build the SQL query for the search
    928  */
    929 Zotero.Search.prototype._buildQuery = Zotero.Promise.coroutine(function* () {
    930 	this._requireData('conditions');
    931 	
    932 	var sql = 'SELECT itemID FROM items';
    933 	var sqlParams = [];
    934 	// Separate ANY conditions for 'required' condition support
    935 	var anySQL = '';
    936 	var anySQLParams = [];
    937 	
    938 	var conditions = [];
    939 	
    940 	let lastCondition;
    941 	for (let condition of Object.values(this._conditions)) {
    942 		let name = condition.condition;
    943 		let conditionData = Zotero.SearchConditions.get(name);
    944 		
    945 		// Has a table (or 'savedSearch', which doesn't have a table but isn't special)
    946 		if (conditionData.table || name == 'savedSearch' || name == 'tempTable') {
    947 			// For conditions with an inline filter using 'is'/'isNot', combine with last condition
    948 			// if the same
    949 			if (lastCondition
    950 					&& ((!lastCondition.alias && !condition.alias && name == lastCondition.name)
    951 						|| (lastCondition.alias && condition.alias && lastCondition.alias == condition.alias))
    952 					&& condition.operator.startsWith('is')
    953 					&& condition.operator == lastCondition.operator
    954 					&& conditionData.inlineFilter) {
    955 				if (!Array.isArray(lastCondition.value)) {
    956 					lastCondition.value = [lastCondition.value];
    957 				}
    958 				lastCondition.value.push(condition.value);
    959 				continue;
    960 			}
    961 			
    962 			lastCondition = {
    963 				name: conditionData.name,
    964 				alias: conditionData.name != name ? name : false,
    965 				table: conditionData.table,
    966 				field: conditionData.field,
    967 				operator: condition.operator,
    968 				value: condition.value,
    969 				flags: conditionData.flags,
    970 				required: condition.required,
    971 				inlineFilter: conditionData.inlineFilter
    972 			};
    973 			conditions.push(lastCondition);
    974 			
    975 			this._hasPrimaryConditions = true;
    976 		}
    977 		
    978 		// Handle special conditions
    979 		else {
    980 			switch (conditionData.name) {
    981 				case 'deleted':
    982 					var deleted = condition.operator == 'true';
    983 					continue;
    984 				
    985 				case 'noChildren':
    986 					var noChildren = condition.operator == 'true';
    987 					continue;
    988 				
    989 				case 'includeParentsAndChildren':
    990 					var includeParentsAndChildren = condition.operator == 'true';
    991 					continue;
    992 					
    993 				case 'includeParents':
    994 					var includeParents = condition.operator == 'true';
    995 					continue;
    996 				
    997 				case 'includeChildren':
    998 					var includeChildren = condition.operator == 'true';
    999 					continue;
   1000 				
   1001 				case 'unfiled':
   1002 					var unfiled = condition.operator == 'true';
   1003 					continue;
   1004 				
   1005 				case 'publications':
   1006 					var publications = condition.operator == 'true';
   1007 					continue;
   1008 				
   1009 				// Search subcollections
   1010 				case 'recursive':
   1011 					var recursive = condition.operator == 'true';
   1012 					continue;
   1013 				
   1014 				// Join mode ('any' or 'all')
   1015 				case 'joinMode':
   1016 					var joinMode = condition.operator.toUpperCase();
   1017 					continue;
   1018 				
   1019 				case 'fulltextContent':
   1020 					// Handled in Search.search()
   1021 					continue;
   1022 				
   1023 				// For quicksearch block markers
   1024 				case 'blockStart':
   1025 					conditions.push({name:'blockStart'});
   1026 					continue;
   1027 				case 'blockEnd':
   1028 					conditions.push({name:'blockEnd'});
   1029 					continue;
   1030 			}
   1031 			
   1032 			throw new Error('Unhandled special condition ' + name);
   1033 		}
   1034 	}
   1035 	
   1036 	// Exclude deleted items (and their child items) by default
   1037 	let not = deleted ? "" : "NOT ";
   1038 	let op = deleted ? "OR" : "AND";
   1039 	sql += " WHERE ("
   1040 		+ `itemID ${not} IN (SELECT itemID FROM deletedItems) `
   1041 		+ `${op} itemID ${not}IN (SELECT itemID FROM itemNotes `
   1042 				+ "WHERE parentItemID IS NOT NULL AND "
   1043 				+ "parentItemID IN (SELECT itemID FROM deletedItems)) "
   1044 		+ `${op} itemID ${not}IN (SELECT itemID FROM itemAttachments `
   1045 				+ "WHERE parentItemID IS NOT NULL AND "
   1046 				+ "parentItemID IN (SELECT itemID FROM deletedItems))"
   1047 		+ ")";
   1048 	
   1049 	if (noChildren){
   1050 		sql += " AND (itemID NOT IN (SELECT itemID FROM itemNotes "
   1051 			+ "WHERE parentItemID IS NOT NULL) AND itemID NOT IN "
   1052 			+ "(SELECT itemID FROM itemAttachments "
   1053 			+ "WHERE parentItemID IS NOT NULL))";
   1054 	}
   1055 	
   1056 	if (unfiled) {
   1057 		sql += " AND (itemID NOT IN (SELECT itemID FROM collectionItems) "
   1058 			// Exclude children
   1059 			+ "AND itemID NOT IN "
   1060 			+ "(SELECT itemID FROM itemAttachments WHERE parentItemID IS NOT NULL "
   1061 			+ "UNION SELECT itemID FROM itemNotes WHERE parentItemID IS NOT NULL)"
   1062 			+ ") "
   1063 			// Exclude My Publications
   1064 			+ "AND itemID NOT IN (SELECT itemID FROM publicationsItems)";
   1065 	}
   1066 	
   1067 	if (publications) {
   1068 		sql += " AND (itemID IN (SELECT itemID FROM publicationsItems))";
   1069 	}
   1070 	
   1071 	// Limit to library search belongs to
   1072 	//
   1073 	// This is equivalent to adding libraryID as a search condition,
   1074 	// but it works with ANY
   1075 	if (this.libraryID !== null) {
   1076 		sql += " AND (itemID IN (SELECT itemID FROM items WHERE libraryID=?))";
   1077 		sqlParams.push(this.libraryID);
   1078 	}
   1079 	
   1080 	if (this._hasPrimaryConditions) {
   1081 		sql += " AND ";
   1082 		
   1083 		for (let condition of Object.values(conditions)){
   1084 				var skipOperators = false;
   1085 				var openParens = 0;
   1086 				var condSQL = '';
   1087 				var selectOpenParens = 0;
   1088 				var condSelectSQL = '';
   1089 				var condSQLParams = [];
   1090 				
   1091 				//
   1092 				// Special table handling
   1093 				//
   1094 				if (condition['table']){
   1095 					switch (condition['table']){
   1096 						default:
   1097 							condSelectSQL += 'itemID '
   1098 							switch (condition['operator']){
   1099 								case 'isNot':
   1100 								case 'doesNotContain':
   1101 									condSelectSQL += 'NOT ';
   1102 									break;
   1103 							}
   1104 							condSelectSQL += 'IN (';
   1105 							selectOpenParens = 1;
   1106 							condSQL += 'SELECT itemID FROM ' +
   1107 								condition['table'] + ' WHERE (';
   1108 							openParens = 1;
   1109 					}
   1110 				}
   1111 				
   1112 				//
   1113 				// Special condition handling
   1114 				//
   1115 				switch (condition['name']){
   1116 					case 'field':
   1117 					case 'datefield':
   1118 					case 'numberfield':
   1119 						if (condition['alias']) {
   1120 							// Add base field
   1121 							condSQLParams.push(
   1122 								Zotero.ItemFields.getID(condition['alias'])
   1123 							);
   1124 							var typeFields = Zotero.ItemFields.getTypeFieldsFromBase(condition['alias']);
   1125 							if (typeFields) {
   1126 								condSQL += 'fieldID IN (?,';
   1127 								// Add type-specific fields
   1128 								for (let fieldID of typeFields) {
   1129 									condSQL += '?,';
   1130 									condSQLParams.push(fieldID);
   1131 								}
   1132 								condSQL = condSQL.substr(0, condSQL.length - 1);
   1133 								condSQL += ') AND ';
   1134 							}
   1135 							else {
   1136 								condSQL += 'fieldID=? AND ';
   1137 							}
   1138 						}
   1139 						
   1140 						condSQL += "valueID IN (SELECT valueID FROM "
   1141 							+ "itemDataValues WHERE ";
   1142 						
   1143 						openParens++;
   1144 						break;
   1145 					
   1146 					case 'year':
   1147 						condSQLParams.push(Zotero.ItemFields.getID('date'));
   1148 						//Add base field
   1149 						var dateFields = Zotero.ItemFields.getTypeFieldsFromBase('date');
   1150 						if (dateFields) {
   1151 							condSQL += 'fieldID IN (?,';																
   1152 							// Add type-specific date fields (dateEnacted, dateDecided, issueDate)
   1153 							for (let fieldID of dateFields) {
   1154 								condSQL += '?,';
   1155 								condSQLParams.push(fieldID);
   1156 							}
   1157 							condSQL = condSQL.substr(0, condSQL.length - 1);
   1158 							condSQL += ') AND ';
   1159 						}
   1160 					
   1161 						condSQL += "valueID IN (SELECT valueID FROM "
   1162 							+ "itemDataValues WHERE ";
   1163 						
   1164 						openParens++;
   1165 						break;
   1166 					
   1167 					case 'collection':
   1168 					case 'savedSearch':
   1169 						let obj;
   1170 						let objLibraryID;
   1171 						let objKey = condition.value;
   1172 						let objectType = condition.name == 'collection' ? 'collection' : 'search';
   1173 						let objectTypeClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
   1174 						
   1175 						// libraryID assigned on search
   1176 						if (this.libraryID !== null) {
   1177 							objLibraryID = this.libraryID;
   1178 						}
   1179 						
   1180 						// If search doesn't have a libraryID, check all possible libraries
   1181 						// for the collection/search
   1182 						if (objLibraryID === undefined) {
   1183 							let foundLibraryID = false;
   1184 							for (let c of Object.values(this._conditions)) {
   1185 								if (c.condition == 'libraryID' && c.operator == 'is') {
   1186 									foundLibraryID = true;
   1187 									obj = yield objectTypeClass.getByLibraryAndKeyAsync(
   1188 										c.value, objKey
   1189 									);
   1190 									if (obj) {
   1191 										break;
   1192 									}
   1193 								}
   1194 							}
   1195 							if (!foundLibraryID) {
   1196 								Zotero.debug("WARNING: libraryID condition not found for "
   1197 									+ objectType + " in search", 2);
   1198 							}
   1199 						}
   1200 						else {
   1201 							obj = yield objectTypeClass.getByLibraryAndKeyAsync(
   1202 								objLibraryID, objKey
   1203 							);
   1204 						}
   1205 						if (!obj) {
   1206 							var msg = objectType.charAt(0).toUpperCase() + objectType.substr(1)
   1207 								+ " " + objKey + " specified in search not found";
   1208 							Zotero.debug(msg, 2);
   1209 							Zotero.log(msg, 'warning', 'chrome://zotero/content/xpcom/search.js');
   1210 							if (objectType == 'search') {
   1211 								continue;
   1212 							}
   1213 							obj = {
   1214 								id: 0
   1215 							};
   1216 						}
   1217 						if (objectType == 'search' && obj == this) {
   1218 							Zotero.warn(`Search "${this.name}" references itself -- skipping condition`);
   1219 							continue;
   1220 						}
   1221 						
   1222 						if (objectType == 'collection') {
   1223 							let ids = [obj.id];
   1224 							
   1225 							// Search descendent collections if recursive search
   1226 							if (recursive){
   1227 								ids = ids.concat(obj.getDescendents(false, 'collection').map(d => d.id));
   1228 							}
   1229 							
   1230 							condSQL += 'collectionID IN (' + ids.join(', ') + ')';
   1231 						}
   1232 						// Saved search
   1233 						else {
   1234 							// Check if there are any post-search filters
   1235 							var hasFilter = obj.hasPostSearchFilter();
   1236 							
   1237 							// This is an ugly and inefficient way of doing a
   1238 							// subsearch, but it's necessary if there are any
   1239 							// post-search filters (e.g. fulltext scanning) in the
   1240 							// subsearch
   1241 							//
   1242 							// DEBUG: it's possible there's a query length limit here
   1243 							// or that this slows things down with large libraries
   1244 							// -- should probably use a temporary table instead
   1245 							if (hasFilter){
   1246 								let subids = yield obj.search();
   1247 								condSQL += subids.join();
   1248 							}
   1249 							// Otherwise just put the SQL in a subquery
   1250 							else {
   1251 								condSQL += "itemID ";
   1252 								if (condition.operator == 'isNot') {
   1253 									condSQL += "NOT ";
   1254 								}
   1255 								condSQL += "IN (";
   1256 								condSQL += yield obj.getSQL();
   1257 								let subpar = yield obj.getSQLParams();
   1258 								for (let k in subpar){
   1259 									condSQLParams.push(subpar[k]);
   1260 								}
   1261 							}
   1262 							condSQL += ")";
   1263 						}
   1264 						
   1265 						skipOperators = true;
   1266 						break;
   1267 					
   1268 					case 'itemType':
   1269 						condSQL += "itemTypeID IN (SELECT itemTypeID FROM itemTypesCombined WHERE ";
   1270 						openParens++;
   1271 						break;
   1272 					
   1273 					case 'fileTypeID':
   1274 						var ftSQL = 'SELECT mimeType FROM fileTypeMimeTypes '
   1275 							+ 'WHERE fileTypeID IN ('
   1276 							+ 'SELECT fileTypeID FROM fileTypes WHERE '
   1277 							+ 'fileTypeID=?)';
   1278 						var patterns = yield Zotero.DB.columnQueryAsync(ftSQL, { int: condition.value });
   1279 						if (patterns) {
   1280 							for (let str of patterns) {
   1281 								condSQL += 'contentType LIKE ? OR ';
   1282 								condSQLParams.push(str + '%');
   1283 							}
   1284 							condSQL = condSQL.substring(0, condSQL.length - 4);
   1285 						}
   1286 						else {
   1287 							throw ("Invalid fileTypeID '" + condition.value + "' specified in search.js")
   1288 						}
   1289 						skipOperators = true;
   1290 						break;
   1291 					
   1292 					case 'tag':
   1293 						condSQL += "tagID IN (SELECT tagID FROM tags WHERE ";
   1294 						openParens++;
   1295 						break;
   1296 					
   1297 					case 'creator':
   1298 					case 'lastName':
   1299 						condSQL += "creatorID IN (SELECT creatorID FROM creators WHERE ";
   1300 						openParens++;
   1301 						break;
   1302 					
   1303 					case 'childNote':
   1304 						condSQL += "itemID IN (SELECT parentItemID FROM "
   1305 							+ "itemNotes WHERE ";
   1306 						openParens++;
   1307 						break;
   1308 					
   1309 					case 'fulltextWord':
   1310 						condSQL += "wordID IN (SELECT wordID FROM fulltextWords "
   1311 							+ "WHERE ";
   1312 						openParens++;
   1313 						break;
   1314 					
   1315 					case 'tempTable':
   1316 						condSQL += "itemID IN (SELECT id FROM " + condition.value + ")";
   1317 						skipOperators = true;
   1318 						break;
   1319 						
   1320 					// For quicksearch blocks
   1321 					case 'blockStart':
   1322 					case 'blockEnd':
   1323 						skipOperators = true;
   1324 						break;
   1325 				}
   1326 				
   1327 				if (!skipOperators){
   1328 					// Special handling for date fields
   1329 					//
   1330 					// Note: We assume full datetimes are already UTC and don't
   1331 					// need to be handled specially
   1332 					if ((condition['name']=='dateAdded' ||
   1333 							condition['name']=='dateModified' ||
   1334 							condition['name']=='datefield') &&
   1335 							!Zotero.Date.isSQLDateTime(condition['value'])){
   1336 						
   1337 						// TODO: document these flags
   1338 						var parseDate = null;
   1339 						var alt = null;
   1340 						var useFreeform = null;
   1341 						
   1342 						switch (condition['operator']){
   1343 							case 'is':
   1344 							case 'isNot':
   1345 								var parseDate = true;
   1346 								var alt = '__';
   1347 								var useFreeform = true;
   1348 								break;
   1349 							
   1350 							case 'isBefore':
   1351 								var parseDate = true;
   1352 								var alt = '00';
   1353 								var useFreeform = false;
   1354 								break;
   1355 								
   1356 							case 'isAfter':
   1357 								var parseDate = true;
   1358 								// '__' used here just so the > string comparison
   1359 								// doesn't match dates in the specified year
   1360 								var alt = '__';
   1361 								var useFreeform = false;
   1362 								break;
   1363 								
   1364 							case 'isInTheLast':
   1365 								var parseDate = false;
   1366 								break;
   1367 								
   1368 							default:
   1369 								throw ('Invalid date field operator in search');
   1370 						}
   1371 						
   1372 						// Convert stored UTC dates to localtime
   1373 						//
   1374 						// It'd be nice not to deal with time zones here at all,
   1375 						// but otherwise searching for the date part of a field
   1376 						// stored as UTC that wraps midnight would be unsuccessful
   1377 						if (condition['name']=='dateAdded' ||
   1378 								condition['name']=='dateModified' ||
   1379 								condition['alias']=='accessDate'){
   1380 							condSQL += "DATE(" + condition['field'] + ", 'localtime')";
   1381 						}
   1382 						// Only use first (SQL) part of multipart dates
   1383 						else {
   1384 							condSQL += "SUBSTR(" + condition['field'] + ", 1, 10)";
   1385 						}
   1386 						
   1387 						if (parseDate){
   1388 							var go = false;
   1389 							var dateparts = Zotero.Date.strToDate(condition.value);
   1390 							
   1391 							// Search on SQL date -- underscore is
   1392 							// single-character wildcard
   1393 							//
   1394 							// If isBefore or isAfter, month and day fall back
   1395 							// to '00' so that a search for just a year works
   1396 							// (and no year will just not find anything)
   1397 							var sqldate = dateparts.year ?
   1398 								Zotero.Utilities.lpad(dateparts.year, '0', 4) : '____';
   1399 							sqldate += '-'
   1400 							sqldate += dateparts.month || dateparts.month === 0 ?
   1401 								Zotero.Utilities.lpad(dateparts.month + 1, '0', 2) : alt;
   1402 							sqldate += '-';
   1403 							sqldate += dateparts.day ?
   1404 								Zotero.Utilities.lpad(dateparts.day, '0', 2) : alt;
   1405 							
   1406 							if (sqldate!='____-__-__'){
   1407 								go = true;
   1408 								
   1409 								switch (condition['operator']){
   1410 									case 'is':
   1411 									case 'isNot':
   1412 										condSQL += ' LIKE ?';
   1413 										break;
   1414 									
   1415 									case 'isBefore':
   1416 										condSQL += '<?';
   1417 										condSQL += ' AND ' + condition['field'] +
   1418 											">'0000-00-00'";
   1419 										break;
   1420 										
   1421 									case 'isAfter':
   1422 										condSQL += '>?';
   1423 										break;
   1424 								}
   1425 								
   1426 								condSQLParams.push({string:sqldate});
   1427 							}
   1428 							
   1429 							// Search for any remaining parts individually
   1430 							if (useFreeform && dateparts['part']){
   1431 								go = true;
   1432 								var parts = dateparts['part'].split(' ');
   1433 								for (let part of parts) {
   1434 									condSQL += " AND SUBSTR(" + condition['field'] + ", 12, 100)";
   1435 									condSQL += " LIKE ?";
   1436 									condSQLParams.push('%' + part  + '%');
   1437 								}
   1438 							}
   1439 							
   1440 							// If neither part used, invalidate clause
   1441 							if (!go){
   1442 								condSQL += '=0';
   1443 							}
   1444 						}
   1445 						
   1446 						else {
   1447 							switch (condition['operator']){
   1448 								case 'isInTheLast':
   1449 									condSQL += ">DATE('NOW', 'localtime', ?)"; // e.g. ('NOW', '-10 DAYS')
   1450 									condSQLParams.push({string: '-' + condition['value']});
   1451 									break;
   1452 							}
   1453 						}
   1454 					}
   1455 					
   1456 					// Non-date fields
   1457 					else {
   1458 						switch (condition.operator) {
   1459 							// Cast strings as integers for < and > comparisons,
   1460 							// at least until 
   1461 							case 'isLessThan':
   1462 							case 'isGreaterThan':
   1463 								condSQL += "CAST(" + condition['field'] + " AS INT)";
   1464 								// Make sure either field is an integer or
   1465 								// converting to an integer and back to a string
   1466 								// yields the same result (i.e. it's numeric)
   1467 								var opAppend = " AND (TYPEOF("
   1468 									+ condition['field'] + ") = 'integer' OR "
   1469 									+ "CAST("
   1470 										+ "CAST(" + condition['field'] + " AS INT)"
   1471 									+ " AS STRING) = " + condition['field'] + ")"
   1472 								break;
   1473 								
   1474 							default:
   1475 								condSQL += condition['field'];
   1476 						}
   1477 						
   1478 						switch (condition['operator']){
   1479 							case 'contains':
   1480 							case 'doesNotContain': // excluded with NOT IN above
   1481 								condSQL += ' LIKE ?';
   1482 								// For fields with 'leftbound' flag, perform a
   1483 								// leftbound search even for 'contains' condition
   1484 								if (condition['flags'] &&
   1485 										condition['flags']['leftbound'] &&
   1486 										Zotero.Prefs.get('search.useLeftBound')) {
   1487 									condSQLParams.push(condition['value'] + '%');
   1488 								}
   1489 								else {
   1490 									condSQLParams.push('%' + condition['value'] + '%');
   1491 								}
   1492 								break;
   1493 								
   1494 							case 'is':
   1495 							case 'isNot': // excluded with NOT IN above
   1496 								// If inline filter is available, embed value directly to get around
   1497 								// the max bound parameter limit
   1498 								if (condition.inlineFilter) {
   1499 									let src = Array.isArray(condition.value)
   1500 										? condition.value : [condition.value];
   1501 									let values = [];
   1502 									
   1503 									for (let val of src) {
   1504 										val = condition.inlineFilter(val);
   1505 										if (val) {
   1506 											values.push(val);
   1507 										}
   1508 									}
   1509 									
   1510 									if (!values.length) {
   1511 										continue;
   1512 									}
   1513 									
   1514 									condSQL += values.length > 1
   1515 										? ` IN (${values.join(', ')})`
   1516 										: `=${values[0]}`;
   1517 								}
   1518 								else {
   1519 									// Automatically cast values which might
   1520 									// have been stored as integers
   1521 									if (condition.value && typeof condition.value == 'string'
   1522 											&& condition.value.match(/^[1-9]+[0-9]*$/)) {
   1523 										condSQL += ' LIKE ?';
   1524 									}
   1525 									else if (condition.value === null) {
   1526 										condSQL += ' IS NULL';
   1527 										break;
   1528 									}
   1529 									else {
   1530 										condSQL += '=?';
   1531 									}
   1532 									condSQLParams.push(condition['value']);
   1533 								}
   1534 								break;
   1535 							
   1536 							case 'beginsWith':
   1537 								condSQL += ' LIKE ?';
   1538 								condSQLParams.push(condition['value'] + '%');
   1539 								break;
   1540 							
   1541 							case 'isLessThan':
   1542 								condSQL += '<?';
   1543 								condSQLParams.push({int:condition['value']});
   1544 								condSQL += opAppend;
   1545 								break;
   1546 								
   1547 							case 'isGreaterThan':
   1548 								condSQL += '>?';
   1549 								condSQLParams.push({int:condition['value']});
   1550 								condSQL += opAppend;
   1551 								break;
   1552 							
   1553 							// Next two only used with full datetimes
   1554 							case 'isBefore':
   1555 								condSQL += '<?';
   1556 								condSQLParams.push({string:condition['value']});
   1557 								break;
   1558 								
   1559 							case 'isAfter':
   1560 								condSQL += '>?';
   1561 								condSQLParams.push({string:condition['value']});
   1562 								break;
   1563 						}
   1564 					}
   1565 				}
   1566 				
   1567 				// Close open parentheses
   1568 				for (var k=openParens; k>0; k--){
   1569 					condSQL += ')';
   1570 				}
   1571 				
   1572 				if (includeParentsAndChildren || includeParents) {
   1573 					var parentSQL = "SELECT itemID FROM items WHERE "
   1574 						+ "itemID IN (SELECT parentItemID FROM itemAttachments "
   1575 							+ "WHERE itemID IN (" + condSQL + ")) "
   1576 						+ "OR itemID IN (SELECT parentItemID FROM itemNotes "
   1577 							+ "WHERE itemID IN (" + condSQL + ")) ";
   1578 					var parentSQLParams = condSQLParams.concat(condSQLParams);
   1579 				}
   1580 				
   1581 				if (includeParentsAndChildren || includeChildren) {
   1582 					var childrenSQL = "SELECT itemID FROM itemAttachments WHERE "
   1583 						+ "parentItemID IN (" + condSQL + ") UNION "
   1584 						+ "SELECT itemID FROM itemNotes "
   1585 						+ "WHERE parentItemID IN (" + condSQL + ")";
   1586 					var childSQLParams = condSQLParams.concat(condSQLParams);
   1587 				}
   1588 				
   1589 				if (includeParentsAndChildren || includeParents) {
   1590 					condSQL += " UNION " + parentSQL;
   1591 					condSQLParams = condSQLParams.concat(parentSQLParams);
   1592 				}
   1593 				
   1594 				if (includeParentsAndChildren || includeChildren) {
   1595 					condSQL += " UNION " + childrenSQL;
   1596 					condSQLParams = condSQLParams.concat(childSQLParams);
   1597 				}
   1598 				
   1599 				condSQL = condSelectSQL + condSQL;
   1600 				
   1601 				// Close open parentheses
   1602 				for (var k=selectOpenParens; k>0; k--) {
   1603 					condSQL += ')';
   1604 				}
   1605 				
   1606 				// Little hack to support multiple quicksearch words
   1607 				if (condition['name'] == 'blockStart') {
   1608 					var inQS = true;
   1609 					var qsSQL = '';
   1610 					var qsParams = [];
   1611 					continue;
   1612 				}
   1613 				else if (condition['name'] == 'blockEnd') {
   1614 					inQS = false;
   1615 					// Strip ' OR ' from last condition
   1616 					qsSQL = qsSQL.substring(0, qsSQL.length-4);
   1617 					
   1618 					// Add to existing quicksearch words
   1619 					if (!quicksearchSQLSet) {
   1620 						var quicksearchSQLSet = [];
   1621 						var quicksearchParamsSet = [];
   1622 					}
   1623 					quicksearchSQLSet.push(qsSQL);
   1624 					quicksearchParamsSet.push(qsParams);
   1625 				}
   1626 				else if (inQS) {
   1627 					qsSQL += condSQL + ' OR ';
   1628 					qsParams = qsParams.concat(condSQLParams);
   1629 				}
   1630 				// Keep non-required conditions separate if in ANY mode
   1631 				else if (!condition['required'] && joinMode == 'ANY') {
   1632 					anySQL += condSQL + ' OR ';
   1633 					anySQLParams = anySQLParams.concat(condSQLParams);
   1634 				}
   1635 				else {
   1636 					condSQL += ' AND ';
   1637 					sql += condSQL;
   1638 					sqlParams = sqlParams.concat(condSQLParams);
   1639 				}
   1640 		}
   1641 		
   1642 		// Add on ANY conditions
   1643 		if (anySQL){
   1644 			sql += '(' + anySQL;
   1645 			sqlParams = sqlParams.concat(anySQLParams);
   1646 			sql = sql.substring(0, sql.length-4); // remove last ' OR '
   1647 			sql += ')';
   1648 		}
   1649 		else {
   1650 			sql = sql.substring(0, sql.length-5); // remove last ' AND '
   1651 		}
   1652 		
   1653 		// Add on quicksearch conditions
   1654 		if (quicksearchSQLSet) {
   1655 			sql = "SELECT itemID FROM items WHERE itemID IN (" + sql + ") "
   1656 				+ "AND ((" + quicksearchSQLSet.join(') AND (') + "))";
   1657 			
   1658 			for (var k=0; k<quicksearchParamsSet.length; k++) {
   1659 				sqlParams = sqlParams.concat(quicksearchParamsSet[k]);
   1660 			}
   1661 		}
   1662 	}
   1663 	
   1664 	this._sql = sql;
   1665 	this._sqlParams = sqlParams.length ? sqlParams : false;
   1666 });