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

Zotero.Search = function() {
	if (arguments[0]) {
		throw ("Zotero.Search constructor doesn't take any parameters");
	}
	
	this._loaded = false;
	this._init();
}


Zotero.Search.prototype._init = function () {
	// Public members for access by public methods -- do not access directly
	this._id = null;
	this._libraryID = null;
	this._key = null;
	this._name = null;
	this._dateAdded = null;
	this._dateModified = null;
	
	this._changed = false;
	this._previousData = false;
	
	this._scope = null;
	this._scopeIncludeChildren = null;
	this._sql = null;
	this._sqlParams = null;
	this._maxSearchConditionID = 0;
	this._conditions = [];
	this._hasPrimaryConditions = false;
}



Zotero.Search.prototype.getID = function(){
	Zotero.debug('Zotero.Search.getName() is deprecated -- use Search.id');
	return this._id;
}

Zotero.Search.prototype.getName = function() {
	Zotero.debug('Zotero.Search.getName() is deprecated -- use Search.name');
	return this.name;
}

Zotero.Search.prototype.setName = function(val) {
	Zotero.debug('Zotero.Search.setName() is deprecated -- use Search.name');
	this.name = val;
}


Zotero.Search.prototype.__defineGetter__('objectType', function () { return 'search'; });
Zotero.Search.prototype.__defineGetter__('id', function () { return this._get('id'); });
Zotero.Search.prototype.__defineSetter__('id', function (val) { this._set('id', val); });
Zotero.Search.prototype.__defineGetter__('libraryID', function () { return this._get('libraryID'); });
Zotero.Search.prototype.__defineSetter__('libraryID', function (val) { return this._set('libraryID', val); });
Zotero.Search.prototype.__defineGetter__('key', function () { return this._get('key'); });
Zotero.Search.prototype.__defineSetter__('key', function (val) { this._set('key', val) });
Zotero.Search.prototype.__defineGetter__('name', function () { return this._get('name'); });
Zotero.Search.prototype.__defineSetter__('name', function (val) { this._set('name', val); });
Zotero.Search.prototype.__defineGetter__('dateAdded', function () { return this._get('dateAdded'); });
Zotero.Search.prototype.__defineSetter__('dateAdded', function (val) { this._set('dateAdded', val); });
Zotero.Search.prototype.__defineGetter__('dateModified', function () { return this._get('dateModified'); });
Zotero.Search.prototype.__defineSetter__('dateModified', function (val) { this._set('dateModified', val); });

Zotero.Search.prototype.__defineGetter__('conditions', function (arr) { this.getSearchConditions(); });


Zotero.Search.prototype._get = function (field) {
	if ((this._id || this._key) && !this._loaded) {
		this.load();
	}
	return this['_' + field];
}


Zotero.Search.prototype._set = function (field, val) {
	switch (field) {
		case 'id':
		case 'libraryID':
		case 'key':
			if (val == this['_' + field]) {
				return;
			}
			
			if (this._loaded) {
				throw ("Cannot set " + field + " after object is already loaded in Zotero.Search._set()");
			}
			//this._checkValue(field, val);
			this['_' + field] = val;
			return;
		
		case 'name':
			val = Zotero.Utilities.trim(val);
			break;
	}
	
	if (this.id || this.key) {
		if (!this._loaded) {
			this.load();
		}
	}
	else {
		this._loaded = true;
	}
	
	if (this['_' + field] != val) {
		this._prepFieldChange(field);
		
		switch (field) {
			default:
				this['_' + field] = val;
		}
	}
}


/**
 * Check if saved search exists in the database
 *
 * @return	bool			TRUE if the search exists, FALSE if not
 */
Zotero.Search.prototype.exists = function() {
	if (!this.id) {
		throw ('searchID not set in Zotero.Search.exists()');
	}
	
	var sql = "SELECT COUNT(*) FROM savedSearches WHERE savedSearchID=?";
	return !!Zotero.DB.valueQuery(sql, this.id);
}


/*
 * Load a saved search from the DB
 */
Zotero.Search.prototype.load = function() {
	// Changed in 1.5
	if (arguments[0]) {
		throw ('Parameter no longer allowed in Zotero.Search.load()');
	}
	
	var id = this._id;
	var key = this._key;
	var libraryID = this._libraryID;
	var desc = id ? id : libraryID + "/" + key;
	
	var sql = "SELECT S.*, "
		+ "MAX(searchConditionID) AS maxID "
		+ "FROM savedSearches S LEFT JOIN savedSearchConditions "
		+ "USING (savedSearchID) WHERE ";
	if (id) {
		sql += "savedSearchID=?";
		var params = id;
	}
	else {
		sql += "key=?";
		var params = [key];
		if (libraryID) {
			sql += " AND libraryID=?";
			params.push(libraryID);
		}
		else {
			sql += " AND libraryID IS NULL";
		}
	}
	sql += " GROUP BY savedSearchID";
	var data = Zotero.DB.rowQuery(sql, params);
	
	this._loaded = true;
	
	if (!data) {
		return;
	}
	
	this._init();
	this._id = data.savedSearchID;
	this._libraryID = data.libraryID;
	this._key = data.key;
	this._name = data.savedSearchName;
	this._dateAdded = data.dateAdded;
	this._dateModified = data.dateModified;
	this._maxSearchConditionID = data.maxID;
	
	var sql = "SELECT * FROM savedSearchConditions "
		+ "WHERE savedSearchID=? ORDER BY searchConditionID";
	var conditions = Zotero.DB.query(sql, this._id);
	
	// Reindex conditions, in case they're not contiguous in the DB
	var conditionID = 1;
	
	for (var i in conditions) {
		// Parse "condition[/mode]"
		var [condition, mode] =
			Zotero.SearchConditions.parseCondition(conditions[i]['condition']);
		
		var cond = Zotero.SearchConditions.get(condition);
		if (!cond || cond.noLoad) {
			Zotero.debug("Invalid saved search condition '" + condition + "' -- skipping", 2);
			continue;
		}
		
		// Convert itemTypeID to itemType
		//
		// TEMP: This can be removed at some point
		if (condition == 'itemTypeID') {
			condition = 'itemType';
			conditions[i].value = Zotero.ItemTypes.getName(conditions[i].value);
		}
		
		this._conditions[conditionID] = {
			id: conditionID,
			condition: condition,
			mode: mode,
			operator: conditions[i]['operator'],
			value: conditions[i]['value'],
			required: conditions[i]['required']
		};
		
		conditionID++;
	}
}


/*
 * Save the search to the DB and return a savedSearchID
 *
 * If there are gaps in the searchConditionIDs, |fixGaps| must be true
 * and the caller must dispose of the search or reload the condition ids,
 * which may change after the save.
 *
 * For new searches, name must be set called before saving
 */
