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