Просмотр файла esoTalk-1.0.0g4/core/models/ETSearchModel.class.php

Размер файла: 31.05Kb
<?php
// Copyright 2011 Toby Zerner, Simon Zerner
// This file is part of esoTalk. Please see the included license file for usage information.

if (!defined("IN_ESOTALK")) exit;

/**
 * A model which provides functions to perform searches for conversations. Handles the implementation
 * of gambits, and does search optimization.
 *
 * Searches are performed by the following steps:
 * 1. Call getConversationIDs with a list of channel IDs to show results from and a search string.
 * 2. The search string is parsed and split into terms. When a term is matched to a gambit, the
 *    gambit's callback function is called.
 * 3. Callback functions add conversation ID filters to narrow the range of conversations being
 *    searched, or may alter other parts of the search query.
 * 4. Using the applied ID filters, a final list of conversation IDs is retrieved and returned.
 * 5. Call getResults with this list, and full details are retireved for each of the conversations.
 *
 * @package esoTalk
 */
class ETSearchModel extends ETModel {


/**
 * An array of functional gambits. Each gambit is an array(callback, condition)
 * @var array
 * @see addGambit
 */
protected static $gambits = array();


/**
 * An array of aliases. An alias is a string of text which is just shorthand for a more complex
 * gambit. Each alias is an array(term, replacement term)
 * @var array
 * @see addAlias
 */
protected static $aliases = array();


/**
 * Whether or not there are more results for the most recent search than what was returned.
 * @var bool
 */
protected $areMoreResults = false;


/**
 * The SQL query object used to construct a query that retrieves a list of matching conversation IDs.
 * @var ETSQLQuery
 */
public $sql;


/**
 * An array of converastion ID filters that should be run before querying the conversations table
 * for a final list of conversation IDs.
 * @var array
 * @see addIDFilter
 */
protected $idFilters = array();


/**
 * An array of fields to order the conversation IDs by.
 * @var array
 */
protected $orderBy = array();


/**
 * Whether or not the direction in the $orderBy fields should be reversed.
 * @var bool
 */
public $orderReverse = false;


/**
 * Whether or not the direction in the $orderBy fields should be reversed.
 * @var bool
 */
public $limit = false;


/**
 * Whether or not to include ignored conversations in the results.
 * @var bool
 */
public $includeIgnored = false;


/**
 * An array of fulltext keywords to filter the results by.
 * @var array
 */
public $fulltext = array();


/**
 * Class constructor. Sets up the inherited model functions to handle data in the search table
 * (used for logging search activity -> flood control.)
 *
 * @return void
 */
public function __construct()
{
	parent::__construct("search");
}


/**
 * Add a gambit to the collection. When a search term is matched to a gambit, the specified
 * callback function will be called. A match is determined by the return value of running
 * $condition through eval().
 *
 * @param string $condition The condition to run through eval() to determine a match.
 * 		$term represents the search term, in lowercase, in the eval() context. The condition
 * 		should return a boolean value: true means a match, false means no match.
 * 		Example: return $term == "sticky";
 * @param array $function The function to call if the gambit is matched. Function will be called
 * 		with parameters callback($sender, $term, $negate).
 * @return void
 */
public static function addGambit($condition, $function)
{
	self::$gambits[] = array($condition, $function);
}


/**
 * Add an alias for another gambit to the collection. When a search term is matched
 * to an alias, it will be interpreted as $realTerm.
 *
 * @param string $term The alias term.
 * @param string $realTerm The replacement term.
 * @return void
 */
public static function addAlias($term, $realTerm)
{
	self::$aliases[$term] = $realTerm;
}


/**
 * Add an SQL query to be run before the conversations table is queried for the final list of
 * conversation IDs. The query should return a list of conversation IDs; the results then will be
 * limited to conversations matching this list of IDs.
 *
 * See some of the default gambits for examples.
 *
 * @param ETSQLQuery $sql The SQL query that will return a list of matching conversation IDs.
 * @param bool $negate If set to true, the returned conversation IDs will be blacklisted.
 * @return void
 */
public function addIDFilter($sql, $negate = false)
{
	$this->idFilters[] = array($sql, $negate);
}


/**
 * Add a term to include in a fulltext search.
 *
 * @param string $term The term.
 * @return void
 */
public function fulltext($term)
{
	$this->fulltext[] = $term;
}


/**
 * Apply an order to the search results. This function will ensure that a direction (ASC|DESC) is
 * at the end.
 *
 * @param string $order The field to order the results by.
 * @return void
 */
public function orderBy($order)
{
	$direction = substr($order, strrpos($order, " ") + 1);
	if ($direction != "ASC" and $direction != "DESC") $order .= " ASC";
	$this->orderBy[] = $order;
}


/**
 * Apply a custom limit to the number of search results returned.
 *
 * @param int $limit The limit.
 * @return void
 */
public function limit($limit)
{
	$this->limit = $limit;
}


/**
 * Reset instance variables.
 *
 * @return void
 */
protected function reset()
{
	$this->resultCount = 0;
	$this->areMoreResults = false;
	$this->sql = null;
	$this->idFilters = array();
	$this->orderBy = array();
	$this->orderReverse = false;
	$this->limit = false;
	$this->includeIgnored = false;
	$this->fulltext = array();
}


/**
 * Determines whether or not the user is "flooding" the search system, based on the number of searches
 * they have performed in the last minute.
 *
 * @return bool|int If the user is not flooding, returns false, but if they are, returned the number
 * 		of seconds until they can perform another search.
 */
public function isFlooding()
{
	if (C("esoTalk.search.searchesPerMinute") <= 0) return false;
	$time = time();
	$period = 60;

	// If we have a record of their searches in the session, check how many searches they've performed in the last minute.
	$searches = ET::$session->get("searches");
	if (!empty($searches)) {

		// Clean anything older than $period seconds out of the searches array.
		foreach ($searches as $k => $v) {
			if ($v < $time - $period) unset($searches[$k]);
		}

		// Have they performed >= [searchesPerMinute] searches in the last minute? If so, they are flooding.
		if (count($searches) >= C("esoTalk.search.searchesPerMinute"))
			return $period - $time + min($searches);
	}

	// However, if we don't have a record in the session, query the database searches table.
	else {

		// Get the user's IP address.
		$ip = (int)ip2long(ET::$session->ip);

		// Have they performed >= $config["searchesPerMinute"] searches in the last minute?
		$sql = ET::SQL()
			->select("COUNT(ip)")
			->from("search")
			->where("type='conversations'")
			->where("ip=:ip")->bind(":ip", $ip)
			->where("time>:time")->bind(":time", $time - $period);

		if ($sql->exec()->result() >= C("esoTalk.search.searchesPerMinute"))
			return $period;

		// Log this search in the searches table.
		ET::SQL()->insert("search")->set("type", "conversations")->set("ip", $ip)->set("time", $time)->exec();

		// Proactively clean the searches table of searches older than $period seconds.
		ET::SQL()->delete()->from("search")->where("type", "conversations")->where("time<:time")->bind(":time", $time - $period)->exec();
	}

	// Log this search in the session array.
	$searches[] = $time;
	ET::$session->store("searches", $searches);

	return false;
}


/**
 * Deconstruct a search string and return a list of conversation IDs that fulfill it.
 *
 * @param array $channelIDs A list of channel IDs to include results from.
 * @param string $searchString The search string to deconstruct and find matching conversations.
 * @param bool $orderBySticky Whether or not to put stickied conversations at the top.
 * @return array|bool An array of matching conversation IDs, or false if there are none.
 */
public function getConversationIDs($channelIDs = array(), $searchString = "", $orderBySticky = false)
{
	$this->reset();

	$this->trigger("getConversationIDsBefore", array(&$channelIDs, &$searchString, &$orderBySticky));

	if ($searchString and ($seconds = $this->isFlooding())) {
		$this->error("search", sprintf(T("message.waitToSearch"), $seconds));
		return false;
	}

	// Initialize the SQL query that will return the resulting conversation IDs.
	$this->sql = ET::SQL()->select("c.conversationId")->from("conversation c");

	// Only get conversations in the specified channels.
	if ($channelIDs) {
		$this->sql->where("c.channelId IN (:channelIds)")->bind(":channelIds", $channelIDs);
	}

	// Process the search string into individial terms. Replace all "-" signs with "+!", and then
	// split the string by "+". Negated terms will then be prefixed with "!". Only keep the first
	// 5 terms, just to keep the load on the database down!
	$terms = !empty($searchString) ? explode("+", strtolower(str_replace("-", "+!", trim($searchString, " +-")))) : array();
	$terms = array_slice(array_filter($terms), 0, 5);

	// Take each term, match it with a gambit, and execute the gambit's function.
	foreach ($terms as $term) {

		// Are we dealing with a negated search term, ie. prefixed with a "!"?
		$term = trim($term);
		if ($negate = ($term[0] == "!")) $term = trim($term, "! ");

		if ($term[0] == "#") {
			$term = ltrim($term, "#");

			// If the term is an alias, translate it into the appropriate gambit.
			if (array_key_exists($term, self::$aliases)) $term = self::$aliases[$term];

			// Find a matching gambit by evaluating each gambit's condition, and run its callback function.
			foreach (self::$gambits as $gambit) {
				list($condition, $function) = $gambit;
				if (eval($condition)) {
					call_user_func_array($function, array(&$this, $term, $negate));
					continue 2;
				}
			}
		}

		// If we didn't find a gambit, use this term as a fulltext term.
		if ($negate) $term = "-".str_replace(" ", " -", $term);
		$this->fulltext($term);
	}

	// If an order for the search results has not been specified, apply a default.
	// Order by sticky and then last post time.
	if (!count($this->orderBy)) {
		if ($orderBySticky) $this->orderBy("c.sticky DESC");
		$this->orderBy("c.lastPostTime DESC");
	}

	// If we're not including ignored conversations, add a where predicate to the query to exclude them.
	if (!$this->includeIgnored and ET::$session->user) {
		$q = ET::SQL()->select("conversationId")->from("member_conversation")->where("type='member'")->where("id=:memberIdIgnored")->where("ignored=1")->get();
		$this->sql->where("conversationId NOT IN ($q)")->bind(":memberIdIgnored", ET::$session->userId);
	}

	// Now we need to loop through the ID filters and run them one-by-one. When a query returns a selection
	// of conversation IDs, subsequent queries are restricted to filtering those conversation IDs,
	// and so on, until we have a list of IDs to pass to the final query.
	$goodConversationIDs = array();
	$badConversationIDs = array();
	$idCondition = "";
	foreach ($this->idFilters as $v) {
		list($sql, $negate) = $v;

		// Apply the list of good IDs to the query.
		$sql->where($idCondition);

		// Get the list of conversation IDs so that the next condition can use it in its query.
		$result = $sql->exec();
		$ids = array();
		while ($row = $result->nextRow()) $ids[] = (int)reset($row);

		// If this condition is negated, then add the IDs to the list of bad conversations.
		// If the condition is not negated, set the list of good conversations to the IDs, provided there are some.
		if ($negate) $badConversationIDs = array_merge($badConversationIDs, $ids);
		elseif (count($ids)) $goodConversationIDs = $ids;
		else return false;

		// Strip bad conversation IDs from the list of good conversation IDs.
		if (count($goodConversationIDs)) {
			$goodConversationIds = array_diff($goodConversationIDs, $badConversationIDs);
			if (!count($goodConversationIDs)) return false;
		}

		// This will be the condition for the next query that restricts or eliminates conversation IDs.
		if (count($goodConversationIDs))
			$idCondition = "conversationId IN (".implode(",", $goodConversationIDs).")";
		elseif (count($badConversationIDs))
			$idCondition = "conversationId NOT IN (".implode(",", $badConversationIDs).")";
	}

	// Reverse the order if necessary - swap DESC and ASC.
	if ($this->orderReverse) {
		foreach ($this->orderBy as $k => $v)
			$this->orderBy[$k] = strtr($this->orderBy[$k], array("DESC" => "ASC", "ASC" => "DESC"));
	}

	// Now check if there are any fulltext keywords to filter by.
	if (count($this->fulltext)) {

		// Run a query against the posts table to get matching conversation IDs.
		$fulltextString = implode(" ", $this->fulltext);
		$fulltextQuery = ET::SQL()
			->select("DISTINCT conversationId")
			->from("post")
			->where("MATCH (title, content) AGAINST (:fulltext IN BOOLEAN MODE)")
			->where($idCondition)
			->orderBy("MATCH (title, content) AGAINST (:fulltextOrder) DESC")
			->bind(":fulltext", $fulltextString)
			->bind(":fulltextOrder", $fulltextString);

		$this->trigger("fulltext", array($fulltextQuery, $this->fulltext));

		$result = $fulltextQuery->exec();
		$ids = array();
		while ($row = $result->nextRow()) $ids[] = reset($row);

		// Change the ID condition to this list of matching IDs, and order by relevance.
		if (count($ids)) $idCondition = "conversationId IN (".implode(",", $ids).")";
		else return false;
		$this->orderBy = array("FIELD(c.conversationId,".implode(",", $ids).")");
	}

	// Set a default limit if none has previously been set.
	if (!$this->limit) $this->limit = C("esoTalk.search.limit");

	// Finish constructing the final query using the ID whitelist/blacklist we've come up with.
	// Get one more result than we'll actually need so we can see if there are "more results."
	if ($idCondition) $this->sql->where($idCondition);
	$this->sql->orderBy($this->orderBy)->limit($this->limit + 1);

	// Make sure conversations that the user isn't allowed to see are filtered out.
	ET::conversationModel()->addAllowedPredicate($this->sql);

	// Execute the query, and collect the final set of conversation IDs.
	$result = $this->sql->exec();
	$conversationIDs = array();
	while ($row = $result->nextRow()) $conversationIDs[] = reset($row);

	// If there's one more result than we actually need, indicate that there are "more results."
	if (count($conversationIDs) == $this->limit + 1) {
		array_pop($conversationIDs);
		if ($this->limit < C("esoTalk.search.limitMax")) $this->areMoreResults = true;
	}

	return count($conversationIDs) ? $conversationIDs : false;
}


/**
 * Get a full list of conversation details for a list of conversation IDs.
 *
 * @param array $conversationIDs The list of conversation IDs to fetch details for.
 * @param bool $checkForPermission Whether or not to add a check onto the query to make sure the
 * 		user has permission to view all of the conversations.
 */
public function getResults($conversationIDs, $checkForPermission = false)
{
	// Construct a query to get details for all of the specified conversations.
	$sql = ET::SQL()
		->select("s.*") // Select the status fields first so that the conversation fields take precedence.
		->select("c.*")
		->select("sm.memberId", "startMemberId")
		->select("sm.username", "startMember")
		->select("sm.avatarFormat", "startMemberAvatarFormat")
		->select("lpm.memberId", "lastPostMemberId")
		->select("lpm.username", "lastPostMember")
		->select("lpm.email", "lastPostMemberEmail")
		->select("lpm.avatarFormat", "lastPostMemberAvatarFormat")
		->select("IF((IF(c.lastPostTime IS NOT NULL,c.lastPostTime,c.startTime)>:markedAsRead AND (s.lastRead IS NULL OR s.lastRead<c.countPosts)),(c.countPosts - IF(s.lastRead IS NULL,0,s.lastRead)),0)", "unread")
		->select("p.content", "firstPost")
		->from("conversation c")
		->from("member_conversation s", "s.conversationId=c.conversationId AND s.type='member' AND s.id=:memberId", "left")
		->from("member sm", "c.startMemberId=sm.memberId", "left")
		->from("member lpm", "c.lastPostMemberId=lpm.memberId", "left")
		->from("channel ch", "c.channelId=ch.channelId", "left")
		->from("post p", "c.sticky AND c.conversationId=p.conversationId AND c.startTime=p.time", "left")
		->bind(":markedAsRead", ET::$session->preference("markedAllConversationsAsRead"))
		->bind(":memberId", ET::$session->userId);

	// If we need to, filter out all conversations that the user isn't allowed to see.
	if ($checkForPermission) ET::conversationModel()->addAllowedPredicate($sql);

	// Add a labels column to the query.
	ET::conversationModel()->addLabels($sql);

	// Limit the results to the specified conversation IDs
	$sql->where("c.conversationId IN (:conversationIds)")->orderBy("FIELD(c.conversationId,:conversationIdsOrder)");
	$sql->bind(":conversationIds", $conversationIDs, PDO::PARAM_INT);
	$sql->bind(":conversationIdsOrder", $conversationIDs, PDO::PARAM_INT);

	$this->trigger("beforeGetResults", array(&$sql));

	// Execute the query and put the details of the conversations into an array.
	$result = $sql->exec();
	$results = array();
	$model = ET::conversationModel();

	while ($row = $result->nextRow()) {

		// Expand the comma-separated label flags into a workable array of active labels.
		$row["labels"] = $model->expandLabels($row["labels"]);

		$row["replies"] = max(0, $row["countPosts"] - 1);
		$results[] = $row;

	}

	$this->trigger("afterGetResults", array(&$results));

	return $results;
}


/**
 * Returns whether or not there are more results for the most recent search than were returned.
 *
 * @return bool
 */
public function areMoreResults()
{
	return $this->areMoreResults;
}


/**
 * Strip a gambit from a search string. This is useful when constructing the 'view more' link in 
 * the results, where we need to remove the existing #limit gambit and add a new one.
 *
 * @param string $searchString The search string.
 * @param string $condition The condition to run through eval() to determine a match.
 * 		$term represents the search term, in lowercase, in the eval() context. The condition
 * 		should return a boolean value: true means a match, false means no match.
 * 		Example: return $term == "sticky";
 * @return string The new search string.
 */
public function removeGambit($searchString, $condition)
{
	// Process the search string into individial terms. Replace all "-" signs with "+!", and then
	// split the string by "+". Negated terms will then be prefixed with "!".
	$terms = !empty($searchString) ? explode("+", strtolower(str_replace("-", "+!", trim($searchString, " +-")))) : array();

	// Take each term, match it with a gambit, and execute the gambit's function.
	foreach ($terms as $k => $term) {

		$term = $terms[$k] = trim($term);

		if ($term[0] == "#") {
			$term = ltrim($term, "#");

			// If the term is an alias, translate it into the appropriate gambit.
			if (array_key_exists($term, self::$aliases)) $term = self::$aliases[$term];

			// Find a matching gambit by evaluating each gambit's condition, and run its callback function.
			if (eval($condition)) {
				unset($terms[$k]);
				continue;
			}
		}
	}

	return implode(" + ", $terms);
}


/**
 * The "unread" gambit callback. Applies a filter to fetch only unread conversations.
 *
 * @param ETSearchModel $search The search model.
 * @param string $term The gambit term (in this case, will simply be "unread").
 * @param bool $negate Whether or not the gambit is negated.
 * @return void
 *
 * @todo Make negation work on this gambit. Probably requires some kind of "OR" functionality, so that
 * 		we can get conversations which:
 * 		- are NOT in conversationIds with a lastRead status less than the number of posts in the conversation
 * 		- OR which have a lastPostTime less than the markedAsRead time.
 */
public static function gambitUnread(&$search, $term, $negate)
{
	if (!ET::$session->user) return false;

	$q = ET::SQL()
		->select("c2.conversationId")
		->from("conversation c2")
		->from("member_conversation s2", "c2.conversationId=s2.conversationId AND s2.type='member' AND s2.id=:gambitUnread_memberId", "left")
		->where("s2.lastRead>=c2.countPosts")
		->get();

	$search->sql
		->where("c.conversationId NOT IN ($q)")
		->where("c.lastPostTime>=:gambitUnread_markedAsRead")
		->bind(":gambitUnread_memberId", ET::$session->userId)
		->bind(":gambitUnread_markedAsRead", ET::$session->preference("markedAllConversationsAsRead"));
}


/**
 * The "starred" gambit callback. Applies a filter to fetch only starred conversations.
 *
 * @see gambitUnread for parameter descriptions.
 */
public static function gambitStarred(&$search, $term, $negate)
{
	if (!ET::$session->user) return;

	$sql = ET::SQL()
		->select("DISTINCT conversationId")
		->from("member_conversation")
		->where("type='member'")
		->where("id=:memberId")
		->where("starred=1")
		->bind(":memberId", ET::$session->userId);

	$search->addIDFilter($sql, $negate);
}


/**
 * The "private" gambit callback. Applies a filter to fetch only private conversations.
 *
 * @see gambitUnread for parameter descriptions.
 */
public static function gambitPrivate(&$search, $term, $negate)
{
	$search->sql->where("c.private=".($negate ? "0" : "1"));
}


/**
 * The "ignored" gambit callback. Applies a filter to fetch only ignored conversations.
 *
 * @see gambitUnread for parameter descriptions.
 */
public static function gambitIgnored(&$search, $term, $negate)
{
	if (!ET::$session->user or $negate) return;
	$search->includeIgnored = true;

	$sql = ET::SQL()
		->select("DISTINCT conversationId")
		->from("member_conversation")
		->where("type='member'")
		->where("id=:memberId")
		->where("ignored=1")
		->bind(":memberId", ET::$session->userId);

	$search->addIDFilter($sql);
}


/**
 * The "draft" gambit callback. Applies a filter to fetch only conversations which the user has a
 * draft in.
 *
 * @see gambitUnread for parameter descriptions.
 */
public static function gambitDraft(&$search, $term, $negate)
{
	if (!ET::$session->user) return;
	$sql = ET::SQL()
		->select("DISTINCT conversationId")
		->from("member_conversation")
		->where("type='member'")
		->where("id=:memberId")
		->where("draft IS NOT NULL")
		->bind(":memberId", ET::$session->userId);

	$search->addIDFilter($sql, $negate);
}


/**
 * The "active" gambit callback. Applies a filter to fetch only conversations which have been active
 * in a certain period of time.
 *
 * @see gambitUnread for parameter descriptions.
 */
public function gambitActive(&$search, $term, $negate)
{
	// Multiply the "amount" part (b) of the regular expression matches by the value of the "unit" part (c).
	$search->matches["b"] = (int)$search->matches["b"];
	switch ($search->matches["c"]) {
		case T("gambit.minute"): $search->matches["b"] *= 60; break;
		case T("gambit.hour"): $search->matches["b"] *= 3600; break;
		case T("gambit.day"): $search->matches["b"] *= 86400; break;
		case T("gambit.week"): $search->matches["b"] *= 604800; break;
		case T("gambit.month"): $search->matches["b"] *= 2626560; break;
		case T("gambit.year"): $search->matches["b"] *= 31536000;
	}

	// Set the "quantifier" part (a); default to <= (i.e. "last").
	$search->matches["a"] = (!$search->matches["a"] or $search->matches["a"] == T("gambit.last")) ? "<=" : $search->matches["a"];

	// If the gambit is negated, use the inverse of the selected quantifier.
	if ($negate) {
		switch ($search->matches["a"]) {
			case "<": $search->matches["a"] = ">="; break;
			case "<=": $search->matches["a"] = ">"; break;
			case ">": $search->matches["a"] = "<="; break;
			case ">=": $search->matches["a"] = "<";
		}
	}

	// Apply the condition and force use of an index.
	$search->sql->where("UNIX_TIMESTAMP() - {$search->matches["b"]} {$search->matches["a"]} c.lastPostTime");
	$search->sql->useIndex("conversation_lastPostTime");
}


/**
 * The "author" gambit callback. Applies a filter to fetch only conversations which were started by
 * a particular member.
 *
 * @see gambitUnread for parameter descriptions.
 * @todo Somehow make the use of this gambit trigger the switching of the "last post" column in the
 * 		results table with a "started by" column.
 */
public static function gambitAuthor(&$search, $term, $negate)
{
	// Get the name of the member.
	$term = trim(str_replace("\xc2\xa0", " ", substr($term, strlen(T("gambit.author:")))));

	// If the user is referring to themselves, then we already have their member ID.
	if ($term == T("gambit.myself")) $q = (int)ET::$session->userId;

	// Otherwise, make a query to find the member ID of the specified member name.
	else {
		$q = ET::SQL()->select("memberId")->from("member")->where("username=:username")->bind(":username", $term)->get();
	}

	// Apply the condition.
	$search->sql->where("c.startMemberId".($negate ? " NOT" : "")." IN ($q)");
}


/**
 * The "contributor" gambit callback. Applies a filter to fetch only conversations which contain posts
 * by a particular member.
 *
 * @see gambitUnread for parameter descriptions.
 */
public static function gambitContributor(&$search, $term, $negate)
{
	// Get the name of the member.
	$term = trim(str_replace("\xc2\xa0", " ", substr($term, strlen(T("gambit.contributor:")))));

	// If the user is referring to themselves, then we already have their member ID.
	if ($term == T("gambit.myself")) $q = (int)ET::$session->userId;

	// Otherwise, make a query to find the member ID of the specified member name.
	else {
		$q = ET::SQL()->select("memberId")->from("member")->where("username=:username")->bind(":username", $term)->get();
	}

	// Apply the condition.
	$sql = ET::SQL()
		->select("DISTINCT conversationId")
		->from("post")
		->where("memberId IN ($q)");
	$search->addIDFilter($sql, $negate);
}


/**
 * The "limit" gambit callback. Specifies the number of results to display.
 *
 * @see gambitUnread for parameter descriptions.
 */
public static function gambitLimit(&$search, $term, $negate)
{
	if ($negate) return;

	// Get the number of results they want.
	$limit = (int)trim(substr($term, strlen(T("gambit.limit:"))));
	$limit = max(1, $limit);
	if (($max = C("esoTalk.search.limitMax")) > 0) $limit = min($max, $limit);

	$search->limit($limit);
}


/**
 * The "replies" gambit callback. Applies a filter to fetch only conversations which have a certain
 * amount of replies.
 *
 * @see gambitUnread for parameter descriptions.
 */
public static function gambitHasNReplies(&$search, $term, $negate)
{
	// Work out which quantifier to use; default to "=".
	$search->matches["a"] = (!$search->matches["a"]) ? "=" : $search->matches["a"];

	// If the gambit is negated, use the inverse of the quantifier.
	if ($negate) {
		switch ($search->matches["a"]) {
			case "<": $search->matches["a"] = ">="; break;
			case "<=": $search->matches["a"] = ">"; break;
			case ">": $search->matches["a"] = "<="; break;
			case ">=": $search->matches["a"] = "<"; break;
			case "=": $search->matches["a"] = "!=";
		}
	}

	// Increase the amount by one as we are checking replies, but the column in the conversations
	// table is a post count (it includes the original post.)
	$search->matches["b"]++;

	// Apply the condition.
	$search->sql->where("countPosts {$search->matches["a"]} {$search->matches["b"]}");
}


/**
 * The "order by replies" gambit callback. Orders the results by the number of replies they have.
 *
 * @see gambitUnread for parameter descriptions.
 */
public static function gambitOrderByReplies(&$search, $term, $negate)
{
	$search->orderBy("c.countPosts ".($negate ? "ASC" : "DESC"));
	$search->sql->useIndex("conversation_countPosts");
}


/**
 * The "order by newest" gambit callback. Orders the results by their start time.
 *
 * @see gambitUnread for parameter descriptions.
 * @todo Somehow make the use of this gambit trigger the switching of the "last post" column in the
 * 		results table with a "started by" column.
 */
public static function gambitOrderByNewest(&$search, $term, $negate)
{
	$search->orderBy("c.startTime ".($negate ? "ASC" : "DESC"));
	$search->sql->useIndex("conversation_startTime");
}


/**
 * The "sticky" gambit callback. Applies a filter to fetch only stickied conversations.
 *
 * @see gambitUnread for parameter descriptions.
 */
public static function gambitSticky(&$search, $term, $negate)
{
	$search->sql->where("sticky=".($negate ? "0" : "1"));
}


/**
 * The "random" gambit callback. Orders conversations randomly.
 *
 * @see gambitUnread for parameter descriptions.
 * @todo Make this not horrendously slow on large forums. For now there is a config option to disable
 * 		this gambit.
 */
public static function gambitRandom(&$search, $term, $negate)
{
	if (!$negate) $search->orderBy("RAND()");
}


/**
 * The "reverse" gambit callback. Reverses the order of conversations.
 *
 * @see gambitUnread for parameter descriptions.
 */
public static function gambitReverse(&$search, $term, $negate)
{
	if (!$negate) $search->orderReverse = true;
}


/**
 * The "locked" gambit callback. Applies a filter to fetch only locked conversations.
 *
 * @see gambitUnread for parameter descriptions.
 */
public static function gambitLocked(&$search, $term, $negate)
{
	$search->sql->where("locked=".($negate ? "0" : "1"));
}


}