Zotero.Search.prototype.save = function(fixGaps) {
	Zotero.Searches.editCheck(this);
	
	if (!this.name) {
		throw('Name not provided for saved search');
	}
	
	Zotero.DB.beginTransaction();
	
	var isNew = !this.id || !this.exists();
	
	try {
		var searchID = this.id ? this.id : Zotero.ID.get('savedSearches');
		
		Zotero.debug("Saving " + (isNew ? 'new ' : '') + "search " + this.id);
		
		var key = this.key ? this.key : this._generateKey();
		
		var columns = [
			'savedSearchID',
			'savedSearchName',
			'dateAdded',
			'dateModified',
			'clientDateModified',
			'libraryID',
			'key'
		];
		var placeholders = ['?', '?', '?', '?', '?', '?', '?'];
		var sqlValues = [
			searchID ? { int: searchID } : null,
			{ string: this.name },
			// If date added isn't set, use current timestamp
			this.dateAdded ? this.dateAdded : Zotero.DB.transactionDateTime,
			// If date modified hasn't changed, use current timestamp
			this._changed.dateModified ?
				this.dateModified : Zotero.DB.transactionDateTime,
			Zotero.DB.transactionDateTime,
			this.libraryID ? this.libraryID : this.libraryID,
			key
		];
		
		var sql = "REPLACE INTO savedSearches (" + columns.join(', ') + ") VALUES ("
			+ placeholders.join(', ') + ")";
		var insertID = Zotero.DB.query(sql, sqlValues);
		if (!searchID) {
			searchID = insertID;
		}
		
		if (!isNew) {
			var sql = "DELETE FROM savedSearchConditions WHERE savedSearchID=?";
			Zotero.DB.query(sql, this.id);
		}
		
		// Close gaps in savedSearchIDs
		var saveConditions = {};
		var i = 1;
		for (var id in this._conditions) {
			if (!fixGaps && id != i) {
				Zotero.DB.rollbackTransaction();
				throw ('searchConditionIDs not contiguous and |fixGaps| not set in save() of saved search ' + this._id);
			}
			saveConditions[i] = this._conditions[id];
			i++;
		}
		
		this._conditions = saveConditions;
		
		// TODO: use proper bound parameters once DB class is updated
		for (var i in this._conditions){
			var sql = "INSERT INTO savedSearchConditions (savedSearchID, "
				+ "searchConditionID, condition, operator, value, required) "
				+ "VALUES (?,?,?,?,?,?)";
			
			// Convert condition and mode to "condition[/mode]"
			var condition = this._conditions[i].mode ?
				this._conditions[i].condition + '/' + this._conditions[i].mode :
				this._conditions[i].condition
			
			var sqlParams = [
				searchID, i, condition,
				this._conditions[i].operator
					? this._conditions[i].operator : null,
				this._conditions[i].value
					? this._conditions[i].value : null,
				this._conditions[i].required
					? 1 : null
			];
			Zotero.DB.query(sql, sqlParams);
		}
		
		
		if (isNew && this.libraryID) {
			var groupID = Zotero.Groups.getGroupIDFromLibraryID(this.libraryID);
			var group = Zotero.Groups.get(groupID);
			group.clearSearchCache();
		}
		
		Zotero.DB.commitTransaction();
	}
	catch (e) {
		Zotero.DB.rollbackTransaction();
		throw (e);
	}
	
	// If successful, set values in object
	if (!this.id) {
		this._id = searchID;
	}
	
	if (!this.key) {
		this._key = key;
	}
	
	if (isNew) {
		Zotero.Notifier.trigger('add', 'search', this.id);
	}
	else {
		Zotero.Notifier.trigger('modify', 'search', this.id, this._previousData);
	}
	
	return this._id;
}


Zotero.Search.prototype.clone = function() {
	var s = new Zotero.Search();
	
	var conditions = this.getSearchConditions();
	
	for each(var condition in conditions) {
		var name = condition.mode ?
			condition.condition + '/' + condition.mode :
			condition.condition
			
		s.addCondition(name, condition.operator, condition.value,
			condition.required);
	}
	
	return s;
}


Zotero.Search.prototype.addCondition = function(condition, operator, value, required) {
	if ((this.id || this.key) && !this._loaded) {
		this.load();
	}
	
	if (!Zotero.SearchConditions.hasOperator(condition, operator)){
		throw ("Invalid operator '" + operator + "' for condition " + condition);
	}
	
	// Shortcut to add a condition on every table -- does not return an id
	if (condition.match(/^quicksearch/)) {
		var parts = Zotero.SearchConditions.parseSearchString(value);
		
		for each(var part in parts) {
			this.addCondition('blockStart');
			
			// If search string is 8 characters, see if this is a item key
			if (operator == 'contains' && part.text.length == 8) {
				this.addCondition('key', 'is', part.text, false);
			}
			
			if (condition == 'quicksearch-titleCreatorYear') {
				this.addCondition('title', operator, part.text, false);
				this.addCondition('year', operator, part.text, false);
			}
			else {
				this.addCondition('field', operator, part.text, false);
				this.addCondition('tag', operator, part.text, false);
				this.addCondition('note', operator, part.text, false);
			}
			this.addCondition('creator', operator, part.text, false);
			
			if (condition == 'quicksearch-everything') {
				this.addCondition('annotation', operator, part.text, false);
				
				if (part.inQuotes) {
					this.addCondition('fulltextContent', operator, part.text, false);
				}
				else {
					var splits = Zotero.Fulltext.semanticSplitter(part.text);
					for each(var split in splits) {
						this.addCondition('fulltextWord', operator, split, false);
					}
				}
			}
			
			this.addCondition('blockEnd');
		}
		
		if (condition == 'quicksearch-titleCreatorYear') {
			this.addCondition('noChildren', 'true');
		}
		
		return false;
	}
	// Shortcut to add a collection
	else if (condition == 'collectionID') {
		var c = Zotero.Collections.get(value);
		if (!c) {
			var msg = "Collection " + value + " not found";
			Zotero.debug(msg, 2);
			Components.utils.reportError(msg);
			return;
		}
		var lkh = Zotero.Collections.getLibraryKeyHash(c);
		return this.addCondition('collection', operator, lkh, required);
	}
	// Shortcut to add a saved search
	else if (condition == 'savedSearchID') {
		var s = Zotero.Searches.get(value);
		if (!s) {
			var msg = "Saved search " + value + " not found";
			Zotero.debug(msg, 2);
			Components.utils.reportError(msg);
			return;
		}
		var lkh = Zotero.Searches.getLibraryKeyHash(s);
		return this.addCondition('savedSearch', operator, lkh, required);
	}
	
	var searchConditionID = ++this._maxSearchConditionID;
	
	var [condition, mode] = Zotero.SearchConditions.parseCondition(condition);
	
	this._conditions[searchConditionID] = {
		id: searchConditionID,
		condition: condition,
		mode: mode,
		operator: operator,
		value: value,
		required: required
	};
	
	this._sql = null;
	this._sqlParams = null;
	return searchConditionID;
}


/*
 * Sets scope of search to the results of the passed Search object
 */
Zotero.Search.prototype.setScope = function (searchObj, includeChildren) {
	this._scope = searchObj;
	this._scopeIncludeChildren = includeChildren;
}


Zotero.Search.prototype.updateCondition = function(searchConditionID, condition, operator, value, required){
	if ((this.id || this.key) && !this._loaded) {
		this.load();
	}
	
	if (typeof this._conditions[searchConditionID] == 'undefined'){
		throw ('Invalid searchConditionID ' + searchConditionID + ' in updateCondition()');
	}
	
	if (!Zotero.SearchConditions.hasOperator(condition, operator)){
		throw ("Invalid operator '" + operator + "' for condition " + condition);
	}
	
	var [condition, mode] = Zotero.SearchConditions.parseCondition(condition);
	
	this._conditions[searchConditionID] = {
		id: parseInt(searchConditionID),
		condition: condition,
		mode: mode,
		operator: operator,
		value: value,
		required: required
	};
	
	this._sql = null;
	this._sqlParams = null;
}


Zotero.Search.prototype.removeCondition = function(searchConditionID){
	if ((this.id || this.key) && !this._loaded) {
		this.load();
	}
	
	if (typeof this._conditions[searchConditionID] == 'undefined'){
		throw ('Invalid searchConditionID ' + searchConditionID + ' in removeCondition()');
	}
	
	delete this._conditions[searchConditionID];
}


/*
 * Returns an array with 'condition', 'operator', 'value', 'required'
 * for the given searchConditionID
 */
Zotero.Search.prototype.getSearchCondition = function(searchConditionID){
	if ((this.id || this.key) && !this._loaded) {
		this.load();
	}
	return this._conditions[searchConditionID];
}


/*
 * Returns a multidimensional array of conditions/operator/value sets
 * used in the search, indexed by searchConditionID
 */
Zotero.Search.prototype.getSearchConditions = function(){
	if ((this.id || this.key) && !this._loaded) {
		this.load();
	}
	var conditions = [];
	for (var id in this._conditions) {
		var condition = this._conditions[id];
		conditions[id] = {
			id: id,
			condition: condition.condition,
			mode: condition.mode,
			operator: condition.operator,
			value: condition.value,
			required: condition.required
		};
	}
	return conditions;
}


