PageRenderTime 48ms CodeModel.GetById 17ms RepoModel.GetById 0ms app.codeStats 0ms

/inc/search.php

https://bitbucket.org/wez/mtrack/
PHP | 439 lines | 349 code | 48 blank | 42 comment | 48 complexity | bed1f20ced38d77860a3d24fcde5de6d MD5 | raw file
Possible License(s): BSD-3-Clause, Apache-2.0
  1. <?php # vim:ts=2:sw=2:et:
  2. /* For licensing and copyright terms, see the file named LICENSE */
  3. include MTRACK_INC_DIR . '/search/lucene.php';
  4. include MTRACK_INC_DIR . '/search/solr.php';
  5. class MTrackSearchResult {
  6. /** object identifier of result */
  7. public $objectid;
  8. /** result ranking; higher is more relevant */
  9. public $score;
  10. /** excerpt of matching text */
  11. public $excerpt;
  12. /* some implementations may need the caller to provide the context
  13. * text; the default just returns what is there */
  14. function getExcerpt($text) {
  15. return $this->excerpt;
  16. }
  17. }
  18. interface IMTrackSearchEngine {
  19. public function setBatchMode();
  20. public function commit($optimize = false);
  21. public function remove($object);
  22. public function add($object, $fields, $replace = false);
  23. /** returns an array of MTrackSearchResult objects corresponding
  24. * to matches to the supplied query string */
  25. public function search($query);
  26. /** returns true if the engine needs the caller to provide context
  27. * for highlighting */
  28. public function highlighterNeedsContext();
  29. }
  30. class MTrackSearchDB {
  31. static $index = null;
  32. static $engine = null;
  33. static function getEngine() {
  34. if (self::$engine === null) {
  35. $name = MTrackConfig::get('core', 'search_engine');
  36. if (!$name) $name = 'MTrackSearchEngineLucene';
  37. self::$engine = new $name;
  38. }
  39. return self::$engine;
  40. }
  41. public function highlighterNeedsContext() {
  42. return self::getEngine()->highlighterNeedsContext();
  43. }
  44. /* functions that can perform indexing */
  45. static $funcs = array();
  46. static function register_indexer($id, $func)
  47. {
  48. self::$funcs[$id] = $func;
  49. }
  50. static function index_object($id)
  51. {
  52. $key = $id;
  53. while (strlen($key)) {
  54. if (isset(self::$funcs[$key])) {
  55. break;
  56. }
  57. $new_key = preg_replace('/:[^:]+$/', '', $key);
  58. if ($key == $new_key) {
  59. break;
  60. }
  61. $key = $new_key;
  62. }
  63. if (isset(self::$funcs[$key])) {
  64. $func = self::$funcs[$key];
  65. /* some of the indexing code is verbose; if we're updating
  66. * inline as part of the requests, we need to turn that off
  67. * to avoid breaking the page output! */
  68. if (MTrackConfig::get('core', 'update_search_immediate')) {
  69. ob_start();
  70. }
  71. $ret = call_user_func($func, $id);
  72. if (MTrackConfig::get('core', 'update_search_immediate')) {
  73. ob_end_clean();
  74. }
  75. return $ret;
  76. }
  77. return false;
  78. }
  79. static function get() {
  80. return self::getEngine()->getIdx();
  81. }
  82. static function setBatchMode() {
  83. self::getEngine()->setBatchMode();
  84. }
  85. static function commit($optimize = false) {
  86. self::getEngine()->commit($optimize);
  87. }
  88. static function remove($object) {
  89. self::getEngine()->remove($object);
  90. }
  91. static function add($object, $fields, $replace = false) {
  92. self::getEngine()->add($object, $fields, $replace);
  93. }
  94. static function search($query) {
  95. return self::getEngine()->search($query);
  96. }
  97. static function expand_quick_link($q) {
  98. global $ABSWEB;
  99. if (preg_match("/^help$/i", $q)) {
  100. return array("Help on $q", $ABSWEB . 'help.php');
  101. }
  102. if (preg_match('/^#([a-zA-Z0-9]+)$/', $q, $M)) {
  103. /* ticket */
  104. $t = $M[1];
  105. $url = $ABSWEB . "ticket.php/$t";
  106. return array("<a href='$url' class='ticketlink'>Ticket #$t</a>", $url);
  107. }
  108. if (preg_match('/^([0-9]+)$/', $q, $M)) {
  109. $t = $M[1];
  110. $url = $ABSWEB . "ticket.php/$t";
  111. return array("<a href='$url' class='ticketlink'>Ticket #$t</a>", $url);
  112. }
  113. if (preg_match('/^(?:#?[0-9-]+\s*)+$/', $q)) {
  114. /* tickets; show a custom query for those */
  115. $tkts = array();
  116. foreach (preg_split("/\s+/", $q) as $id) {
  117. if ($id[0] == '#') $id = substr($id, 1);
  118. $tkts[] = $id;
  119. }
  120. return array("Show ticket list: $q",
  121. $ABSWEB . "query.php?ticket=" . join('|', $tkts));
  122. }
  123. if (preg_match('/^r([a-zA-Z]*\d+)$/', $q, $M)) {
  124. /* changeset */
  125. $url = mtrack_changeset_url($M[1]);
  126. return array("Show changeset $q", $url);
  127. }
  128. if (preg_match('/^\[([a-zA-Z]*\d+)\]$/', $q, $M)) {
  129. /* changeset */
  130. $url = mtrack_changeset_url($M[1]);
  131. return array("Show changeset $q", $url);
  132. }
  133. if (preg_match('/^\{(\d+)\}$/', $q, $M)) {
  134. /* report */
  135. return array("Go to report $q",
  136. $ABSWEB . "report.php/$M[1]");
  137. }
  138. return null;
  139. }
  140. static function rest_query_array($method, $uri, $captures) {
  141. $q = MTrackAPI::getParam('q');
  142. MTrackAPI::checkAllowed($method, 'GET');
  143. /* full text. We hide closed tickets to reduce noise;
  144. * we're more likely to be searching for active items here */
  145. $notickets = "$q -status:closed";
  146. $res = mtrack_cache(array('MTrackSearchDB', '_do_search'),
  147. array($notickets), 6);
  148. foreach ($res->results as $idx => $group) {
  149. if (!MTrackACL::hasAnyRights($group->object, 'read')) {
  150. unset($res->results[$idx]);
  151. }
  152. }
  153. /* aggregate the ticket search results */
  154. $res = $res->results;
  155. $truncated = false;
  156. if (count($res) > 8) {
  157. $res = array_slice($res, 0, 8);
  158. $truncated = true;
  159. }
  160. if (preg_match("/^[#0-9 -]+$/", $q)) {
  161. $t = MTrackAPI::invoke('GET', "/ticket/search/basic", null, array(
  162. 'q' => trim($q)));
  163. foreach ($t->result as $r) {
  164. if (count($res) > 8) {
  165. $truncated = true;
  166. break;
  167. }
  168. $o = new stdclass;
  169. $o->url = $GLOBALS['ABSWEB'] . "ticket.php/$r->nsident";
  170. $o->link = "<a class='ticketlink' href='$o->url'>#$r->nsident $r->summary</a>";
  171. $res[] = $o;
  172. }
  173. }
  174. $quick = self::expand_quick_link($q);
  175. if ($quick) {
  176. /* prepend the quick link version */
  177. $o = new stdclass;
  178. $o->link = $quick[0];
  179. $o->url = $quick[1];
  180. array_unshift($res, $o);
  181. }
  182. /* catch all: take them to the main search page.
  183. * This is here because there are some quick search cases we don't
  184. * handle here, and they might want to see the help on searching */
  185. $o = new stdclass;
  186. $o->link = $truncated ? "<em>More results for $q</em>" :
  187. "<em>Search for $q</em>";
  188. $o->url = $GLOBALS['ABSWEB'] . "search.php?q=" . urlencode($q);
  189. $res[] = $o;
  190. return $res;
  191. }
  192. static function rest_query($method, $uri, $captures) {
  193. MTrackAPI::checkAllowed($method, 'GET');
  194. $q = MTrackAPI::getParam('q');
  195. $results = mtrack_cache(array('MTrackSearchDB', '_do_search'),
  196. array($q), 6);
  197. foreach ($results->results as $idx => $group) {
  198. if (!MTrackACL::hasAnyRights($group->object, 'read')) {
  199. unset($results->results[$idx]);
  200. }
  201. }
  202. return $results;
  203. }
  204. static function _do_search($q) {
  205. $start = microtime(true);
  206. $hits = self::search($q);
  207. $end = microtime(true);
  208. $searchTime = $end - $start;
  209. $start = $end;
  210. /* aggregate results by canonical object since we index comments
  211. * separately from the the top level item */
  212. $by_obj = array();
  213. $alias = array();
  214. foreach ($hits as $hit) {
  215. list($item, $id) = explode(':', $hit->objectid, 3);
  216. $object = "$item:$id";
  217. if (isset($alias[$object])) {
  218. $object = $alias[$object];
  219. }
  220. if (!isset($by_obj[$object])) {
  221. $H = new stdclass;
  222. $H->maxScore = $hit->score;
  223. $H->hits = array($hit);
  224. $H->object = $object;
  225. $H->aclid = $object;
  226. $H->type = $item;
  227. $H->id = $id;
  228. /* we may have indexed legacy change audit fields, which means
  229. * that we have to detect aliasing of identifiers (such as
  230. * milestone:id vs. milestone:name) and return the canonical
  231. * information via _get_object_info */
  232. $info = self::_get_object_info($H);
  233. if (!$info) {
  234. continue;
  235. }
  236. if (!isset($by_obj[$info->object])) {
  237. /* no entry yet; set it up */
  238. $H->object = $info->object;
  239. $H->_info = $info;
  240. $by_obj[$H->object] = $H;
  241. continue;
  242. }
  243. /* it's an alias */
  244. $alias[$object] = $info->object;
  245. $object = $info->object;
  246. }
  247. /* associate with existing entry */
  248. $H = $by_obj[$object];
  249. $H->hits[] = $hit;
  250. if ($hit->score > $H->maxScore) {
  251. $hit->maxScore = $hit->score;
  252. }
  253. }
  254. /* order that in descending score order */
  255. uasort($by_obj, array('MTrackSearchDB', '_cmp_hit_container'));
  256. $res = array();
  257. foreach ($by_obj as $H) {
  258. $items = array();
  259. /* we can get runs of hits to the same object with all the same
  260. * properties; collapse those down to unique hits */
  261. foreach ($H->hits as $hit) {
  262. /* if the engine gave us an excerpt, assume it is unique */
  263. if ($hit->excerpt) {
  264. $items[] = $hit;
  265. continue;
  266. }
  267. /* otherwise, it is quite likely a duplicate */
  268. $uniq = "$hit->objectid:$hit->score";
  269. if (isset($items[$uniq])) {
  270. continue;
  271. }
  272. $items[$uniq] = $hit;
  273. }
  274. $H->hits = array();
  275. $H->object = $H->_info->object;
  276. $H->url = $H->_info->url;
  277. $H->link = $H->_info->link;
  278. foreach ($items as $hit) {
  279. $H->hits[] = self::_get_hit_info($H, $H->_info->o, $hit);
  280. }
  281. unset($H->_info);
  282. $res[] = $H;
  283. }
  284. $end = microtime(true);
  285. $S = new stdclass;
  286. $S->searchTime = $searchTime;
  287. $S->renderTime = $end - $start;
  288. $S->results = $res;
  289. $S->query = $q;
  290. return $S;
  291. }
  292. static function _get_hit_info($H, $obj, $hit) {
  293. $item = new stdclass;
  294. $item->objectid = $hit->objectid;
  295. $item->score = $hit->score;
  296. $context = "";
  297. switch ($H->type) {
  298. case 'milestone':
  299. $context = $obj->description;
  300. break;
  301. case 'ticket':
  302. if (preg_match("/comment:(.*)$/", $hit->objectid, $M)) {
  303. $comment = $M[1];
  304. if (MTrackSearchDB::highlighterNeedsContext()) {
  305. $context = $obj->getComment($comment);
  306. }
  307. } else {
  308. $context = $obj->description;
  309. }
  310. break;
  311. case 'wiki':
  312. if (MTrackSearchDB::highlighterNeedsContext()) {
  313. $context = $obj->content;
  314. }
  315. break;
  316. }
  317. $item->excerpt = $hit->getExcerpt($context);
  318. return $item;
  319. }
  320. static function _get_object_info($H) {
  321. global $ABSWEB;
  322. switch ($H->type) {
  323. case 'milestone':
  324. static $milestone_name_to_id = null;
  325. static $milestone_cache = array();
  326. /* some change audit tables contain milestone:name instead
  327. * of milestone:id */
  328. if (!preg_match("/^\d+$/", $H->id)) {
  329. if ($milestone_name_to_id === null) {
  330. foreach (MTrackMilestone::enumMilestones() as $mid => $name) {
  331. $milestone_name_to_id[$name] = $mid;
  332. }
  333. }
  334. $mid = $milestone_name_to_id[$H->id];
  335. } else {
  336. $mid = $H->id;
  337. }
  338. $M = MTrackMilestone::loadById($mid);
  339. if (!$M) {
  340. return null;
  341. }
  342. $url = "{$ABSWEB}milestone.php/" .
  343. str_replace('%2F', '/', urlencode($M->name));
  344. $name = htmlentities($M->name, ENT_QUOTES, 'utf-8');
  345. $class = 'milestone';
  346. if ($M->deleted || $M->completed) {
  347. $class .= ' completed';
  348. }
  349. return (object)array(
  350. 'o' => $M,
  351. 'object' => "milestone:$M->mid",
  352. 'url' => $url,
  353. 'link' => "<span class='$class'><a href='$url'>$name</a></span>",
  354. );
  355. case 'ticket':
  356. $tkt = MTrackIssue::loadById($H->id);
  357. if (!$tkt) {
  358. return null;
  359. }
  360. $url = "{$ABSWEB}ticket.php/$tkt->nsident";
  361. return (object)array(
  362. 'o' => $tkt,
  363. 'object' => "ticket:$tkt->tid",
  364. 'url' => $url,
  365. 'link' => mtrack_ticket($tkt, array(
  366. 'display' => "#$tkt->nsident $tkt->summary"
  367. )),
  368. );
  369. case 'wiki':
  370. $wiki = null;
  371. if (MTrackSearchDB::highlighterNeedsContext()) {
  372. $wiki = MTrackWikiItem::loadByPageName($H->id);
  373. if (!$wiki) {
  374. return null;
  375. }
  376. }
  377. return (object)array(
  378. 'o' => $wiki,
  379. 'object' => "wiki:$H->id",
  380. 'url' => "{$ABSWEB}wiki.php/$H->id",
  381. 'link' => mtrack_wiki($H->id)
  382. );
  383. }
  384. return null;
  385. }
  386. static function _cmp_hit_container($A, $B) {
  387. return $B->maxScore - $A->maxScore;
  388. }
  389. }
  390. MTrackAPI::register('/search/query', 'MTrackSearchDB::rest_query');
  391. MTrackAPI::register('/search/query/array', 'MTrackSearchDB::rest_query_array');