// Add default gambits.
ETSearchModel::addGambit('return $term == strtolower(T("gambit.starred"));', array("ETSearchModel", "gambitStarred"));
ETSearchModel::addGambit('return $term == strtolower(T("gambit.ignored"));', array("ETSearchModel", "gambitIgnored"));
ETSearchModel::addGambit('return $term == strtolower(T("gambit.draft"));', array("ETSearchModel", "gambitDraft"));
ETSearchModel::addGambit('return $term == strtolower(T("gambit.private"));', array("ETSearchModel", "gambitPrivate"));
ETSearchModel::addGambit('return $term == strtolower(T("gambit.sticky"));', array("ETSearchModel", "gambitSticky"));
ETSearchModel::addGambit('return $term == strtolower(T("gambit.locked"));', array("ETSearchModel", "gambitLocked"));
ETSearchModel::addGambit('return strpos($term, strtolower(T("gambit.author:"))) === 0;', array("ETSearchModel", "gambitAuthor"));
ETSearchModel::addGambit('return strpos($term, strtolower(T("gambit.contributor:"))) === 0;', array("ETSearchModel", "gambitContributor"));
ETSearchModel::addGambit('return preg_match(T("gambit.gambitActive"), $term, $this->matches);', array("ETSearchModel", "gambitActive"));
ETSearchModel::addGambit('return preg_match(T("gambit.gambitHasNReplies"), $term, $this->matches);', array("ETSearchModel", "gambitHasNReplies"));
ETSearchModel::addGambit('return $term == strtolower(T("gambit.order by replies"));', array("ETSearchModel", "gambitOrderByReplies"));
ETSearchModel::addGambit('return $term == strtolower(T("gambit.order by newest"));', array("ETSearchModel", "gambitOrderByNewest"));
ETSearchModel::addGambit('return $term == strtolower(T("gambit.unread"));', array("ETSearchModel", "gambitUnread"));
ETSearchModel::addGambit('return $term == strtolower(T("gambit.reverse"));', array("ETSearchModel", "gambitReverse"));
ETSearchModel::addGambit('return strpos($term, strtolower(T("gambit.limit:"))) === 0;', array("ETSearchModel", "gambitLimit"));

if (!C("esoTalk.search.disableRandomGambit"))
	ETSearchModel::addGambit('return $term == strtolower(T("gambit.random"));', array("ETSearchModel", "gambitRandom"));


// Add default aliases.
ETSearchModel::addAlias(T("gambit.active today"), T("gambit.active 1 day"));
ETSearchModel::addAlias(T("gambit.has replies"), T("gambit.has >0 replies"));
ETSearchModel::addAlias(T("gambit.has no replies"), T("gambit.has 0 replies"));
ETSearchModel::addAlias(T("gambit.dead"), T("gambit.active >30 day"));