Zotero.Search.prototype.hasPostSearchFilter = function() {
	if ((this.id || this.key) && !this._loaded) {
		this.load();
	}
	for each(var i in this._conditions){
		if (i.condition == 'fulltextContent'){
			return true;
		}
	}
	return false;
}


/*
 * Run the search and return an array of item ids for results
 */
Zotero.Search.prototype.search = function(asTempTable){
	if ((this.id || this.key) && !this._loaded) {
		this.load();
	}
	
	if (!this._sql){
		this._buildQuery();
	}
	
	// Default to 'all' mode
	var joinMode = 'all';
	
	// Set some variables for conditions to avoid further lookups
	for each(var condition in this._conditions) {
		switch (condition.condition) {
			case 'joinMode':
				if (condition.operator == 'any') {
					joinMode = 'any';
				}
				break;
			
			case 'fulltextContent':
				var fulltextContent = true;
				break;
			
			case 'includeParentsAndChildren':
				if (condition.operator == 'true') {
					var includeParentsAndChildren = true;
				}
				break;
			
			case 'includeParents':
				if (condition.operator == 'true') {
					var includeParents = true;
				}
				break;
			
			case 'includeChildren':
				if (condition.operator == 'true') {
					var includeChildren = true;
				}
				break;
			
			case 'blockStart':
				var hasQuicksearch = true;
				break;
		}
	}
	
	// Run a subsearch to define the superset of possible results
	if (this._scope) {
		Zotero.DB.beginTransaction();
		
		// If subsearch has post-search filter, run and insert ids into temp table
		if (this._scope.hasPostSearchFilter()) {
			var ids = this._scope.search();
			if (!ids) {
				Zotero.DB.commitTransaction();
				return false;
			}
			
			var tmpTable = Zotero.Search.idsToTempTable(ids);
		}
		// Otherwise, just copy to temp table directly
		else {
			var tmpTable = "tmpSearchResults_" + Zotero.randomString(8);
			var sql = "CREATE TEMPORARY TABLE " + tmpTable + " AS "
				+ this._scope.getSQL();
			Zotero.DB.query(sql, this._scope.getSQLParams());
			var sql = "CREATE INDEX " + tmpTable + "_itemID ON " + tmpTable + "(itemID)";
			Zotero.DB.query(sql);
		}
		
		// Search ids in temp table
		var sql = "SELECT GROUP_CONCAT(itemID) FROM items WHERE itemID IN (" + this._sql + ") "
			+ "AND ("
			+ "itemID IN (SELECT itemID FROM " + tmpTable + ")";
		
		if (this._scopeIncludeChildren) {
			sql += " OR itemID IN (SELECT itemID FROM itemAttachments"
			+ " WHERE sourceItemID IN (SELECT itemID FROM " + tmpTable + ")) OR "
			+ "itemID IN (SELECT itemID FROM itemNotes"
			+ " WHERE sourceItemID IN (SELECT itemID FROM " + tmpTable + "))";
		}
		sql += ")";
		
		var res = Zotero.DB.valueQuery(sql, this._sqlParams),
			ids = res ? res.split(",") : [];
		/*
		// DEBUG: Should this be here?
		//
		if (!ids) {
			Zotero.DB.query("DROP TABLE " + tmpTable);
			Zotero.DB.commitTransaction();
			return false;
		}
		*/
	}
	// Or just run main search
	else {
		var ids = Zotero.DB.columnQuery(this._sql, this._sqlParams);
	}
	
	//Zotero.debug('IDs from main search or subsearch: ');
	//Zotero.debug(ids);
	
	//Zotero.debug('Join mode: ' + joinMode);
	
	// Filter results with fulltext search
	//
	// If join mode ALL, return the (intersection of main and fulltext word search)
	// filtered by fulltext content
	//
	// If join mode ANY or there's a quicksearch (which we assume
	// fulltextContent is part of), return the union of the main search and
	// (a separate fulltext word search filtered by fulltext content)
	for each(var condition in this._conditions){
		if (condition['condition']=='fulltextContent'){
			var filter = function(val, index, array) {
				return hash[val] ?
					(condition.operator == 'contains') :
					(condition.operator == 'doesNotContain');
			};
			
			// Regexp mode -- don't use fulltext word index
			if (condition.mode && condition.mode.indexOf('regexp') == 0) {
				// In an ANY search, only bother scanning items that
				// haven't already been found by the main search
				if (joinMode == 'any') {
					if (!tmpTable) {
						Zotero.DB.beginTransaction();
						var tmpTable = Zotero.Search.idsToTempTable(ids);
					}
					
					var sql = "SELECT GROUP_CONCAT(itemID) FROM items WHERE "
						+ "itemID NOT IN (SELECT itemID FROM " + tmpTable + ")";
					var res = Zotero.DB.valueQuery(sql);
					var scopeIDs = res ? res.split(",") : [];
				}
				// If an ALL search, scan only items from the main search
				else {
					var scopeIDs = ids;
				}
			}
			// If not regexp mode, run a new search against the fulltext word
			// index for words in this phrase
			else {
				Zotero.debug('Running subsearch against fulltext word index');
				var s = new Zotero.Search();
				
				// Add any necessary conditions to the fulltext word search --
				// those that are required in an ANY search and any outside the
				// quicksearch in an ALL search
				for each(var c in this._conditions) {
					if (c.condition == 'blockStart') {
						var inQS = true;
						continue;
					}
					else if (c.condition == 'blockEnd') {
						inQS = false;
						continue;
					}
					else if (c.condition == 'fulltextContent' ||
							c.condition == 'fulltextContent' ||
								inQS) {
						continue;
					}
					else if (joinMode == 'any' && !c.required) {
						continue;
					}
					s.addCondition(c.condition, c.operator, c.value);
				}
				
				var splits = Zotero.Fulltext.semanticSplitter(condition.value);
				for each(var split in splits){
					s.addCondition('fulltextWord', condition.operator, split);
				}
				var fulltextWordIDs = s.search();
				
				//Zotero.debug("Fulltext word IDs");
				//Zotero.debug(fulltextWordIDs);
				
				// If ALL mode, set intersection of main search and fulltext word index
				// as the scope for the fulltext content search
				if (joinMode == 'all' && !hasQuicksearch) {
					var hash = {};
					for each(var id in fulltextWordIDs){
						hash[id] = true;
					}
					
					if (ids) {
						var scopeIDs = ids.filter(filter);
					}
					else {
						var scopeIDs = [];
					}
				}
				// If ANY mode, just use fulltext word index hits for content search,
				// since the main results will be added in below
				else {
					var scopeIDs = fulltextWordIDs;
				}
			}
			
			if (scopeIDs && scopeIDs.length) {
				var fulltextIDs = Zotero.Fulltext.findTextInItems(scopeIDs,
					condition['value'], condition['mode']);
				
				var hash = {};
				for each(var val in fulltextIDs){
					hash[val.id] = true;
				}
				
				filteredIDs = scopeIDs.filter(filter);
			}
			else {
				var filteredIDs = [];
			}
			
			//Zotero.debug("Filtered IDs:")
			//Zotero.debug(filteredIDs);
			
			// If join mode ANY, add any new items from the fulltext content
			// search to the main search results
			//
			// We only do this if there are primary conditions that alter the
			// main search, since otherwise all items will match
			if (this._hasPrimaryConditions &&
					(joinMode == 'any' || hasQuicksearch) && ids) {
				//Zotero.debug("Adding filtered IDs to main set");
				for each(var id in filteredIDs) {
					if (ids.indexOf(id) == -1) {
						ids.push(id);
					}
				}
			}
			else {
				//Zotero.debug("Replacing main set with filtered IDs");
				ids = filteredIDs;
			}
		}
	}
	
	if (tmpTable) {
		Zotero.DB.query("DROP TABLE " + tmpTable);
		Zotero.DB.commitTransaction();
	}
	
	if (this.hasPostSearchFilter() &&
			(includeParentsAndChildren || includeParents || includeChildren)) {
		Zotero.DB.beginTransaction();
		var tmpTable = Zotero.Search.idsToTempTable(ids);
		
		if (includeParentsAndChildren || includeParents) {
			//Zotero.debug("Adding parent items to result set");
			var sql = "SELECT sourceItemID FROM itemAttachments "
				+ "WHERE itemID IN (SELECT itemID FROM " + tmpTable + ") "
					+ " AND sourceItemID IS NOT NULL "
				+ "UNION SELECT sourceItemID FROM itemNotes "
					+ "WHERE itemID IN (SELECT itemID FROM " + tmpTable + ")"
					+ " AND sourceItemID IS NOT NULL";
		}
		
		if (includeParentsAndChildren || includeChildren) {
			//Zotero.debug("Adding child items to result set");
			var childrenSQL = "SELECT itemID FROM itemAttachments WHERE "
				+ "sourceItemID IN (SELECT itemID FROM " + tmpTable + ") UNION "
				+ "SELECT itemID FROM itemNotes WHERE sourceItemID IN "
				+ "(SELECT itemID FROM " + tmpTable + ")";
				
			if (includeParentsAndChildren || includeParents) {
				sql += " UNION " + childrenSQL;
			}
			else {
				sql = childrenSQL;
			}
		}
		
		sql = "SELECT GROUP_CONCAT(itemID) FROM items WHERE itemID IN (" + sql + ")";
		var res = Zotero.DB.valueQuery(sql);
		var parentChildIDs = res ? res.split(",") : [];
		Zotero.DB.query("DROP TABLE " + tmpTable);
		Zotero.DB.commitTransaction();
		
		// Add parents and children to main ids
		if (parentChildIDs) {
			for (var i=0; i<parentChildIDs.length; i++) {
				var id = parentChildIDs[i];
				if (ids.indexOf(id) == -1) {
					ids.push(id);
				}
			}
		}
	}
	
	//Zotero.debug('Final result set');
	//Zotero.debug(ids);
	
	if (!ids || !ids.length) {
		return false;
	}
	
	if (asTempTable) {
		var table = Zotero.Search.idsToTempTable(ids);
		return table;
	}
	
	return ids;
}


