PageRenderTime 57ms CodeModel.GetById 26ms RepoModel.GetById 0ms app.codeStats 0ms

/models/ETSearchModel.class.php

https://github.com/Ramir1/esoTalk
PHP | 857 lines | 386 code | 155 blank | 316 comment | 61 complexity | 3fee6b329b27772b5c2b866904774be1 MD5 | raw file
Possible License(s): GPL-2.0
  1. <?php
  2. // Copyright 2011 Toby Zerner, Simon Zerner
  3. // This file is part of esoTalk. Please see the included license file for usage information.
  4. if (!defined("IN_ESOTALK")) exit;
  5. /**
  6. * A model which provides functions to perform searches for conversations. Handles the implementation
  7. * of gambits, and does search optimization.
  8. *
  9. * Searches are performed by the following steps:
  10. * 1. Call getConversationIDs with a list of channel IDs to show results from and a search string.
  11. * 2. The search string is parsed and split into terms. When a term is matched to a gambit, the
  12. * gambit's callback function is called.
  13. * 3. Callback functions add conversation ID filters to narrow the range of conversations being
  14. * searched, or may alter other parts of the search query.
  15. * 4. Using the applied ID filters, a final list of conversation IDs is retrieved and returned.
  16. * 5. Call getResults with this list, and full details are retireved for each of the conversations.
  17. *
  18. * @package esoTalk
  19. */
  20. class ETSearchModel extends ETModel {
  21. /**
  22. * An array of functional gambits. Each gambit is an array(callback, condition)
  23. * @var array
  24. * @see addGambit
  25. */
  26. protected static $gambits = array();
  27. /**
  28. * An array of aliases. An alias is a string of text which is just shorthand for a more complex
  29. * gambit. Each alias is an array(term, replacement term)
  30. * @var array
  31. * @see addAlias
  32. */
  33. protected static $aliases = array();
  34. /**
  35. * Whether or not there are more results for the most recent search than what was returned.
  36. * @var bool
  37. */
  38. protected $areMoreResults = false;
  39. /**
  40. * The SQL query object used to construct a query that retrieves a list of matching conversation IDs.
  41. * @var ETSQLQuery
  42. */
  43. public $sql;
  44. /**
  45. * An array of converastion ID filters that should be run before querying the conversations table
  46. * for a final list of conversation IDs.
  47. * @var array
  48. * @see addIDFilter
  49. */
  50. protected $idFilters = array();
  51. /**
  52. * An array of fields to order the conversation IDs by.
  53. * @var array
  54. */
  55. protected $orderBy = array();
  56. /**
  57. * Whether or not the direction in the $orderBy fields should be reversed.
  58. * @var bool
  59. */
  60. public $orderReverse = false;
  61. /**
  62. * Whether or not the direction in the $orderBy fields should be reversed.
  63. * @var bool
  64. */
  65. protected $limit = false;
  66. /**
  67. * Whether or not to include muted conversations in the results.
  68. * @var bool
  69. */
  70. public $includeMuted = false;
  71. /**
  72. * An array of fulltext keywords to filter the results by.
  73. * @var array
  74. */
  75. public $fulltext = array();
  76. /**
  77. * Class constructor. Sets up the inherited model functions to handle data in the search table
  78. * (used for logging search activity -> flood control.)
  79. *
  80. * @return void
  81. */
  82. public function __construct()
  83. {
  84. parent::__construct("search");
  85. }
  86. /**
  87. * Add a gambit to the collection. When a search term is matched to a gambit, the specified
  88. * callback function will be called. A match is determined by the return value of running
  89. * $condition through eval().
  90. *
  91. * @param string $condition The condition to run through eval() to determine a match.
  92. * $term represents the search term, in lowercase, in the eval() context. The condition
  93. * should return a boolean value: true means a match, false means no match.
  94. * Example: return $term == "sticky";
  95. * @param array $function The function to call if the gambit is matched. Function will be called
  96. * with parameters callback($sender, $term, $negate).
  97. * @return void
  98. */
  99. public static function addGambit($condition, $function)
  100. {
  101. self::$gambits[] = array($condition, $function);
  102. }
  103. /**
  104. * Add an alias for another gambit to the collection. When a search term is matched
  105. * to an alias, it will be interpreted as $realTerm.
  106. *
  107. * @param string $term The alias term.
  108. * @param string $realTerm The replacement term.
  109. * @return void
  110. */
  111. public static function addAlias($term, $realTerm)
  112. {
  113. self::$aliases[$term] = $realTerm;
  114. }
  115. /**
  116. * Add an SQL query to be run before the conversations table is queried for the final list of
  117. * conversation IDs. The query should return a list of conversation IDs; the results then will be
  118. * limited to conversations matching this list of IDs.
  119. *
  120. * See some of the default gambits for examples.
  121. *
  122. * @param ETSQLQuery $sql The SQL query that will return a list of matching conversation IDs.
  123. * @param bool $negate If set to true, the returned conversation IDs will be blacklisted.
  124. * @return void
  125. */
  126. public function addIDFilter($sql, $negate = false)
  127. {
  128. $this->idFilters[] = array($sql, $negate);
  129. }
  130. /**
  131. * Add a term to include in a fulltext search.
  132. *
  133. * @param string $term The term.
  134. * @return void
  135. */
  136. public function fulltext($term)
  137. {
  138. $this->fulltext[] = $term;
  139. }
  140. /**
  141. * Apply an order to the search results. This function will ensure that a direction (ASC|DESC) is
  142. * at the end.
  143. *
  144. * @param string $order The field to order the results by.
  145. * @return void
  146. */
  147. public function orderBy($order)
  148. {
  149. $direction = substr($order, strrpos($order, " ") + 1);
  150. if ($direction != "ASC" and $direction != "DESC") $order .= " ASC";
  151. $this->orderBy[] = $order;
  152. }
  153. /**
  154. * Apply a custom limit to the number of search results returned.
  155. *
  156. * @param int $limit The limit.
  157. * @return void
  158. */
  159. public function limit($limit)
  160. {
  161. $this->limit = $limit;
  162. }
  163. /**
  164. * Reset instance variables.
  165. *
  166. * @return void
  167. */
  168. protected function reset()
  169. {
  170. $this->resultCount = 0;
  171. $this->areMoreResults = false;
  172. $this->sql = null;
  173. $this->idFilters = array();
  174. $this->orderBy = array();
  175. $this->orderReverse = false;
  176. $this->limit = false;
  177. $this->includeMuted = false;
  178. $this->fulltext = array();
  179. }
  180. /**
  181. * Determines whether or not the user is "flooding" the search system, based on the number of searches
  182. * they have performed in the last minute.
  183. *
  184. * @return bool|int If the user is not flooding, returns false, but if they are, returned the number
  185. * of seconds until they can perform another search.
  186. */
  187. public function isFlooding()
  188. {
  189. if (C("esoTalk.search.searchesPerMinute") <= 0) return false;
  190. $time = time();
  191. $period = 60;
  192. // If we have a record of their searches in the session, check how many searches they've performed in the last minute.
  193. $searches = ET::$session->get("searches");
  194. if (!empty($searches)) {
  195. // Clean anything older than $period seconds out of the searches array.
  196. foreach ($searches as $k => $v) {
  197. if ($v < $time - $period) unset($searches[$k]);
  198. }
  199. // Have they performed >= [searchesPerMinute] searches in the last minute? If so, they are flooding.
  200. if (count($searches) >= C("esoTalk.search.searchesPerMinute"))
  201. return $period - $time + min($searches);
  202. }
  203. // However, if we don't have a record in the session, query the database searches table.
  204. else {
  205. // Get the user's IP address.
  206. $ip = (int)ip2long(ET::$session->ip);
  207. // Have they performed >= $config["searchesPerMinute"] searches in the last minute?
  208. $sql = ET::SQL()
  209. ->select("COUNT(ip)")
  210. ->from("search")
  211. ->where("type='conversations'")
  212. ->where("ip=:ip")->bind(":ip", $ip)
  213. ->where("time>:time")->bind(":time", $time - $period);
  214. if ($sql->exec()->result() >= C("esoTalk.search.searchesPerMinute"))
  215. return $period;
  216. // Log this search in the searches table.
  217. ET::SQL()->insert("search")->set("type", "conversations")->set("ip", $ip)->set("time", $time)->exec();
  218. // Proactively clean the searches table of searches older than $period seconds.
  219. ET::SQL()->delete()->from("search")->where("type", "conversations")->where("time<:time")->bind(":time", $time - $period)->exec();
  220. }
  221. // Log this search in the session array.
  222. $searches[] = $time;
  223. ET::$session->store("searches", $searches);
  224. return false;
  225. }
  226. /**
  227. * Deconstruct a search string and return a list of conversation IDs that fulfill it.
  228. *
  229. * @param array $channelIDs A list of channel IDs to include results from.
  230. * @param string $searchString The search string to deconstruct and find matching conversations.
  231. * @param bool $orderBySticky Whether or not to put stickied conversations at the top.
  232. * @return array|bool An array of matching conversation IDs, or false if there are none.
  233. */
  234. public function getConversationIDs($channelIDs = array(), $searchString = "", $orderBySticky = false)
  235. {
  236. $this->reset();
  237. $this->trigger("getConversationIDsBefore", array(&$channelIDs, &$searchString, &$orderBySticky));
  238. if ($searchString and ($seconds = $this->isFlooding())) {
  239. $this->error("search", sprintf(T("message.waitToSearch"), $seconds));
  240. return false;
  241. }
  242. // Initialize the SQL query that will return the resulting conversation IDs.
  243. $this->sql = ET::SQL()->select("c.conversationId")->from("conversation c");
  244. // Only get conversations in the specified channels.
  245. if ($channelIDs) {
  246. $this->sql->where("c.channelId IN (:channelIds)")->bind(":channelIds", $channelIDs);
  247. }
  248. // Process the search string into individial terms. Replace all "-" signs with "+!", and then
  249. // split the string by "+". Negated terms will then be prefixed with "!". Only keep the first
  250. // 5 terms, just to keep the load on the database down!
  251. $terms = !empty($searchString) ? explode("+", strtolower(str_replace("-", "+!", trim($searchString, " +-")))) : array();
  252. $terms = array_slice($terms, 0, 5);
  253. // Take each term, match it with a gambit, and execute the gambit's function.
  254. foreach ($terms as $term) {
  255. // Are we dealing with a negated search term, ie. prefixed with a "!"?
  256. $term = trim($term);
  257. if ($negate = ($term[0] == "!")) $term = trim($term, "! ");
  258. if ($term[0] == "#") {
  259. $term = ltrim($term, "#");
  260. // If the term is an alias, translate it into the appropriate gambit.
  261. if (array_key_exists($term, self::$aliases)) $term = self::$aliases[$term];
  262. // Find a matching gambit by evaluating each gambit's condition, and run its callback function.
  263. foreach (self::$gambits as $gambit) {
  264. list($condition, $function) = $gambit;
  265. if (eval($condition)) {
  266. call_user_func_array($function, array(&$this, $term, $negate));
  267. continue 2;
  268. }
  269. }
  270. }
  271. // If we didn't find a gambit, use this term as a fulltext term.
  272. if ($negate) $term = "-".str_replace(" ", " -", $term);
  273. $this->fulltext($term);
  274. }
  275. // If an order for the search results has not been specified, apply a default.
  276. // Order by sticky and then last post time.
  277. if (!count($this->orderBy)) {
  278. if ($orderBySticky) $this->orderBy("c.sticky DESC");
  279. $this->orderBy("c.lastPostTime DESC");
  280. }
  281. // If we're not including muted conversations, add a where predicate to the query to exclude them.
  282. if (!$this->includeMuted and ET::$session->user) {
  283. $q = ET::SQL()->select("conversationId")->from("member_conversation")->where("type='member'")->where("id=:memberIdMuted")->where("muted=1")->get();
  284. $this->sql->where("conversationId NOT IN ($q)")->bind(":memberIdMuted", ET::$session->userId);
  285. }
  286. // Now we need to loop through the ID filters and run them one-by-one. When a query returns a selection
  287. // of conversation IDs, subsequent queries are restricted to filtering those conversation IDs,
  288. // and so on, until we have a list of IDs to pass to the final query.
  289. $goodConversationIDs = array();
  290. $badConversationIDs = array();
  291. $idCondition = "";
  292. foreach ($this->idFilters as $v) {
  293. list($sql, $negate) = $v;
  294. // Apply the list of good IDs to the query.
  295. $sql->where($idCondition);
  296. // Get the list of conversation IDs so that the next condition can use it in its query.
  297. $result = $sql->exec();
  298. $ids = array();
  299. while ($row = $result->nextRow()) $ids[] = (int)reset($row);
  300. // If this condition is negated, then add the IDs to the list of bad conversations.
  301. // If the condition is not negated, set the list of good conversations to the IDs, provided there are some.
  302. if ($negate) $badConversationIDs = array_merge($badConversationIDs, $ids);
  303. elseif (count($ids)) $goodConversationIDs = $ids;
  304. else return false;
  305. // Strip bad conversation IDs from the list of good conversation IDs.
  306. if (count($goodConversationIDs)) {
  307. $goodConversationIds = array_diff($goodConversationIDs, $badConversationIDs);
  308. if (!count($goodConversationIDs)) return false;
  309. }
  310. // This will be the condition for the next query that restricts or eliminates conversation IDs.
  311. if (count($goodConversationIDs))
  312. $idCondition = "conversationId IN (".implode(",", $goodConversationIDs).")";
  313. elseif (count($badConversationIDs))
  314. $idCondition = "conversationId NOT IN (".implode(",", $badConversationIDs).")";
  315. }
  316. // Reverse the order if necessary - swap DESC and ASC.
  317. if ($this->orderReverse) {
  318. foreach ($this->orderBy as $k => $v)
  319. $this->orderBy[$k] = strtr($this->orderBy[$k], array("DESC" => "ASC", "ASC" => "DESC"));
  320. }
  321. // Now check if there are any fulltext keywords to filter by.
  322. if (count($this->fulltext)) {
  323. // Run a query against the posts table to get matching conversation IDs.
  324. $fulltextString = implode(" ", $this->fulltext);
  325. $result = ET::SQL()
  326. ->select("DISTINCT conversationId")
  327. ->from("post")
  328. ->where("MATCH (title, content) AGAINST (:fulltext IN BOOLEAN MODE)")
  329. ->where($idCondition)
  330. ->orderBy("MATCH (title, content) AGAINST (:fulltextOrder) DESC")
  331. ->bind(":fulltext", $fulltextString)
  332. ->bind(":fulltextOrder", $fulltextString)
  333. ->exec();
  334. $ids = array();
  335. while ($row = $result->nextRow()) $ids[] = reset($row);
  336. // Change the ID condition to this list of matching IDs, and order by relevance.
  337. if (count($ids)) $idCondition = "conversationId IN (".implode(",", $ids).")";
  338. else return false;
  339. $this->orderBy = array("FIELD(c.conversationId,".implode(",", $ids).")");
  340. }
  341. // Set a default limit if none has previously been set. Set it with one more result than we'll
  342. // need so we can see if there are "more results."
  343. if (!$this->limit) $this->limit = C("esoTalk.search.results") + 1;
  344. // Finish constructing the final query using the ID whitelist/blacklist we've come up with.
  345. if ($idCondition) $this->sql->where($idCondition);
  346. $this->sql->orderBy($this->orderBy)->limit($this->limit);
  347. // Make sure conversations that the user isn't allowed to see are filtered out.
  348. ET::conversationModel()->addAllowedPredicate($this->sql);
  349. // Execute the query, and collect the final set of conversation IDs.
  350. $result = $this->sql->exec();
  351. $conversationIDs = array();
  352. while ($row = $result->nextRow()) $conversationIDs[] = reset($row);
  353. // If there's one more result than we actually need, indicate that there are "more results."
  354. if ($this->limit == C("esoTalk.search.results") + 1 and count($conversationIDs) == $this->limit) {
  355. array_pop($conversationIDs);
  356. $this->areMoreResults = true;
  357. }
  358. return count($conversationIDs) ? $conversationIDs : false;
  359. }
  360. /**
  361. * Get a full list of conversation details for a list of conversation IDs.
  362. *
  363. * @param array $conversationIDs The list of conversation IDs to fetch details for.
  364. * @param bool $checkForPermission Whether or not to add a check onto the query to make sure the
  365. * user has permission to view all of the conversations.
  366. */
  367. public function getResults($conversationIDs, $checkForPermission = false)
  368. {
  369. // Construct a query to get details for all of the specified conversations.
  370. $sql = ET::SQL()
  371. ->select("s.*") // Select the status fields first so that the conversation fields take precedence.
  372. ->select("c.*")
  373. ->select("sm.username", "startMember")
  374. ->select("sm.avatarFormat", "startMemberAvatarFormat")
  375. ->select("lpm.username", "lastPostMember")
  376. ->select("lpm.email", "lastPostMemberEmail")
  377. ->select("lpm.avatarFormat", "lastPostMemberAvatarFormat")
  378. ->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")
  379. ->from("conversation c")
  380. ->from("member_conversation s", "s.conversationId=c.conversationId AND s.type='member' AND s.id=:memberId", "left")
  381. ->from("member sm", "c.startMemberId=sm.memberId", "left")
  382. ->from("member lpm", "c.lastPostMemberId=lpm.memberId", "left")
  383. ->from("channel ch", "c.channelId=ch.channelId", "left")
  384. ->bind(":markedAsRead", ET::$session->preference("markedAllConversationsAsRead"))
  385. ->bind(":memberId", ET::$session->userId);
  386. // If we need to, filter out all conversations that the user isn't allowed to see.
  387. if ($checkForPermission) ET::conversationModel()->addAllowedPredicate($sql);
  388. // Add a labels column to the query.
  389. ET::conversationModel()->addLabels($sql);
  390. // Limit the results to the specified conversation IDs
  391. $sql->where("c.conversationId IN (:conversationIds)")->orderBy("FIELD(c.conversationId,:conversationIdsOrder)");
  392. $sql->bind(":conversationIds", $conversationIDs, PDO::PARAM_INT);
  393. $sql->bind(":conversationIdsOrder", $conversationIDs, PDO::PARAM_INT);
  394. $this->trigger("beforeGetResults", array(&$sql));
  395. // Execute the query and put the details of the conversations into an array.
  396. $result = $sql->exec();
  397. $results = array();
  398. $model = ET::conversationModel();
  399. while ($row = $result->nextRow()) {
  400. // Expand the comma-separated label flags into a workable array of active labels.
  401. $row["labels"] = $model->expandLabels($row["labels"]);
  402. $row["replies"] = max(0, $row["countPosts"] - 1);
  403. $results[] = $row;
  404. }
  405. $this->trigger("afterGetResults", array(&$results));
  406. return $results;
  407. }
  408. /**
  409. * Returns whether or not there are more results for the most recent search than were returned.
  410. *
  411. * @return bool
  412. */
  413. public function areMoreResults()
  414. {
  415. return $this->areMoreResults;
  416. }
  417. /**
  418. * The "unread" gambit callback. Applies a filter to fetch only unread conversations.
  419. *
  420. * @param ETSearchModel $search The search model.
  421. * @param string $term The gambit term (in this case, will simply be "unread").
  422. * @param bool $negate Whether or not the gambit is negated.
  423. * @return void
  424. *
  425. * @todo Make negation work on this gambit. Probably requires some kind of "OR" functionality, so that
  426. * we can get conversations which:
  427. * - are NOT in conversationIds with a lastRead status less than the number of posts in the conversation
  428. * - OR which have a lastPostTime less than the markedAsRead time.
  429. */
  430. public static function gambitUnread(&$search, $term, $negate)
  431. {
  432. if (!ET::$session->user) return false;
  433. $q = ET::SQL()
  434. ->select("c2.conversationId")
  435. ->from("conversation c2")
  436. ->from("member_conversation s2", "c2.conversationId=s2.conversationId AND s2.type='member' AND s2.id=:gambitUnread_memberId", "left")
  437. ->where("s2.lastRead>=c2.countPosts")
  438. ->get();
  439. $search->sql
  440. ->where("c.conversationId NOT IN ($q)")
  441. ->where("c.lastPostTime>=:gambitUnread_markedAsRead")
  442. ->bind(":gambitUnread_memberId", ET::$session->userId)
  443. ->bind(":gambitUnread_markedAsRead", ET::$session->preference("markedAllConversationsAsRead"));
  444. }
  445. /**
  446. * The "starred" gambit callback. Applies a filter to fetch only starred conversations.
  447. *
  448. * @see gambitUnread for parameter descriptions.
  449. */
  450. public static function gambitStarred(&$search, $term, $negate)
  451. {
  452. if (!ET::$session->user) return;
  453. $sql = ET::SQL()
  454. ->select("DISTINCT conversationId")
  455. ->from("member_conversation")
  456. ->where("type='member'")
  457. ->where("id=:memberId")
  458. ->where("starred=1")
  459. ->bind(":memberId", ET::$session->userId);
  460. $search->addIDFilter($sql, $negate);
  461. }
  462. /**
  463. * The "private" gambit callback. Applies a filter to fetch only private conversations.
  464. *
  465. * @see gambitUnread for parameter descriptions.
  466. */
  467. public static function gambitPrivate(&$search, $term, $negate)
  468. {
  469. $search->sql->where("c.private=".($negate ? "0" : "1"));
  470. }
  471. /**
  472. * The "muted" gambit callback. Applies a filter to fetch only muted conversations.
  473. *
  474. * @see gambitUnread for parameter descriptions.
  475. */
  476. public static function gambitMuted(&$search, $term, $negate)
  477. {
  478. if (!ET::$session->user or $negate) return;
  479. $search->includeMuted = true;
  480. $sql = ET::SQL()
  481. ->select("DISTINCT conversationId")
  482. ->from("member_conversation")
  483. ->where("type='member'")
  484. ->where("id=:memberId")
  485. ->where("muted=1")
  486. ->bind(":memberId", ET::$session->userId);
  487. $search->addIDFilter($sql);
  488. }
  489. /**
  490. * The "draft" gambit callback. Applies a filter to fetch only conversations which the user has a
  491. * draft in.
  492. *
  493. * @see gambitUnread for parameter descriptions.
  494. */
  495. public static function gambitDraft(&$search, $term, $negate)
  496. {
  497. if (!ET::$session->user) return;
  498. $sql = ET::SQL()
  499. ->select("DISTINCT conversationId")
  500. ->from("member_conversation")
  501. ->where("type='member'")
  502. ->where("id=:memberId")
  503. ->where("draft IS NOT NULL")
  504. ->bind(":memberId", ET::$session->userId);
  505. $search->addIDFilter($sql, $negate);
  506. }
  507. /**
  508. * The "active" gambit callback. Applies a filter to fetch only conversations which have been active
  509. * in a certain period of time.
  510. *
  511. * @see gambitUnread for parameter descriptions.
  512. */
  513. public function gambitActive(&$search, $term, $negate)
  514. {
  515. // Multiply the "amount" part (b) of the regular expression matches by the value of the "unit" part (c).
  516. $search->matches["b"] = (int)$search->matches["b"];
  517. switch ($search->matches["c"]) {
  518. case T("gambit.minute"): $search->matches["b"] *= 60; break;
  519. case T("gambit.hour"): $search->matches["b"] *= 3600; break;
  520. case T("gambit.day"): $search->matches["b"] *= 86400; break;
  521. case T("gambit.week"): $search->matches["b"] *= 604800; break;
  522. case T("gambit.month"): $search->matches["b"] *= 2626560; break;
  523. case T("gambit.year"): $search->matches["b"] *= 31536000;
  524. }
  525. // Set the "quantifier" part (a); default to <= (i.e. "last").
  526. $search->matches["a"] = (!$search->matches["a"] or $search->matches["a"] == T("gambit.last")) ? "<=" : $search->matches["a"];
  527. // If the gambit is negated, use the inverse of the selected quantifier.
  528. if ($negate) {
  529. switch ($search->matches["a"]) {
  530. case "<": $search->matches["a"] = ">="; break;
  531. case "<=": $search->matches["a"] = ">"; break;
  532. case ">": $search->matches["a"] = "<="; break;
  533. case ">=": $search->matches["a"] = "<";
  534. }
  535. }
  536. // Apply the condition and force use of an index.
  537. $search->sql->where("UNIX_TIMESTAMP() - {$search->matches["b"]} {$search->matches["a"]} c.lastPostTime");
  538. $search->sql->useIndex("conversation_lastPostTime");
  539. }
  540. /**
  541. * The "author" gambit callback. Applies a filter to fetch only conversations which were started by
  542. * a particular member.
  543. *
  544. * @see gambitUnread for parameter descriptions.
  545. * @todo Somehow make the use of this gambit trigger the switching of the "last post" column in the
  546. * results table with a "started by" column.
  547. */
  548. public static function gambitAuthor(&$search, $term, $negate)
  549. {
  550. // Get the name of the member.
  551. $term = trim(substr($term, strlen(T("gambit.author:"))));
  552. // If the user is referring to themselves, then we already have their member ID.
  553. if ($term == T("gambit.myself")) $q = (int)ET::$session->userId;
  554. // Otherwise, make a query to find the member ID of the specified member name.
  555. else {
  556. $q = "(".ET::SQL()->select("memberId")->from("member")->where("username=:username")->bind(":username", $term)->get().")";
  557. }
  558. // Apply the condition.
  559. $search->sql->where("c.startMemberId".($negate ? " NOT" : "")." IN $q");
  560. }
  561. /**
  562. * The "contributor" gambit callback. Applies a filter to fetch only conversations which contain posts
  563. * by a particular member.
  564. *
  565. * @see gambitUnread for parameter descriptions.
  566. */
  567. public static function gambitContributor(&$search, $term, $negate)
  568. {
  569. // Get the name of the member.
  570. $term = trim(substr($term, strlen(T("gambit.contributor:"))));
  571. // If the user is referring to themselves, then we already have their member ID.
  572. if ($term == T("gambit.myself")) $q = (int)ET::$session->userId;
  573. // Otherwise, make a query to find the member ID of the specified member name.
  574. else {
  575. $q = "(".ET::SQL()->select("memberId")->from("member")->where("username=:username")->bind(":username", $term)->get().")";
  576. }
  577. // Apply the condition.
  578. $sql = ET::SQL()
  579. ->select("DISTINCT conversationId")
  580. ->from("post")
  581. ->where("memberId IN $q");
  582. $search->addIDFilter($sql, $negate);
  583. }
  584. /**
  585. * The "more results" gambit callback. Bumps up the limit to display more results.
  586. *
  587. * @see gambitUnread for parameter descriptions.
  588. */
  589. public static function gambitMoreResults(&$search, $term, $negate)
  590. {
  591. if (!$negate) $search->limit(C("esoTalk.search.moreResults"));
  592. }
  593. /**
  594. * The "replies" gambit callback. Applies a filter to fetch only conversations which have a certain
  595. * amount of replies.
  596. *
  597. * @see gambitUnread for parameter descriptions.
  598. */
  599. public static function gambitHasNReplies(&$search, $term, $negate)
  600. {
  601. // Work out which quantifier to use; default to "=".
  602. $search->matches["a"] = (!$search->matches["a"]) ? "=" : $search->matches["a"];
  603. // If the gambit is negated, use the inverse of the quantifier.
  604. if ($negate) {
  605. switch ($search->matches["a"]) {
  606. case "<": $search->matches["a"] = ">="; break;
  607. case "<=": $search->matches["a"] = ">"; break;
  608. case ">": $search->matches["a"] = "<="; break;
  609. case ">=": $search->matches["a"] = "<"; break;
  610. case "=": $search->matches["a"] = "!=";
  611. }
  612. }
  613. // Increase the amount by one as we are checking replies, but the column in the conversations
  614. // table is a post count (it includes the original post.)
  615. $search->matches["b"]++;
  616. // Apply the condition.
  617. $search->sql->where("countPosts {$search->matches["a"]} {$search->matches["b"]}");
  618. }
  619. /**
  620. * The "order by replies" gambit callback. Orders the results by the number of replies they have.
  621. *
  622. * @see gambitUnread for parameter descriptions.
  623. */
  624. public static function gambitOrderByReplies(&$search, $term, $negate)
  625. {
  626. $search->orderBy("c.countPosts ".($negate ? "ASC" : "DESC"));
  627. $search->sql->useIndex("conversation_countPosts");
  628. }
  629. /**
  630. * The "order by newest" gambit callback. Orders the results by their start time.
  631. *
  632. * @see gambitUnread for parameter descriptions.
  633. * @todo Somehow make the use of this gambit trigger the switching of the "last post" column in the
  634. * results table with a "started by" column.
  635. */
  636. public static function gambitOrderByNewest(&$search, $term, $negate)
  637. {
  638. $search->orderBy("c.startTime ".($negate ? "ASC" : "DESC"));
  639. $search->sql->useIndex("conversation_startTime");
  640. }
  641. /**
  642. * The "sticky" gambit callback. Applies a filter to fetch only stickied conversations.
  643. *
  644. * @see gambitUnread for parameter descriptions.
  645. */
  646. public static function gambitSticky(&$search, $term, $negate)
  647. {
  648. $search->sql->where("sticky=".($negate ? "0" : "1"));
  649. }
  650. /**
  651. * The "random" gambit callback. Orders conversations randomly.
  652. *
  653. * @see gambitUnread for parameter descriptions.
  654. * @todo Make this not horrendously slow on large forums. For now there is a config option to disable
  655. * this gambit.
  656. */
  657. public static function gambitRandom(&$search, $term, $negate)
  658. {
  659. if (!$negate) $search->orderBy("RAND()");
  660. }
  661. /**
  662. * The "reverse" gambit callback. Reverses the order of conversations.
  663. *
  664. * @see gambitUnread for parameter descriptions.
  665. */
  666. public static function gambitReverse(&$search, $term, $negate)
  667. {
  668. if (!$negate) $search->orderReverse = true;
  669. }
  670. /**
  671. * The "locked" gambit callback. Applies a filter to fetch only locked conversations.
  672. *
  673. * @see gambitUnread for parameter descriptions.
  674. */
  675. public static function gambitLocked(&$search, $term, $negate)
  676. {
  677. $search->sql->where("locked=".($negate ? "0" : "1"));
  678. }
  679. }
  680. // Add default gambit.
  681. ETSearchModel::addGambit('return $term == strtolower(T("gambit.starred"));', array("ETSearchModel", "gambitStarred"));
  682. ETSearchModel::addGambit('return $term == strtolower(T("gambit.muted"));', array("ETSearchModel", "gambitMuted"));
  683. ETSearchModel::addGambit('return $term == strtolower(T("gambit.draft"));', array("ETSearchModel", "gambitDraft"));
  684. ETSearchModel::addGambit('return $term == strtolower(T("gambit.private"));', array("ETSearchModel", "gambitPrivate"));
  685. ETSearchModel::addGambit('return $term == strtolower(T("gambit.sticky"));', array("ETSearchModel", "gambitSticky"));
  686. ETSearchModel::addGambit('return $term == strtolower(T("gambit.locked"));', array("ETSearchModel", "gambitLocked"));
  687. ETSearchModel::addGambit('return strpos($term, strtolower(T("gambit.author:"))) === 0;', array("ETSearchModel", "gambitAuthor"));
  688. ETSearchModel::addGambit('return strpos($term, strtolower(T("gambit.contributor:"))) === 0;', array("ETSearchModel", "gambitContributor"));
  689. ETSearchModel::addGambit('return preg_match(T("gambit.gambitActive"), $term, $this->matches);', array("ETSearchModel", "gambitActive"));
  690. ETSearchModel::addGambit('return preg_match(T("gambit.gambitHasNReplies"), $term, $this->matches);', array("ETSearchModel", "gambitHasNReplies"));
  691. ETSearchModel::addGambit('return $term == strtolower(T("gambit.order by replies"));', array("ETSearchModel", "gambitOrderByReplies"));
  692. ETSearchModel::addGambit('return $term == strtolower(T("gambit.order by newest"));', array("ETSearchModel", "gambitOrderByNewest"));
  693. ETSearchModel::addGambit('return $term == strtolower(T("gambit.unread"));', array("ETSearchModel", "gambitUnread"));
  694. ETSearchModel::addGambit('return $term == strtolower(T("gambit.reverse"));', array("ETSearchModel", "gambitReverse"));
  695. ETSearchModel::addGambit('return $term == strtolower(T("gambit.more results"));', array("ETSearchModel", "gambitMoreResults"));
  696. if (!C("esoTalk.search.disableRandomGambit"))
  697. ETSearchModel::addGambit('return $term == strtolower(T("gambit.random"));', array("ETSearchModel", "gambitRandom"));
  698. // Add default aliases.
  699. ETSearchModel::addAlias(T("gambit.active today"), T("gambit.active 1 day"));
  700. ETSearchModel::addAlias(T("gambit.has replies"), T("gambit.has >0 replies"));
  701. ETSearchModel::addAlias(T("gambit.has no replies"), T("gambit.has 0 replies"));
  702. ETSearchModel::addAlias(T("gambit.dead"), T("gambit.active >30 day"));