You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

626 lines
20 KiB

  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 = [];
  13. protected $fullTextResults = [];
  14. protected $highlight = [];
  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. {
  131. if (preg_match('/[\(\)\|]/', $parsedQuery['query']) === 1) {
  132. return false;
  133. }
  134. return true;
  135. }
  136. /**
  137. * Check if the query is simple enough to modify the fragment search behavior without breaking the rest of the query
  138. *
  139. * @param array $parsedQuery
  140. *
  141. * @return bool
  142. */
  143. protected function isFragmentAssistanceAvailable(array $parsedQuery)
  144. {
  145. if (preg_match('/[\(\)\|]/', $parsedQuery['query']) === 1) {
  146. return false;
  147. }
  148. if (!empty($parsedQuery['phrases'])) {
  149. return false;
  150. }
  151. return true;
  152. }
  153. /**
  154. * Add the elements to be used for search assistance
  155. *
  156. * @param Form $searchForm
  157. */
  158. protected function addSearchAssistanceElements(Form $searchForm)
  159. {
  160. $searchForm->addTagOpen('div')
  161. ->addClass('advancedOptions')
  162. ->attr('style', 'display: none;')
  163. ->attr('aria-hidden', 'true');
  164. $this->addFragmentBehaviorLinks($searchForm);
  165. $this->addNamespaceSelector($searchForm);
  166. $this->addDateSelector($searchForm);
  167. $this->addSortTool($searchForm);
  168. $searchForm->addTagClose('div');
  169. }
  170. /**
  171. * Add the elements to adjust the fragment search behavior
  172. *
  173. * @param Form $searchForm
  174. */
  175. protected function addFragmentBehaviorLinks(Form $searchForm)
  176. {
  177. if (!$this->isFragmentAssistanceAvailable($this->parsedQuery)) {
  178. return;
  179. }
  180. global $lang;
  181. $options = [
  182. 'exact' => [
  183. 'label' => $lang['search_exact_match'],
  184. 'and' => array_map(static fn($term) => trim($term, '*'), $this->parsedQuery['and']),
  185. 'not' => array_map(static fn($term) => trim($term, '*'), $this->parsedQuery['not']),
  186. ],
  187. 'starts' => [
  188. 'label' => $lang['search_starts_with'],
  189. 'and' => array_map(static fn($term) => trim($term, '*') . '*', $this->parsedQuery['and']),
  190. 'not' => array_map(static fn($term) => trim($term, '*') . '*', $this->parsedQuery['not']),
  191. ],
  192. 'ends' => [
  193. 'label' => $lang['search_ends_with'],
  194. 'and' => array_map(static fn($term) => '*' . trim($term, '*'), $this->parsedQuery['and']),
  195. 'not' => array_map(static fn($term) => '*' . trim($term, '*'), $this->parsedQuery['not']),
  196. ],
  197. 'contains' => [
  198. 'label' => $lang['search_contains'],
  199. 'and' => array_map(static fn($term) => '*' . trim($term, '*') . '*', $this->parsedQuery['and']),
  200. 'not' => array_map(static fn($term) => '*' . trim($term, '*') . '*', $this->parsedQuery['not']),
  201. ]
  202. ];
  203. // detect current
  204. $activeOption = 'custom';
  205. foreach ($options as $key => $option) {
  206. if ($this->parsedQuery['and'] === $option['and']) {
  207. $activeOption = $key;
  208. }
  209. }
  210. if ($activeOption === 'custom') {
  211. $options = array_merge(['custom' => [
  212. 'label' => $lang['search_custom_match'],
  213. ]], $options);
  214. }
  215. $searchForm->addTagOpen('div')->addClass('toggle')->attr('aria-haspopup', 'true');
  216. // render current
  217. $currentWrapper = $searchForm->addTagOpen('div')->addClass('current');
  218. if ($activeOption !== 'exact') {
  219. $currentWrapper->addClass('changed');
  220. }
  221. $searchForm->addHTML($options[$activeOption]['label']);
  222. $searchForm->addTagClose('div');
  223. // render options list
  224. $searchForm->addTagOpen('ul')->attr('aria-expanded', 'false');
  225. foreach ($options as $key => $option) {
  226. $listItem = $searchForm->addTagOpen('li');
  227. if ($key === $activeOption) {
  228. $listItem->addClass('active');
  229. $searchForm->addHTML($option['label']);
  230. } else {
  231. $link = $this->searchState
  232. ->withFragments($option['and'], $option['not'])
  233. ->getSearchLink($option['label']);
  234. $searchForm->addHTML($link);
  235. }
  236. $searchForm->addTagClose('li');
  237. }
  238. $searchForm->addTagClose('ul');
  239. $searchForm->addTagClose('div');
  240. // render options list
  241. }
  242. /**
  243. * Add the elements for the namespace selector
  244. *
  245. * @param Form $searchForm
  246. */
  247. protected function addNamespaceSelector(Form $searchForm)
  248. {
  249. if (!$this->isNamespaceAssistanceAvailable($this->parsedQuery)) {
  250. return;
  251. }
  252. global $lang;
  253. $baseNS = empty($this->parsedQuery['ns']) ? '' : $this->parsedQuery['ns'][0];
  254. $extraNS = $this->getAdditionalNamespacesFromResults($baseNS);
  255. $searchForm->addTagOpen('div')->addClass('toggle')->attr('aria-haspopup', 'true');
  256. // render current
  257. $currentWrapper = $searchForm->addTagOpen('div')->addClass('current');
  258. if ($baseNS) {
  259. $currentWrapper->addClass('changed');
  260. $searchForm->addHTML('@' . $baseNS);
  261. } else {
  262. $searchForm->addHTML($lang['search_any_ns']);
  263. }
  264. $searchForm->addTagClose('div');
  265. // render options list
  266. $searchForm->addTagOpen('ul')->attr('aria-expanded', 'false');
  267. $listItem = $searchForm->addTagOpen('li');
  268. if ($baseNS) {
  269. $listItem->addClass('active');
  270. $link = $this->searchState->withNamespace('')->getSearchLink($lang['search_any_ns']);
  271. $searchForm->addHTML($link);
  272. } else {
  273. $searchForm->addHTML($lang['search_any_ns']);
  274. }
  275. $searchForm->addTagClose('li');
  276. foreach ($extraNS as $ns => $count) {
  277. $listItem = $searchForm->addTagOpen('li');
  278. $label = $ns . ($count ? " <bdi>($count)</bdi>" : '');
  279. if ($ns === $baseNS) {
  280. $listItem->addClass('active');
  281. $searchForm->addHTML($label);
  282. } else {
  283. $link = $this->searchState->withNamespace($ns)->getSearchLink($label);
  284. $searchForm->addHTML($link);
  285. }
  286. $searchForm->addTagClose('li');
  287. }
  288. $searchForm->addTagClose('ul');
  289. $searchForm->addTagClose('div');
  290. }
  291. /**
  292. * Parse the full text results for their top namespaces below the given base namespace
  293. *
  294. * @param string $baseNS the namespace within which was searched, empty string for root namespace
  295. *
  296. * @return array an associative array with namespace => #number of found pages, sorted descending
  297. */
  298. protected function getAdditionalNamespacesFromResults($baseNS)
  299. {
  300. $namespaces = [];
  301. $baseNSLength = strlen($baseNS);
  302. foreach ($this->fullTextResults as $page => $numberOfHits) {
  303. $namespace = getNS($page);
  304. if (!$namespace) {
  305. continue;
  306. }
  307. if ($namespace === $baseNS) {
  308. continue;
  309. }
  310. $firstColon = strpos((string)$namespace, ':', $baseNSLength + 1) ?: strlen($namespace);
  311. $subtopNS = substr($namespace, 0, $firstColon);
  312. if (empty($namespaces[$subtopNS])) {
  313. $namespaces[$subtopNS] = 0;
  314. }
  315. ++$namespaces[$subtopNS];
  316. }
  317. Sort::ksort($namespaces);
  318. arsort($namespaces);
  319. return $namespaces;
  320. }
  321. /**
  322. * @ToDo: custom date input
  323. *
  324. * @param Form $searchForm
  325. */
  326. protected function addDateSelector(Form $searchForm)
  327. {
  328. global $INPUT, $lang;
  329. $options = [
  330. 'any' => [
  331. 'before' => false,
  332. 'after' => false,
  333. 'label' => $lang['search_any_time'],
  334. ],
  335. 'week' => [
  336. 'before' => false,
  337. 'after' => '1 week ago',
  338. 'label' => $lang['search_past_7_days'],
  339. ],
  340. 'month' => [
  341. 'before' => false,
  342. 'after' => '1 month ago',
  343. 'label' => $lang['search_past_month'],
  344. ],
  345. 'year' => [
  346. 'before' => false,
  347. 'after' => '1 year ago',
  348. 'label' => $lang['search_past_year'],
  349. ],
  350. ];
  351. $activeOption = 'any';
  352. foreach ($options as $key => $option) {
  353. if ($INPUT->str('min') === $option['after']) {
  354. $activeOption = $key;
  355. break;
  356. }
  357. }
  358. $searchForm->addTagOpen('div')->addClass('toggle')->attr('aria-haspopup', 'true');
  359. // render current
  360. $currentWrapper = $searchForm->addTagOpen('div')->addClass('current');
  361. if ($INPUT->has('max') || $INPUT->has('min')) {
  362. $currentWrapper->addClass('changed');
  363. }
  364. $searchForm->addHTML($options[$activeOption]['label']);
  365. $searchForm->addTagClose('div');
  366. // render options list
  367. $searchForm->addTagOpen('ul')->attr('aria-expanded', 'false');
  368. foreach ($options as $key => $option) {
  369. $listItem = $searchForm->addTagOpen('li');
  370. if ($key === $activeOption) {
  371. $listItem->addClass('active');
  372. $searchForm->addHTML($option['label']);
  373. } else {
  374. $link = $this->searchState
  375. ->withTimeLimitations($option['after'], $option['before'])
  376. ->getSearchLink($option['label']);
  377. $searchForm->addHTML($link);
  378. }
  379. $searchForm->addTagClose('li');
  380. }
  381. $searchForm->addTagClose('ul');
  382. $searchForm->addTagClose('div');
  383. }
  384. /**
  385. * Build the intro text for the search page
  386. *
  387. * @param string $query the search query
  388. *
  389. * @return string
  390. */
  391. protected function getSearchIntroHTML($query)
  392. {
  393. global $lang;
  394. $intro = p_locale_xhtml('searchpage');
  395. $queryPagename = $this->createPagenameFromQuery($this->parsedQuery);
  396. $createQueryPageLink = html_wikilink($queryPagename . '?do=edit', $queryPagename);
  397. $pagecreateinfo = '';
  398. if (auth_quickaclcheck($queryPagename) >= AUTH_CREATE) {
  399. $pagecreateinfo = sprintf($lang['searchcreatepage'], $createQueryPageLink);
  400. }
  401. return str_replace(
  402. ['@QUERY@', '@SEARCH@', '@CREATEPAGEINFO@'],
  403. [hsc(rawurlencode($query)), hsc($query), $pagecreateinfo],
  404. $intro
  405. );
  406. }
  407. /**
  408. * Create a pagename based the parsed search query
  409. *
  410. * @param array $parsedQuery
  411. *
  412. * @return string pagename constructed from the parsed query
  413. */
  414. public function createPagenameFromQuery($parsedQuery)
  415. {
  416. $cleanedQuery = cleanID($parsedQuery['query']); // already strtolowered
  417. if ($cleanedQuery === PhpString::strtolower($parsedQuery['query'])) {
  418. return ':' . $cleanedQuery;
  419. }
  420. $pagename = '';
  421. if (!empty($parsedQuery['ns'])) {
  422. $pagename .= ':' . cleanID($parsedQuery['ns'][0]);
  423. }
  424. $pagename .= ':' . cleanID(implode(' ', $parsedQuery['highlight']));
  425. return $pagename;
  426. }
  427. /**
  428. * Build HTML for a list of pages with matching pagenames
  429. *
  430. * @param array $data search results
  431. *
  432. * @return string
  433. */
  434. protected function getPageLookupHTML($data)
  435. {
  436. if (empty($data)) {
  437. return '';
  438. }
  439. global $lang;
  440. $html = '<div class="search_quickresult">';
  441. $html .= '<h2>' . $lang['quickhits'] . ':</h2>';
  442. $html .= '<ul class="search_quickhits">';
  443. foreach (array_keys($data) as $id) {
  444. $name = null;
  445. if (!useHeading('navigation') && $ns = getNS($id)) {
  446. $name = shorten(noNS($id), ' (' . $ns . ')', 30);
  447. }
  448. $link = html_wikilink(':' . $id, $name);
  449. $eventData = [
  450. 'listItemContent' => [$link],
  451. 'page' => $id,
  452. ];
  453. Event::createAndTrigger('SEARCH_RESULT_PAGELOOKUP', $eventData);
  454. $html .= '<li>' . implode('', $eventData['listItemContent']) . '</li>';
  455. }
  456. $html .= '</ul> ';
  457. //clear float (see http://www.complexspiral.com/publications/containing-floats/)
  458. $html .= '<div class="clearer"></div>';
  459. $html .= '</div>';
  460. return $html;
  461. }
  462. /**
  463. * Build HTML for fulltext search results or "no results" message
  464. *
  465. * @param array $data the results of the fulltext search
  466. * @param array $highlight the terms to be highlighted in the results
  467. *
  468. * @return string
  469. */
  470. protected function getFulltextResultsHTML($data, $highlight)
  471. {
  472. global $lang;
  473. if (empty($data)) {
  474. return '<div class="nothing">' . $lang['nothingfound'] . '</div>';
  475. }
  476. $html = '<div class="search_fulltextresult">';
  477. $html .= '<h2>' . $lang['search_fullresults'] . ':</h2>';
  478. $html .= '<dl class="search_results">';
  479. $num = 0;
  480. $position = 0;
  481. foreach ($data as $id => $cnt) {
  482. ++$position;
  483. $resultLink = html_wikilink(':' . $id, null, $highlight);
  484. $resultHeader = [$resultLink];
  485. $restrictQueryToNSLink = $this->restrictQueryToNSLink(getNS($id));
  486. if ($restrictQueryToNSLink) {
  487. $resultHeader[] = $restrictQueryToNSLink;
  488. }
  489. $resultBody = [];
  490. $mtime = filemtime(wikiFN($id));
  491. $lastMod = '<span class="lastmod">' . $lang['lastmod'] . '</span> ';
  492. $lastMod .= '<time datetime="' . date_iso8601($mtime) . '" title="' . dformat($mtime) . '">' .
  493. dformat($mtime, '%f') .
  494. '</time>';
  495. $resultBody['meta'] = $lastMod;
  496. if ($cnt !== 0) {
  497. $num++;
  498. $hits = '<span class="hits">' . $cnt . ' ' . $lang['hits'] . '</span>, ';
  499. $resultBody['meta'] = $hits . $resultBody['meta'];
  500. if ($num <= FT_SNIPPET_NUMBER) { // create snippets for the first number of matches only
  501. $resultBody['snippet'] = ft_snippet($id, $highlight);
  502. }
  503. }
  504. $eventData = [
  505. 'resultHeader' => $resultHeader,
  506. 'resultBody' => $resultBody,
  507. 'page' => $id,
  508. 'position' => $position,
  509. ];
  510. Event::createAndTrigger('SEARCH_RESULT_FULLPAGE', $eventData);
  511. $html .= '<div class="search_fullpage_result">';
  512. $html .= '<dt>' . implode(' ', $eventData['resultHeader']) . '</dt>';
  513. foreach ($eventData['resultBody'] as $class => $htmlContent) {
  514. $html .= "<dd class=\"$class\">$htmlContent</dd>";
  515. }
  516. $html .= '</div>';
  517. }
  518. $html .= '</dl>';
  519. $html .= '</div>';
  520. return $html;
  521. }
  522. /**
  523. * create a link to restrict the current query to a namespace
  524. *
  525. * @param false|string $ns the namespace to which to restrict the query
  526. *
  527. * @return false|string
  528. */
  529. protected function restrictQueryToNSLink($ns)
  530. {
  531. if (!$ns) {
  532. return false;
  533. }
  534. if (!$this->isNamespaceAssistanceAvailable($this->parsedQuery)) {
  535. return false;
  536. }
  537. if (!empty($this->parsedQuery['ns']) && $this->parsedQuery['ns'][0] === $ns) {
  538. return false;
  539. }
  540. $name = '@' . $ns;
  541. return $this->searchState->withNamespace($ns)->getSearchLink($name);
  542. }
  543. }