Zotero.Search.prototype.serialize = function() {
	var obj = {
		primary: {
			id: this.id,
			libraryID: this.libraryID,
			key: this.key,
			dateAdded: this.dateAdded,
			dateModified: this.dateModified
		},
		fields: {
			name: this.name,
		},
		conditions: this.getSearchConditions()
	};
	return obj;
}


/*
 * Get the SQL string for the search
 */
Zotero.Search.prototype.getSQL = function(){
	if (!this._sql){
		this._buildQuery();
	}
	return this._sql;
}


Zotero.Search.prototype.getSQLParams = function(){
	if (!this._sql){
		this._buildQuery();
	}
	return this._sqlParams;
}


Zotero.Search.prototype._prepFieldChange = function (field) {
	if (!this._changed) {
		this._changed = {};
	}
	this._changed[field] = true;
	
	// Save a copy of the data before changing
	// TODO: only save previous data if search exists
	if (this.id && this.exists() && !this._previousData) {
		this._previousData = this.serialize();
	}
}


/*
 * Batch insert
 */
Zotero.Search.idsToTempTable = function (ids) {
	const N_COMBINED_INSERTS = 128;
	
	var tmpTable = "tmpSearchResults_" + Zotero.randomString(8);
	
	Zotero.DB.beginTransaction();
	
	var sql = "CREATE TEMPORARY TABLE " + tmpTable + " (itemID INT)";
	Zotero.DB.query(sql);
	
	var sql = "INSERT INTO " + tmpTable + " SELECT ? ";
	for (var i=0; i<(N_COMBINED_INSERTS-1); i++) {
		sql += "UNION SELECT ? ";
	}
	
	var insertStatement = Zotero.DB.getStatement(sql),
		n = ids.length;
	for (var i=0; i<n; i+=N_COMBINED_INSERTS) {
		for (var j=0; j<N_COMBINED_INSERTS; j++) {
			insertStatement.bindInt32Parameter(j, ids[i+j]);
		}
		try {
			insertStatement.execute();
		}
		catch (e) {
			throw (Zotero.DB.getLastErrorString());
		}
	}
	insertStatement.finalize();
	
	var nRemainingInserts = (n % N_COMBINED_INSERTS);
	var remainingInsertStart = n-nRemainingInserts-1;
	var sql = "INSERT INTO " + tmpTable + " SELECT ? ";
	for (var i=remainingInsertStart; i<n; i++) {
		sql += "UNION SELECT ? ";
	}
	
	var insertStatement = Zotero.DB.getStatement(sql);
	for (var j=remainingInsertStart; j<n; j++) {
		insertStatement.bindInt32Parameter(j-remainingInsertStart, ids[remainingInsertStart]);
	}
	
	var sql = "CREATE INDEX " + tmpTable + "_itemID ON " + tmpTable + "(itemID)";
	Zotero.DB.query(sql);
	
	Zotero.DB.commitTransaction();
	
	return tmpTable;
}


/*
 * Build the SQL query for the search
 */
