Search.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. <?php
  2. namespace dokuwiki\Ui;
  3. use dokuwiki\Extension\Event;
  4. use dokuwiki\Form\Form;
  5. use dokuwiki\Utf8\PhpString;
  6. use dokuwiki\Utf8\Sort;
  7. class Search extends Ui
  8. {
  9. protected $query;
  10. protected $parsedQuery;
  11. protected $searchState;
  12. protected $pageLookupResults = array();
  13. protected $fullTextResults = array();
  14. protected $highlight = array();
  15. /**
  16. * Search constructor.
  17. *
  18. * @param array $pageLookupResults pagename lookup results in the form [pagename => pagetitle]
  19. * @param array $fullTextResults fulltext search results in the form [pagename => #hits]
  20. * @param array $highlight array of strings to be highlighted
  21. */
  22. public function __construct(array $pageLookupResults, array $fullTextResults, $highlight)
  23. {
  24. global $QUERY;
  25. $Indexer = idx_get_indexer();
  26. $this->query = $QUERY;
  27. $this->parsedQuery = ft_queryParser($Indexer, $QUERY);
  28. $this->searchState = new SearchState($this->parsedQuery);
  29. $this->pageLookupResults = $pageLookupResults;
  30. $this->fullTextResults = $fullTextResults;
  31. $this->highlight = $highlight;
  32. }
  33. /**
  34. * display the search result
  35. *
  36. * @return void
  37. */
  38. public function show()
  39. {
  40. $searchHTML = $this->getSearchIntroHTML($this->query);
  41. $searchHTML .= $this->getSearchFormHTML($this->query);
  42. $searchHTML .= $this->getPageLookupHTML($this->pageLookupResults);
  43. $searchHTML .= $this->getFulltextResultsHTML($this->fullTextResults, $this->highlight);
  44. echo $searchHTML;
  45. }
  46. /**
  47. * Get a form which can be used to adjust/refine the search
  48. *
  49. * @param string $query
  50. *
  51. * @return string
  52. */
  53. protected function getSearchFormHTML($query)
  54. {
  55. global $lang, $ID, $INPUT;
  56. $searchForm = (new Form(['method' => 'get'], true))->addClass('search-results-form');
  57. $searchForm->setHiddenField('do', 'search');
  58. $searchForm->setHiddenField('id', $ID);
  59. $searchForm->setHiddenField('sf', '1');
  60. if ($INPUT->has('min')) {
  61. $searchForm->setHiddenField('min', $INPUT->str('min'));
  62. }
  63. if ($INPUT->has('max')) {
  64. $searchForm->setHiddenField('max', $INPUT->str('max'));
  65. }
  66. if ($INPUT->has('srt')) {
  67. $searchForm->setHiddenField('srt', $INPUT->str('srt'));
  68. }
  69. $searchForm->addFieldsetOpen()->addClass('search-form');
  70. $searchForm->addTextInput('q')->val($query)->useInput(false);
  71. $searchForm->addButton('', $lang['btn_search'])->attr('type', 'submit');
  72. $this->addSearchAssistanceElements($searchForm);
  73. $searchForm->addFieldsetClose();
  74. return $searchForm->toHTML('Search');
  75. }
  76. /**
  77. * Add elements to adjust how the results are sorted
  78. *
  79. * @param Form $searchForm
  80. */
  81. protected function addSortTool(Form $searchForm)
  82. {
  83. global $INPUT, $lang;
  84. $options = [
  85. 'hits' => [
  86. 'label' => $lang['search_sort_by_hits'],
  87. 'sort' => '',
  88. ],
  89. 'mtime' => [
  90. 'label' => $lang['search_sort_by_mtime'],
  91. 'sort' => 'mtime',
  92. ],
  93. ];
  94. $activeOption = 'hits';
  95. if ($INPUT->str('srt') === 'mtime') {
  96. $activeOption = 'mtime';
  97. }
  98. $searchForm->addTagOpen('div')->addClass('toggle')->attr('aria-haspopup', 'true');
  99. // render current
  100. $currentWrapper = $searchForm->addTagOpen('div')->addClass('current');
  101. if ($activeOption !== 'hits') {
  102. $currentWrapper->addClass('changed');
  103. }
  104. $searchForm->addHTML($options[$activeOption]['label']);
  105. $searchForm->addTagClose('div');
  106. // render options list
  107. $searchForm->addTagOpen('ul')->attr('aria-expanded', 'false');
  108. foreach ($options as $key => $option) {
  109. $listItem = $searchForm->addTagOpen('li');
  110. if ($key === $activeOption) {
  111. $listItem->addClass('active');
  112. $searchForm->addHTML($option['label']);
  113. } else {
  114. $link = $this->searchState->withSorting($option['sort'])->getSearchLink($option['label']);
  115. $searchForm->addHTML($link);
  116. }
  117. $searchForm->addTagClose('li');
  118. }
  119. $searchForm->addTagClose('ul');
  120. $searchForm->addTagClose('div');
  121. }
  122. /**
  123. * Check if the query is simple enough to modify its namespace limitations without breaking the rest of the query
  124. *
  125. * @param array $parsedQuery
  126. *
  127. * @return bool
  128. */
  129. protected function isNamespaceAssistanceAvailable(array $parsedQuery) {
  130. if (preg_match('/[\(\)\|]/', $parsedQuery['query']) === 1) {
  131. return false;
  132. }
  133. return true;
  134. }
  135. /**
  136. * Check if the query is simple enough to modify the fragment search behavior without breaking the rest of the query
  137. *
  138. * @param array $parsedQuery
  139. *
  140. * @return bool
  141. */
  142. protected function isFragmentAssistanceAvailable(array $parsedQuery) {
  143. if (preg_match('/[\(\)\|]/', $parsedQuery['query']) === 1) {
  144. return false;
  145. }
  146. if (!empty($parsedQuery['phrases'])) {
  147. return false;
  148. }
  149. return true;
  150. }
  151. /**
  152. * Add the elements to be used for search assistance
  153. *
  154. * @param Form $searchForm
  155. */
  156. protected function addSearchAssistanceElements(Form $searchForm)
  157. {
  158. $searchForm->addTagOpen('div')
  159. ->addClass('advancedOptions')
  160. ->attr('style', 'display: none;')
  161. ->attr('aria-hidden', 'true');
  162. $this->addFragmentBehaviorLinks($searchForm);
  163. $this->addNamespaceSelector($searchForm);
  164. $this->addDateSelector($searchForm);
  165. $this->addSortTool($searchForm);
  166. $searchForm->addTagClose('div');
  167. }
  168. /**
  169. * Add the elements to adjust the fragment search behavior
  170. *
  171. * @param Form $searchForm
  172. */
  173. protected function addFragmentBehaviorLinks(Form $searchForm)
  174. {
  175. if (!$this->isFragmentAssistanceAvailable($this->parsedQuery)) {
  176. return;
  177. }
  178. global $lang;
  179. $options = [
  180. 'exact' => [
  181. 'label' => $lang['search_exact_match'],
  182. 'and' => array_map(function ($term) {
  183. return trim($term, '*');
  184. }, $this->parsedQuery['and']),
  185. 'not' => array_map(function ($term) {
  186. return trim($term, '*');
  187. }, $this->parsedQuery['not']),
  188. ],
  189. 'starts' => [
  190. 'label' => $lang['search_starts_with'],
  191. 'and' => array_map(function ($term) {
  192. return trim($term, '*') . '*';
  193. }, $this->parsedQuery['and']),
  194. 'not' => array_map(function ($term) {
  195. return trim($term, '*') . '*';
  196. }, $this->parsedQuery['not']),
  197. ],
  198. 'ends' => [
  199. 'label' => $lang['search_ends_with'],
  200. 'and' => array_map(function ($term) {
  201. return '*' . trim($term, '*');
  202. }, $this->parsedQuery['and']),
  203. 'not' => array_map(function ($term) {
  204. return '*' . trim($term, '*');
  205. }, $this->parsedQuery['not']),
  206. ],
  207. 'contains' => [
  208. 'label' => $lang['search_contains'],
  209. 'and' => array_map(function ($term) {
  210. return '*' . trim($term, '*') . '*';
  211. }, $this->parsedQuery['and']),
  212. 'not' => array_map(function ($term) {
  213. return '*' . trim($term, '*') . '*';
  214. }, $this->parsedQuery['not']),
  215. ]
  216. ];
  217. // detect current
  218. $activeOption = 'custom';
  219. foreach ($options as $key => $option) {
  220. if ($this->parsedQuery['and'] === $option['and']) {
  221. $activeOption = $key;
  222. }
  223. }
  224. if ($activeOption === 'custom') {
  225. $options = array_merge(['custom' => [
  226. 'label' => $lang['search_custom_match'],
  227. ]], $options);
  228. }
  229. $searchForm->addTagOpen('div')->addClass('toggle')->attr('aria-haspopup', 'true');
  230. // render current
  231. $currentWrapper = $searchForm->addTagOpen('div')->addClass('current');
  232. if ($activeOption !== 'exact') {
  233. $currentWrapper->addClass('changed');
  234. }
  235. $searchForm->addHTML($options[$activeOption]['label']);
  236. $searchForm->addTagClose('div');
  237. // render options list
  238. $searchForm->addTagOpen('ul')->attr('aria-expanded', 'false');
  239. foreach ($options as $key => $option) {
  240. $listItem = $searchForm->addTagOpen('li');
  241. if ($key === $activeOption) {
  242. $listItem->addClass('active');
  243. $searchForm->addHTML($option['label']);
  244. } else {
  245. $link = $this->searchState
  246. ->withFragments($option['and'], $option['not'])
  247. ->getSearchLink($option['label'])
  248. ;
  249. $searchForm->addHTML($link);
  250. }
  251. $searchForm->addTagClose('li');
  252. }
  253. $searchForm->addTagClose('ul');
  254. $searchForm->addTagClose('div');
  255. // render options list
  256. }
  257. /**
  258. * Add the elements for the namespace selector
  259. *
  260. * @param Form $searchForm
  261. */
  262. protected function addNamespaceSelector(Form $searchForm)
  263. {
  264. if (!$this->isNamespaceAssistanceAvailable($this->parsedQuery)) {
  265. return;
  266. }
  267. global $lang;
  268. $baseNS = empty($this->parsedQuery['ns']) ? '' : $this->parsedQuery['ns'][0];
  269. $extraNS = $this->getAdditionalNamespacesFromResults($baseNS);
  270. $searchForm->addTagOpen('div')->addClass('toggle')->attr('aria-haspopup', 'true');
  271. // render current
  272. $currentWrapper = $searchForm->addTagOpen('div')->addClass('current');
  273. if ($baseNS) {
  274. $currentWrapper->addClass('changed');
  275. $searchForm->addHTML('@' . $baseNS);
  276. } else {
  277. $searchForm->addHTML($lang['search_any_ns']);
  278. }
  279. $searchForm->addTagClose('div');
  280. // render options list
  281. $searchForm->addTagOpen('ul')->attr('aria-expanded', 'false');
  282. $listItem = $searchForm->addTagOpen('li');
  283. if ($baseNS) {
  284. $listItem->addClass('active');
  285. $link = $this->searchState->withNamespace('')->getSearchLink($lang['search_any_ns']);
  286. $searchForm->addHTML($link);
  287. } else {
  288. $searchForm->addHTML($lang['search_any_ns']);
  289. }
  290. $searchForm->addTagClose('li');
  291. foreach ($extraNS as $ns => $count) {
  292. $listItem = $searchForm->addTagOpen('li');
  293. $label = $ns . ($count ? " <bdi>($count)</bdi>" : '');
  294. if ($ns === $baseNS) {
  295. $listItem->addClass('active');
  296. $searchForm->addHTML($label);
  297. } else {
  298. $link = $this->searchState->withNamespace($ns)->getSearchLink($label);
  299. $searchForm->addHTML($link);
  300. }
  301. $searchForm->addTagClose('li');
  302. }
  303. $searchForm->addTagClose('ul');
  304. $searchForm->addTagClose('div');
  305. }
  306. /**
  307. * Parse the full text results for their top namespaces below the given base namespace
  308. *
  309. * @param string $baseNS the namespace within which was searched, empty string for root namespace
  310. *
  311. * @return array an associative array with namespace => #number of found pages, sorted descending
  312. */
  313. protected function getAdditionalNamespacesFromResults($baseNS)
  314. {
  315. $namespaces = [];
  316. $baseNSLength = strlen($baseNS);
  317. foreach ($this->fullTextResults as $page => $numberOfHits) {
  318. $namespace = getNS($page);
  319. if (!$namespace) {
  320. continue;
  321. }
  322. if ($namespace === $baseNS) {
  323. continue;
  324. }
  325. $firstColon = strpos((string)$namespace, ':', $baseNSLength + 1) ?: strlen($namespace);
  326. $subtopNS = substr($namespace, 0, $firstColon);
  327. if (empty($namespaces[$subtopNS])) {
  328. $namespaces[$subtopNS] = 0;
  329. }
  330. $namespaces[$subtopNS] += 1;
  331. }
  332. Sort::ksort($namespaces);
  333. arsort($namespaces);
  334. return $namespaces;
  335. }
  336. /**
  337. * @ToDo: custom date input
  338. *
  339. * @param Form $searchForm
  340. */
  341. protected function addDateSelector(Form $searchForm)
  342. {
  343. global $INPUT, $lang;
  344. $options = [
  345. 'any' => [
  346. 'before' => false,
  347. 'after' => false,
  348. 'label' => $lang['search_any_time'],
  349. ],
  350. 'week' => [
  351. 'before' => false,
  352. 'after' => '1 week ago',
  353. 'label' => $lang['search_past_7_days'],
  354. ],
  355. 'month' => [
  356. 'before' => false,
  357. 'after' => '1 month ago',
  358. 'label' => $lang['search_past_month'],
  359. ],
  360. 'year' => [
  361. 'before' => false,
  362. 'after' => '1 year ago',
  363. 'label' => $lang['search_past_year'],
  364. ],
  365. ];
  366. $activeOption = 'any';
  367. foreach ($options as $key => $option) {
  368. if ($INPUT->str('min') === $option['after']) {
  369. $activeOption = $key;
  370. break;
  371. }
  372. }
  373. $searchForm->addTagOpen('div')->addClass('toggle')->attr('aria-haspopup', 'true');
  374. // render current
  375. $currentWrapper = $searchForm->addTagOpen('div')->addClass('current');
  376. if ($INPUT->has('max') || $INPUT->has('min')) {
  377. $currentWrapper->addClass('changed');
  378. }
  379. $searchForm->addHTML($options[$activeOption]['label']);
  380. $searchForm->addTagClose('div');
  381. // render options list
  382. $searchForm->addTagOpen('ul')->attr('aria-expanded', 'false');
  383. foreach ($options as $key => $option) {
  384. $listItem = $searchForm->addTagOpen('li');
  385. if ($key === $activeOption) {
  386. $listItem->addClass('active');
  387. $searchForm->addHTML($option['label']);
  388. } else {
  389. $link = $this->searchState
  390. ->withTimeLimitations($option['after'], $option['before'])
  391. ->getSearchLink($option['label'])
  392. ;
  393. $searchForm->addHTML($link);
  394. }
  395. $searchForm->addTagClose('li');
  396. }
  397. $searchForm->addTagClose('ul');
  398. $searchForm->addTagClose('div');
  399. }
  400. /**
  401. * Build the intro text for the search page
  402. *
  403. * @param string $query the search query
  404. *
  405. * @return string
  406. */
  407. protected function getSearchIntroHTML($query)
  408. {
  409. global $lang;
  410. $intro = p_locale_xhtml('searchpage');
  411. $queryPagename = $this->createPagenameFromQuery($this->parsedQuery);
  412. $createQueryPageLink = html_wikilink($queryPagename . '?do=edit', $queryPagename);
  413. $pagecreateinfo = '';
  414. if (auth_quickaclcheck($queryPagename) >= AUTH_CREATE) {
  415. $pagecreateinfo = sprintf($lang['searchcreatepage'], $createQueryPageLink);
  416. }
  417. return str_replace(
  418. array('@QUERY@', '@SEARCH@', '@CREATEPAGEINFO@'),
  419. array(hsc(rawurlencode($query)), hsc($query), $pagecreateinfo),
  420. $intro
  421. );
  422. }
  423. /**
  424. * Create a pagename based the parsed search query
  425. *
  426. * @param array $parsedQuery
  427. *
  428. * @return string pagename constructed from the parsed query
  429. */
  430. public function createPagenameFromQuery($parsedQuery)
  431. {
  432. $cleanedQuery = cleanID($parsedQuery['query']); // already strtolowered
  433. if ($cleanedQuery === PhpString::strtolower($parsedQuery['query'])) {
  434. return ':' . $cleanedQuery;
  435. }
  436. $pagename = '';
  437. if (!empty($parsedQuery['ns'])) {
  438. $pagename .= ':' . cleanID($parsedQuery['ns'][0]);
  439. }
  440. $pagename .= ':' . cleanID(implode(' ' , $parsedQuery['highlight']));
  441. return $pagename;
  442. }
  443. /**
  444. * Build HTML for a list of pages with matching pagenames
  445. *
  446. * @param array $data search results
  447. *
  448. * @return string
  449. */
  450. protected function getPageLookupHTML($data)
  451. {
  452. if (empty($data)) {
  453. return '';
  454. }
  455. global $lang;
  456. $html = '<div class="search_quickresult">';
  457. $html .= '<h2>' . $lang['quickhits'] . ':</h2>';
  458. $html .= '<ul class="search_quickhits">';
  459. foreach ($data as $id => $title) {
  460. $name = null;
  461. if (!useHeading('navigation') && $ns = getNS($id)) {
  462. $name = shorten(noNS($id), ' (' . $ns . ')', 30);
  463. }
  464. $link = html_wikilink(':' . $id, $name);
  465. $eventData = [
  466. 'listItemContent' => [$link],
  467. 'page' => $id,
  468. ];
  469. Event::createAndTrigger('SEARCH_RESULT_PAGELOOKUP', $eventData);
  470. $html .= '<li>' . implode('', $eventData['listItemContent']) . '</li>';
  471. }
  472. $html .= '</ul> ';
  473. //clear float (see http://www.complexspiral.com/publications/containing-floats/)
  474. $html .= '<div class="clearer"></div>';
  475. $html .= '</div>';
  476. return $html;
  477. }
  478. /**
  479. * Build HTML for fulltext search results or "no results" message
  480. *
  481. * @param array $data the results of the fulltext search
  482. * @param array $highlight the terms to be highlighted in the results
  483. *
  484. * @return string
  485. */
  486. protected function getFulltextResultsHTML($data, $highlight)
  487. {
  488. global $lang;
  489. if (empty($data)) {
  490. return '<div class="nothing">' . $lang['nothingfound'] . '</div>';
  491. }
  492. $html = '<div class="search_fulltextresult">';
  493. $html .= '<h2>' . $lang['search_fullresults'] . ':</h2>';
  494. $html .= '<dl class="search_results">';
  495. $num = 0;
  496. $position = 0;
  497. foreach ($data as $id => $cnt) {
  498. $position += 1;
  499. $resultLink = html_wikilink(':' . $id, null, $highlight);
  500. $resultHeader = [$resultLink];
  501. $restrictQueryToNSLink = $this->restrictQueryToNSLink(getNS($id));
  502. if ($restrictQueryToNSLink) {
  503. $resultHeader[] = $restrictQueryToNSLink;
  504. }
  505. $resultBody = [];
  506. $mtime = filemtime(wikiFN($id));
  507. $lastMod = '<span class="lastmod">' . $lang['lastmod'] . '</span> ';
  508. $lastMod .= '<time datetime="' . date_iso8601($mtime) . '" title="' . dformat($mtime) . '">' .
  509. dformat($mtime, '%f') .
  510. '</time>';
  511. $resultBody['meta'] = $lastMod;
  512. if ($cnt !== 0) {
  513. $num++;
  514. $hits = '<span class="hits">' . $cnt . ' ' . $lang['hits'] . '</span>, ';
  515. $resultBody['meta'] = $hits . $resultBody['meta'];
  516. if ($num <= FT_SNIPPET_NUMBER) { // create snippets for the first number of matches only
  517. $resultBody['snippet'] = ft_snippet($id, $highlight);
  518. }
  519. }
  520. $eventData = [
  521. 'resultHeader' => $resultHeader,
  522. 'resultBody' => $resultBody,
  523. 'page' => $id,
  524. 'position' => $position,
  525. ];
  526. Event::createAndTrigger('SEARCH_RESULT_FULLPAGE', $eventData);
  527. $html .= '<div class="search_fullpage_result">';
  528. $html .= '<dt>' . implode(' ', $eventData['resultHeader']) . '</dt>';
  529. foreach ($eventData['resultBody'] as $class => $htmlContent) {
  530. $html .= "<dd class=\"$class\">$htmlContent</dd>";
  531. }
  532. $html .= '</div>';
  533. }
  534. $html .= '</dl>';
  535. $html .= '</div>';
  536. return $html;
  537. }
  538. /**
  539. * create a link to restrict the current query to a namespace
  540. *
  541. * @param false|string $ns the namespace to which to restrict the query
  542. *
  543. * @return false|string
  544. */
  545. protected function restrictQueryToNSLink($ns)
  546. {
  547. if (!$ns) {
  548. return false;
  549. }
  550. if (!$this->isNamespaceAssistanceAvailable($this->parsedQuery)) {
  551. return false;
  552. }
  553. if (!empty($this->parsedQuery['ns']) && $this->parsedQuery['ns'][0] === $ns) {
  554. return false;
  555. }
  556. $name = '@' . $ns;
  557. return $this->searchState->withNamespace($ns)->getSearchLink($name);
  558. }
  559. }