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