Zotero.Search.prototype._buildQuery = function(){
	var sql = 'SELECT itemID FROM items';
	var sqlParams = [];
	// Separate ANY conditions for 'required' condition support
	var anySQL = '';
	var anySQLParams = [];
	
	var conditions = [];
	
	for (var i in this._conditions){
		var data = Zotero.SearchConditions.get(this._conditions[i]['condition']);
		
		// Has a table (or 'savedSearch', which doesn't have a table but isn't special)
		if (data.table || data.name == 'savedSearch' || data.name == 'tempTable') {
			conditions.push({
				name: data['name'],
				alias: data['name']!=this._conditions[i]['condition']
					? this._conditions[i]['condition'] : false,
				table: data['table'],
				field: data['field'],
				operator: this._conditions[i]['operator'],
				value: this._conditions[i]['value'],
				flags: data['flags'],
				required: this._conditions[i]['required']
			});
			
			this._hasPrimaryConditions = true;
		}
		
		// Handle special conditions
		else {
			switch (data['name']){
				case 'deleted':
					var deleted = this._conditions[i].operator == 'true';
					continue;
				
				case 'noChildren':
					var noChildren = this._conditions[i]['operator']=='true';
					continue;
				
				case 'includeParentsAndChildren':
					var includeParentsAndChildren = this._conditions[i]['operator'] == 'true';
					continue;
					
				case 'includeParents':
					var includeParents = this._conditions[i]['operator'] == 'true';
					continue;
				
				case 'includeChildren':
					var includeChildren = this._conditions[i]['operator'] == 'true';
					continue;
				
				case 'unfiled':
					this._conditions[i]['operator']
					var unfiled = this._conditions[i]['operator'] == 'true';
					continue;
				
				// Search subcollections
				case 'recursive':
					var recursive = this._conditions[i]['operator']=='true';
					continue;
				
				// Join mode ('any' or 'all')
				case 'joinMode':
					var joinMode = this._conditions[i]['operator'].toUpperCase();
					continue;
				
				case 'fulltextContent':
					// Handled in Search.search()
					continue;
				
				// For quicksearch block markers
				case 'blockStart':
					conditions.push({name:'blockStart'});
					continue;
				case 'blockEnd':
					conditions.push({name:'blockEnd'});
					continue;
			}
			
			throw ('Unhandled special condition ' + this._conditions[i]['condition']);
		}
	}
	
	// Exclude deleted items (and their child items) by default
	sql += " WHERE itemID " + (deleted ? "" : "NOT ") + "IN "
			+ "("
				+ "SELECT itemID FROM deletedItems "
				+ "UNION "
				+ "SELECT itemID FROM itemNotes "
				+ "WHERE sourceItemID IS NOT NULL AND "
				+ "sourceItemID IN (SELECT itemID FROM deletedItems) "
				+ "UNION "
				+ "SELECT itemID FROM itemAttachments "
				+ "WHERE sourceItemID IS NOT NULL AND "
				+ "sourceItemID IN (SELECT itemID FROM deletedItems) "
			+ ")";
	
	if (noChildren){
		sql += " AND (itemID NOT IN (SELECT itemID FROM itemNotes "
			+ "WHERE sourceItemID IS NOT NULL) AND itemID NOT IN "
			+ "(SELECT itemID FROM itemAttachments "
			+ "WHERE sourceItemID IS NOT NULL))";
	}
	
	if (unfiled) {
		sql += " AND (itemID NOT IN (SELECT itemID FROM collectionItems) "
			// Exclude children
			+ "AND itemID NOT IN "
			+ "(SELECT itemID FROM itemAttachments WHERE sourceItemID IS NOT NULL "
			+ "UNION SELECT itemID FROM itemNotes WHERE sourceItemID IS NOT NULL)"
			+ ")";
	}
	
	if (this._hasPrimaryConditions) {
		sql += " AND ";
		
		for each(var condition in conditions){
				var skipOperators = false;
				var openParens = 0;
				var condSQL = '';
				var selectOpenParens = 0;
				var condSelectSQL = '';
				var condSQLParams = [];
				
				//
				// Special table handling
				//
				if (condition['table']){
					switch (condition['table']){
						default:
							condSelectSQL += 'itemID '
							switch (condition['operator']){
								case 'isNot':
								case 'doesNotContain':
									condSelectSQL += 'NOT ';
									break;
							}
							condSelectSQL += 'IN (';
							selectOpenParens = 1;
							condSQL += 'SELECT itemID FROM ' +
								condition['table'] + ' WHERE (';
							openParens = 1;
					}
				}
				
				//
				// Special condition handling
				//
				switch (condition['name']){
					case 'field':
					case 'datefield':
					case 'numberfield':
						if (condition['alias']) {
							// Add base field
							condSQLParams.push(
								Zotero.ItemFields.getID(condition['alias'])
							);
							var typeFields = Zotero.ItemFields.getTypeFieldsFromBase(condition['alias']);
							if (typeFields) {
								condSQL += 'fieldID IN (?,';
								// Add type-specific fields
								for each(var fieldID in typeFields) {
									condSQL += '?,';
									condSQLParams.push(fieldID);
								}
								condSQL = condSQL.substr(0, condSQL.length - 1);
								condSQL += ') AND ';
							}
							else {
								condSQL += 'fieldID=? AND ';
							}
						}
						
						condSQL += "valueID IN (SELECT valueID FROM "
							+ "itemDataValues WHERE ";
						
						openParens++;
						break;
					
					case 'year':
						condSQLParams.push(Zotero.ItemFields.getID('date'));
						//Add base field
						var dateFields = Zotero.ItemFields.getTypeFieldsFromBase('date');
						if (dateFields) {
							condSQL += 'fieldID IN (?,';																
							// Add type-specific date fields (dateEnacted, dateDecided, issueDate)
							for each(var fieldID in dateFields) {
								condSQL += '?,';
								condSQLParams.push(fieldID);
							}
							condSQL = condSQL.substr(0, condSQL.length - 1);
							condSQL += ') AND ';
						}
					
						condSQL += "valueID IN (SELECT valueID FROM "
							+ "itemDataValues WHERE ";
						
						openParens++;
						break;
					
					case 'collection':
						var col;
						if (condition.value) {
							var lkh = Zotero.Collections.parseLibraryKeyHash(condition.value);
							if (lkh) {
								col = Zotero.Collections.getByLibraryAndKey(lkh.libraryID, lkh.key);
							}
						}
						if (!col) {
							var msg = "Collection " + condition.value + " specified in saved search doesn't exist";
							Zotero.debug(msg, 2);
							Zotero.log(msg, 'warning', 'chrome://zotero/content/xpcom/search.js');
							continue;
						}
						
						var q = ['?'];
						var p = [col.id];
						
						// Search descendent collections if recursive search
						if (recursive){
							var descendents = col.getDescendents(false, 'collection');
							if (descendents){
								for (var k in descendents){
									q.push('?');
									p.push({int:descendents[k]['id']});
								}
							}
						}
						
						condSQL += "collectionID IN (" + q.join() + ")";
						condSQLParams = condSQLParams.concat(p);
						
						skipOperators = true;
						break;
					
					case 'savedSearch':
						condSQL += "itemID ";
						if (condition['operator']=='isNot'){
							condSQL += "NOT ";
						}
						condSQL += "IN (";
						
						var search;
						if (condition.value) {
							var lkh = Zotero.Searches.parseLibraryKeyHash(condition.value);
							if (lkh) {
								search = Zotero.Searches.getByLibraryAndKey(lkh.libraryID, lkh.key);
							}
						}
						if (!search) {
							var msg = "Search " + condition.value + " specified in saved search doesn't exist";
							Zotero.debug(msg, 2);
							Zotero.log(msg, 'warning', 'chrome://zotero/content/xpcom/search.js');
							continue;
						}
						
						// Check if there are any post-search filters
						var hasFilter = search.hasPostSearchFilter();
						
						// This is an ugly and inefficient way of doing a
						// subsearch, but it's necessary if there are any
						// post-search filters (e.g. fulltext scanning) in the
						// subsearch
						//
						// DEBUG: it's possible there's a query length limit here
						// or that this slows things down with large libraries
						// -- should probably use a temporary table instead
						if (hasFilter){
							var subids = search.search();
							condSQL += subids.join();
						}
						// Otherwise just put the SQL in a subquery
						else {
							condSQL += search.getSQL();
							var subpar = search.getSQLParams();
							for (var k in subpar){
								condSQLParams.push(subpar[k]);
							}
						}
						condSQL += ")";
						
						skipOperators = true;
						break;
					
					case 'itemType':
						condSQL += "itemTypeID IN (SELECT itemTypeID FROM itemTypesCombined WHERE ";
						openParens++;
						break;
					
					case 'fileTypeID':
						var ftSQL = 'SELECT mimeType FROM fileTypeMimeTypes '
							+ 'WHERE fileTypeID IN ('
							+ 'SELECT fileTypeID FROM fileTypes WHERE '
							+ 'fileTypeID=?)';
						var patterns = Zotero.DB.columnQuery(ftSQL, { int: condition.value });
						if (patterns) {
							for each(str in patterns) {
								condSQL += 'mimeType LIKE ? OR ';
								condSQLParams.push(str + '%');
							}
							condSQL = condSQL.substring(0, condSQL.length - 4);
						}
						else {
							throw ("Invalid fileTypeID '" + condition.value + "' specified in search.js")
						}
						skipOperators = true;
						break;
					
					case 'tag':
						condSQL += "tagID IN (SELECT tagID FROM tags WHERE ";
						openParens++;
						break;
					
					case 'creator':
					case 'lastName':
						condSQL += "creatorID IN (SELECT creatorID FROM creators "
							+ "NATURAL JOIN creatorData WHERE ";
						openParens++;
						break;
					
					case 'childNote':
						condSQL += "itemID IN (SELECT sourceItemID FROM "
							+ "itemNotes WHERE ";
						openParens++;
						break;
					
					case 'fulltextWord':
						condSQL += "wordID IN (SELECT wordID FROM fulltextWords "
							+ "WHERE ";
						openParens++;
						break;
					
					case 'tempTable':
						if (!condition.value.match(/^[a-zA-Z0-9]+$/)) {
							throw ("Invalid temp table '" + condition.value + "'");
						}
						condSQL += "itemID IN (SELECT id FROM " + condition.value + ")";
						skipOperators = true;
						break;
						
					// For quicksearch blocks
					case 'blockStart':
					case 'blockEnd':
						skipOperators = true;
						break;
				}
				
				if (!skipOperators){
					// Special handling for date fields
					//
					// Note: We assume full datetimes are already UTC and don't
					// need to be handled specially
					if ((condition['name']=='dateAdded' ||
							condition['name']=='dateModified' ||
							condition['name']=='datefield') &&
							!Zotero.Date.isSQLDateTime(condition['value'])){
						
						// TODO: document these flags
						var parseDate = null;
						var alt = null;
						var useFreeform = null;
						
						switch (condition['operator']){
							case 'is':
							case 'isNot':
								var parseDate = true;
								var alt = '__';
								var useFreeform = true;
								break;
							
							case 'isBefore':
								var parseDate = true;
								var alt = '00';
								var useFreeform = false;
								break;
								
							case 'isAfter':
								var parseDate = true;
								// '__' used here just so the > string comparison
								// doesn't match dates in the specified year
								var alt = '__';
								var useFreeform = false;
								break;
								
							case 'isInTheLast':
								var parseDate = false;
								break;
								
							default:
								throw ('Invalid date field operator in search');
						}
						
						// Convert stored UTC dates to localtime
						//
						// It'd be nice not to deal with time zones here at all,
						// but otherwise searching for the date part of a field
						// stored as UTC that wraps midnight would be unsuccessful
						if (condition['name']=='dateAdded' ||
								condition['name']=='dateModified' ||
								condition['alias']=='accessDate'){
							condSQL += "DATE(" + condition['field'] + ", 'localtime')";
						}
						// Only use first (SQL) part of multipart dates
						else {
							condSQL += "SUBSTR(" + condition['field'] + ", 1, 10)";
						}
						
						if (parseDate){
							var go = false;
							var dateparts = Zotero.Date.strToDate(condition.value);
							
							// Search on SQL date -- underscore is
							// single-character wildcard
							//
							// If isBefore or isAfter, month and day fall back
							// to '00' so that a search for just a year works
							// (and no year will just not find anything)
							var sqldate = dateparts.year ?
								Zotero.Utilities.lpad(dateparts.year, '0', 4) : '____';
							sqldate += '-'
							sqldate += dateparts.month || dateparts.month === 0 ?
								Zotero.Utilities.lpad(dateparts.month + 1, '0', 2) : alt;
							sqldate += '-';
							sqldate += dateparts.day ?
								Zotero.Utilities.lpad(dateparts.day, '0', 2) : alt;
							
							if (sqldate!='____-__-__'){
								go = true;
								
								switch (condition['operator']){
									case 'is':
									case 'isNot':
										condSQL += ' LIKE ?';
										break;
									
									case 'isBefore':
										condSQL += '<?';
										condSQL += ' AND ' + condition['field'] +
											">'0000-00-00'";
										break;
										
									case 'isAfter':
										condSQL += '>?';
										break;
								}
								
								condSQLParams.push({string:sqldate});
							}
							
							// Search for any remaining parts individually
							if (useFreeform && dateparts['part']){
								go = true;
								var parts = dateparts['part'].split(' ');
								for each (var part in parts){
									condSQL += " AND SUBSTR(" + condition['field'] + ", 12, 100)";
									condSQL += " LIKE ?";
									condSQLParams.push('%' + part  + '%');
								}
							}
							
							// If neither part used, invalidate clause
							if (!go){
								condSQL += '=0';
							}
						}
						
						else {
							switch (condition['operator']){
								case 'isInTheLast':
									condSQL += ">DATE('NOW', 'localtime', ?)"; // e.g. ('NOW', '-10 DAYS')
									condSQLParams.push({string: '-' + condition['value']});
									break;
							}
						}
					}
					
					// Non-date fields
					else {
						switch (condition.operator) {
							// Cast strings as integers for < and > comparisons,
							// at least until 
							case 'isLessThan':
							case 'isGreaterThan':
								condSQL += "CAST(" + condition['field'] + " AS INT)";
								// Make sure either field is an integer or
								// converting to an integer and back to a string
								// yields the same result (i.e. it's numeric)
								var opAppend = " AND (TYPEOF("
									+ condition['field'] + ") = 'integer' OR "
									+ "CAST("
										+ "CAST(" + condition['field'] + " AS INT)"
									+ " AS STRING) = " + condition['field'] + ")"
								break;
								
							default:
								condSQL += condition['field'];
						}
						
						switch (condition['operator']){
							case 'contains':
							case 'doesNotContain': // excluded with NOT IN above
								condSQL += ' LIKE ?';
								// For fields with 'leftbound' flag, perform a
								// leftbound search even for 'contains' condition
								if (condition['flags'] &&
										condition['flags']['leftbound'] &&
										Zotero.Prefs.get('search.useLeftBound')) {
									condSQLParams.push(condition['value'] + '%');
								}
								else {
									condSQLParams.push('%' + condition['value'] + '%');
								}
								break;
								
							case 'is':
							case 'isNot': // excluded with NOT IN above
								// Automatically cast values which might
								// have been stored as integers
								if (condition.value && typeof condition.value == 'string'
										&& condition.value.match(/^[1-9]+[0-9]*$/)) {
									condSQL += ' LIKE ?';
								}
								else if (condition.value === null) {
									condSQL += ' IS NULL';
									break;
								}
								else {
									condSQL += '=?';
								}
								condSQLParams.push(condition['value']);
								break;
							
							case 'beginsWith':
								condSQL += ' LIKE ?';
								condSQLParams.push(condition['value'] + '%');
								break;
							
							case 'isLessThan':
								condSQL += '<?';
								condSQLParams.push({int:condition['value']});
								condSQL += opAppend;
								break;
								
							case 'isGreaterThan':
								condSQL += '>?';
								condSQLParams.push({int:condition['value']});
								condSQL += opAppend;
								break;
							
							// Next two only used with full datetimes
							case 'isBefore':
								condSQL += '<?';
								condSQLParams.push({string:condition['value']});
								break;
								
							case 'isAfter':
								condSQL += '>?';
								condSQLParams.push({string:condition['value']});
								break;
						}
					}
				}
				
				// Close open parentheses
				for (var k=openParens; k>0; k--){
					condSQL += ')';
				}
				
				if (includeParentsAndChildren || includeParents) {
					var parentSQL = "SELECT itemID FROM items WHERE "
						+ "itemID IN (SELECT sourceItemID FROM itemAttachments "
							+ "WHERE itemID IN (" + condSQL + ")) "
						+ "OR itemID IN (SELECT sourceItemID FROM itemNotes "
							+ "WHERE itemID IN (" + condSQL + ")) ";
					var parentSQLParams = condSQLParams.concat(condSQLParams);
				}
				
				if (includeParentsAndChildren || includeChildren) {
					var childrenSQL = "SELECT itemID FROM itemAttachments WHERE "
						+ "sourceItemID IN (" + condSQL + ") UNION "
						+ "SELECT itemID FROM itemNotes "
						+ "WHERE sourceItemID IN (" + condSQL + ")";
					var childSQLParams = condSQLParams.concat(condSQLParams);
				}
				
				if (includeParentsAndChildren || includeParents) {
					condSQL += " UNION " + parentSQL;
					condSQLParams = condSQLParams.concat(parentSQLParams);
				}
				
				if (includeParentsAndChildren || includeChildren) {
					condSQL += " UNION " + childrenSQL;
					condSQLParams = condSQLParams.concat(childSQLParams);
				}
				
				condSQL = condSelectSQL + condSQL;
				
				// Close open parentheses
				for (var k=selectOpenParens; k>0; k--) {
					condSQL += ')';
				}
				
				// Little hack to support multiple quicksearch words
				if (condition['name'] == 'blockStart') {
					var inQS = true;
					var qsSQL = '';
					var qsParams = [];
					continue;
				}
				else if (condition['name'] == 'blockEnd') {
					inQS = false;
					// Strip ' OR ' from last condition
					qsSQL = qsSQL.substring(0, qsSQL.length-4);
					
					// Add to existing quicksearch words
					if (!quicksearchSQLSet) {
						var quicksearchSQLSet = [];
						var quicksearchParamsSet = [];
					}
					quicksearchSQLSet.push(qsSQL);
					quicksearchParamsSet.push(qsParams);
				}
				else if (inQS) {
					qsSQL += condSQL + ' OR ';
					qsParams = qsParams.concat(condSQLParams);
				}
				// Keep non-required conditions separate if in ANY mode
				else if (!condition['required'] && joinMode == 'ANY') {
					anySQL += condSQL + ' OR ';
					anySQLParams = anySQLParams.concat(condSQLParams);
				}
				else {
					condSQL += ' AND ';
					sql += condSQL;
					sqlParams = sqlParams.concat(condSQLParams);
				}
		}
		
		// Add on ANY conditions
		if (anySQL){
			sql += '(' + anySQL;
			sqlParams = sqlParams.concat(anySQLParams);
			sql = sql.substring(0, sql.length-4); // remove last ' OR '
			sql += ')';
		}
		else {
			sql = sql.substring(0, sql.length-5); // remove last ' AND '
		}
		
		// Add on quicksearch conditions
		if (quicksearchSQLSet) {
			sql = "SELECT itemID FROM items WHERE itemID IN (" + sql + ") "
				+ "AND ((" + quicksearchSQLSet.join(') AND (') + "))";
			
			for (var k=0; k<quicksearchParamsSet.length; k++) {
				sqlParams = sqlParams.concat(quicksearchParamsSet[k]);
			}
		}
	}
	
	this._sql = sql;
	this._sqlParams = sqlParams.length ? sqlParams : null;
}


Zotero.Search.prototype._generateKey = function () {
	return Zotero.ID.getKey();
}



Zotero.Searches = new function(){
	Zotero.DataObjects.apply(this, ['search', 'searches', 'savedSearch', 'savedSearches']);
	this.constructor.prototype = new Zotero.DataObjects();
	
	this.get = get;
	this.erase = erase;
	
	
	/**
	 * Retrieve a saved search
	 *
	 * @param	int				id		savedSearchID
	 * @return	object|bool				Zotero.Search object,
	 *										or false if it doesn't exist
	 */
	function get(id) {
		var sql = "SELECT COUNT(*) FROM savedSearches WHERE savedSearchID=?";
		if (Zotero.DB.valueQuery(sql, id)) {
			var search = new Zotero.Search;
			search.id = id;
			return search;
		}
		return false;
	}
	
	
	/**
	 * Returns an array of Zotero.Search objects, ordered by name
	 *
	 * @param	{Integer|null}	[libraryID=null]
	 */
	this.getAll = function (libraryID) {
		var sql = "SELECT savedSearchID AS id, savedSearchName AS name "
				+ "FROM savedSearches WHERE libraryID";
		if (libraryID) {
			sql += "=?";
			var params = [libraryID];
		}
		else {
			sql += " IS NULL";
			var params = null;
		}
		sql += " ORDER BY name COLLATE NOCASE";
		var rows = Zotero.DB.query(sql, params);
		if (!rows) {
			return [];
		}
		
		// Do proper collation sort
		var collation = Zotero.getLocaleCollation();
		rows.sort(function (a, b) {
			return collation.compareString(1, a.name, b.name);
		});
		
		var searches = [];
		for each(var row in rows) {
			var search = new Zotero.Search;
			search.id = row.id;
			searches.push(search);
		}
		return searches;
	}
	
	
	/*
	 * Delete a given saved search from the DB
	 */
	function erase(ids) {
		ids = Zotero.flattenArguments(ids);
		var notifierData = {};
		
		Zotero.DB.beginTransaction();
		for each(var id in ids) {
			var search = new Zotero.Search;
			search.id = id;
			notifierData[id] = { old: search.serialize() };
			
			var sql = "DELETE FROM savedSearchConditions WHERE savedSearchID=?";
			Zotero.DB.query(sql, id);
			
			var sql = "DELETE FROM savedSearches WHERE savedSearchID=?";
			Zotero.DB.query(sql, id);
		}
		Zotero.DB.commitTransaction();
		
		Zotero.Notifier.trigger('delete', 'search', ids, notifierData);
	}
}



Zotero.SearchConditions = new function(){
	this.get = get;
	this.getStandardConditions = getStandardConditions;
	this.hasOperator = hasOperator;
	this.getLocalizedName = getLocalizedName;
	this.parseSearchString = parseSearchString;
	this.parseCondition = parseCondition;
	
	var _initialized = false;
	var _conditions = {};
	var _standardConditions = [];
	
	var self = this;
	
	/*
	 * Define the advanced search operators
	 */
	var _operators = {
		// Standard -- these need to match those in zoterosearch.xml
		is: true,
		isNot: true,
		beginsWith: true,
		contains: true,
		doesNotContain: true,
		isLessThan: true,
		isGreaterThan: true,
		isBefore: true,
		isAfter: true,
		isInTheLast: true,
		
		// Special
		any: true,
		all: true,
		true: true,
		false: true
	};
	
	
	/*
	 * Define and set up the available advanced search conditions
	 *
	 * Flags:
	 *  - special (don't show in search window menu)
	 *  - template (special handling)
	 *  - noLoad (can't load from saved search)
	 */
	function _init(){
		var conditions = [
			//
			// Special conditions
			//
			
			
			{
				name: 'deleted',
				operators: {
					true: true,
					false: true
				}
			},
			
			// Don't include child items
			{
				name: 'noChildren',
				operators: {
					true: true,
					false: true
				}
			},
			
			{
				name: 'unfiled',
				operators: {
					true: true,
					false: true
				}
			},
			
			{
				name: 'includeParentsAndChildren',
				operators: {
					true: true,
					false: true
				}
			},
			
			{
				name: 'includeParents',
				operators: {
					true: true,
					false: true
				}
			},
			
			{
				name: 'includeChildren',
				operators: {
					true: true,
					false: true
				}
			},
			
			// Search recursively within collections
			{
				name: 'recursive',
				operators: {
					true: true,
					false: true
				}
			},
			
			// Join mode
			{
				name: 'joinMode',
				operators: {
					any: true,
					all: true
				}
			},
			
			{
				name: 'quicksearch-titleCreatorYear',
				operators: {
					is: true,
					isNot: true,
					contains: true,
					doesNotContain: true
				},
				noLoad: true
			},
			
			{
				name: 'quicksearch-fields',
				operators: {
					is: true,
					isNot: true,
					contains: true,
					doesNotContain: true
				},
				noLoad: true
			},
			
			{
				name: 'quicksearch-everything',
				operators: {
					is: true,
					isNot: true,
					contains: true,
					doesNotContain: true
				},
				noLoad: true
			},
			
			// Deprecated
			{
				name: 'quicksearch',
				operators: {
					is: true,
					isNot: true,
					contains: true,
					doesNotContain: true
				},
				noLoad: true
			},
			
			// Quicksearch block markers
			{
				name: 'blockStart',
				noLoad: true
			},
			
			{
				name: 'blockEnd',
				noLoad: true
			},
			
			// Shortcuts for adding collections and searches by id
			{
				name: 'collectionID',
				operators: {
					is: true,
					isNot: true
				},
				noLoad: true
			},
			
			{
				name: 'savedSearchID',
				operators: {
					is: true,
					isNot: true
				},
				noLoad: true
			},
			
			
			//
			// Standard conditions
			//
			
			// Collection id to search within
			{
				name: 'collection',
				operators: {
					is: true,
					isNot: true
				},
				table: 'collectionItems',
				field: 'collectionID'
			},
			
			// Saved search to search within
			{
				name: 'savedSearch',
				operators: {
					is: true,
					isNot: true
				},
				special: false
			},
			
			{
				name: 'dateAdded',
				operators: {
					is: true,
					isNot: true,
					isBefore: true,
					isAfter: true,
					isInTheLast: true
				},
				table: 'items',
				field: 'dateAdded'
			},
			
			{
				name: 'dateModified',
				operators: {
					is: true,
					isNot: true,
					isBefore: true,
					isAfter: true,
					isInTheLast: true
				},
				table: 'items',
				field: 'dateModified'
			},
			
			// Deprecated
			{
				name: 'itemTypeID',
				operators: {
					is: true,
					isNot: true
				},
				table: 'items',
				field: 'itemTypeID',
				special: true
			},
			
			{
				name: 'itemType',
				operators: {
					is: true,
					isNot: true
				},
				table: 'items',
				field: 'typeName'
			},
			
			{
				name: 'fileTypeID',
				operators: {
					is: true,
					isNot: true
				},
				table: 'itemAttachments',
				field: 'fileTypeID'
			},
			
			{
				name: 'tagID',
				operators: {
					is: true,
					isNot: true
				},
				table: 'itemTags',
				field: 'tagID',
				special: true
			},
			
			{
				name: 'tag',
				operators: {
					is: true,
					isNot: true,
					contains: true,
					doesNotContain: true
				},
				table: 'itemTags',
				field: 'name'
			},
			
			{
				name: 'note',
				operators: {
					contains: true,
					doesNotContain: true
				},
				table: 'itemNotes',
				field: 'note'
			},
			
			{
				name: 'childNote',
				operators: {
					contains: true,
					doesNotContain: true
				},
				table: 'items',
				field: 'note'
			},
			
			{
				name: 'creator',
				operators: {
					is: true,
					isNot: true,
					contains: true,
					doesNotContain: true
				},
				table: 'itemCreators',
				field: "TRIM(firstName || ' ' || lastName)"
			},
			
			{
				name: 'lastName',
				operators: {
					is: true,
					isNot: true,
					contains: true,
					doesNotContain: true
				},
				table: 'itemCreators',
				field: 'lastName',
				special: true
			},
			
			{
				name: 'field',
				operators: {
					is: true,
					isNot: true,
					contains: true,
					doesNotContain: true
				},
				table: 'itemData',
				field: 'value',
				aliases: Zotero.DB.columnQuery("SELECT fieldName FROM fieldsCombined " +
					"WHERE fieldName NOT IN ('accessDate', 'date', 'pages', " +
					"'section','seriesNumber','issue')"),
				template: true // mark for special handling
			},
			
			{
				name: 'datefield',
				operators: {
					is: true,
					isNot: true,
					isBefore: true,
					isAfter: true,
					isInTheLast: true
				},
				table: 'itemData',
				field: 'value',
				aliases: ['accessDate', 'date', 'dateDue', 'accepted'], // TEMP - NSF
				template: true // mark for special handling
			},
			
			{
				name: 'year',
				operators: {
					is: true,
					isNot: true,
					contains: true,
					doesNotContain: true
				},
				table: 'itemData',
				field: 'SUBSTR(value, 1, 4)',
				special: true
			},
			
			{
				name: 'numberfield',
				operators: {
					is: true,
					isNot: true,
					contains: true,
					doesNotContain: true,
					isLessThan: true,
					isGreaterThan: true
				},
				table: 'itemData',
				field: 'value',
				aliases: ['pages', 'section', 'seriesNumber','issue'],
				template: true // mark for special handling
			},
			
			{
				name: 'libraryID',
				operators: {
					is: true,
					isNot: true
				},
				table: 'items',
				field: 'libraryID',
				special: true,
				noLoad: true
			},
			
			{
				name: 'key',
				operators: {
					is: true,
					isNot: true,
					beginsWith: true
				},
				table: 'items',
				field: 'key',
				special: true,
				noLoad: true
			},
			
			{
				name: 'annotation',
				operators: {
					contains: true,
					doesNotContain: true
				},
				table: 'annotations',
				field: 'text'
			},
			
			{
				name: 'fulltextWord',
				operators: {
					contains: true,
					doesNotContain: true
				},
				table: 'fulltextItemWords',
				field: 'word',
				flags: {
					leftbound: true
				},
				special: true
			},
			
			{
				name: 'fulltextContent',
				operators: {
					contains: true,
					doesNotContain: true
				},
				special: false
			},
			
			{
				name: 'tempTable',
				operators: {
					is: true
				}
			}
		];
		
		// Index conditions by name and aliases
		for (var i in conditions) {
			_conditions[conditions[i]['name']] = conditions[i];
			if (conditions[i]['aliases']) {
				for (var j in conditions[i]['aliases']) {
					// TEMP - NSF
					switch (conditions[i]['aliases'][j]) {
						case 'dateDue':
						case 'accepted':
							if (!Zotero.ItemTypes.getID('nsfReviewer')) {
								continue;
							}
					}
					_conditions[conditions[i]['aliases'][j]] = conditions[i];
				}
			}
			_conditions[conditions[i]['name']] = conditions[i];
		}
		
		var sortKeys = [];
		var sortValues = [];
		
		var baseMappedFields = Zotero.ItemFields.getBaseMappedFields();
		
		// Separate standard conditions for menu display
		for (var i in _conditions){
			var fieldID = false;
			if (['field', 'datefield', 'numberfield'].indexOf(_conditions[i]['name']) != -1) {
				fieldID = Zotero.ItemFields.getID(i);
			}
			
			// If explicitly special...
			if (_conditions[i]['special'] ||
				// or a template master (e.g. 'field')...
				(_conditions[i]['template'] && i==_conditions[i]['name']) ||
				// or no table and not explicitly unspecial...
				(!_conditions[i]['table'] &&
					typeof _conditions[i]['special'] == 'undefined') ||
				// or field is a type-specific version of a base field...
				(fieldID && baseMappedFields.indexOf(fieldID) != -1)) {
				// ...then skip
				continue;
			}
			
			var localized = self.getLocalizedName(i);
			
			sortKeys.push(localized);
			sortValues[localized] = {
				name: i,
				localized: localized,
				operators: _conditions[i]['operators'],
				flags: _conditions[i]['flags']
			};
		}
		
		// Alphabetize by localized name
		// TODO: locale collation sort
		sortKeys = sortKeys.sort();
		for each(var i in sortKeys){
			_standardConditions.push(sortValues[i]);
		}
		
		_initialized = true;
	}
	
	
	/*
	 * Get condition data
	 */
	function get(condition){
		if (!_initialized){
			_init();
		}
		
		return _conditions[condition];
	}
	
	
	/*
	 * Returns array of possible conditions
	 *
	 * Does not include special conditions, only ones that would show in a drop-down list
	 */
	function getStandardConditions(){
		if (!_initialized){
			_init();
		}
		
		// TODO: return copy instead
		return _standardConditions;
	}
	
	
	/*
	 * Check if an operator is valid for a given condition
	 */
	function hasOperator(condition, operator){
		if (!_initialized){
			_init();
		}
		
		var [condition, mode] = this.parseCondition(condition);
		
		if (!_conditions[condition]){
			throw ("Invalid condition '" + condition + "' in hasOperator()");
		}
		
		if (!operator && typeof _conditions[condition]['operators'] == 'undefined'){
			return true;
		}
		
		return !!_conditions[condition]['operators'][operator];
	}
	
	
	function getLocalizedName(str) {
		// TEMP
		if (str == 'itemType') {
			str = 'itemTypeID';
		}
		
		try {
			return Zotero.getString('searchConditions.' + str)
		}
		catch (e) {
			return Zotero.ItemFields.getLocalizedString(null, str);
		}
	}
	
	
	/*
	 * Parses a search into words and "double-quoted phrases"
	 *
	 * Also strips unpaired quotes at the beginning and end of words
	 *
	 * Returns array of objects containing 'text' and 'inQuotes'
	 */
	function parseSearchString(str) {
		var parts = str.split(/\s*("[^"]*")\s*|"\s|\s"|^"|"$|'\s|\s'|^'|'$|\s/m);
		var parsed = [];
		
		for (var i in parts) {
			var part = parts[i];
			if (!part || !part.length) {
				continue;
			}
			
			if (part.charAt(0)=='"' && part.charAt(part.length-1)=='"') {
				parsed.push({
					text: part.substring(1, part.length-1),
					inQuotes: true
				});
			}
			else {
				parsed.push({
					text: part,
					inQuotes: false
				});
			}
		}
		
		return parsed;
	}
	
	
	function parseCondition(condition){
		var mode = false;
		var pos = condition.indexOf('/');
		if (pos != -1){
			mode = condition.substr(pos+1);
			condition = condition.substr(0, pos);
		}
		
		return [condition, mode];
	}
	
	
	this.reload = function () {
		_initialized = false;
		_conditions = {};
		_standardConditions = [];
	}
}
