xref: /dokuwiki/inc/Ui/Search.php (revision f9a94e78392d6a178b0e7f29a005688fc44e5cc3)
1<?php
2
3namespace dokuwiki\Ui;
4
5use dokuwiki\Extension\Event;
6use dokuwiki\Form\Form;
7use dokuwiki\Utf8\PhpString;
8use dokuwiki\Utf8\Sort;
9
10class Search extends Ui
11{
12    protected $query;
13    protected $parsedQuery;
14    protected $searchState;
15    protected $pageLookupResults = [];
16    protected $fullTextResults = [];
17    protected $highlight = [];
18
19    /**
20     * Search constructor.
21     *
22     * @param array $pageLookupResults pagename lookup results in the form [pagename => pagetitle]
23     * @param array $fullTextResults fulltext search results in the form [pagename => #hits]
24     * @param array $highlight array of strings to be highlighted
25     */
26    public function __construct(array $pageLookupResults, array $fullTextResults, $highlight)
27    {
28        global $QUERY;
29        $Indexer = idx_get_indexer();
30
31        $this->query = $QUERY;
32        $this->parsedQuery = ft_queryParser($Indexer, $QUERY);
33        $this->searchState = new SearchState($this->parsedQuery);
34
35        $this->pageLookupResults = $pageLookupResults;
36        $this->fullTextResults = $fullTextResults;
37        $this->highlight = $highlight;
38    }
39
40    /**
41     * display the search result
42     *
43     * @return void
44     */
45    public function show()
46    {
47        $searchHTML = $this->getSearchIntroHTML($this->query);
48
49        $searchHTML .= $this->getSearchFormHTML($this->query);
50
51        $searchHTML .= $this->getPageLookupHTML($this->pageLookupResults);
52
53        $searchHTML .= $this->getFulltextResultsHTML($this->fullTextResults, $this->highlight);
54
55        echo $searchHTML;
56    }
57
58    /**
59     * Get a form which can be used to adjust/refine the search
60     *
61     * @param string $query
62     *
63     * @return string
64     */
65    protected function getSearchFormHTML($query)
66    {
67        global $lang, $ID, $INPUT;
68
69        $searchForm = (new Form(['method' => 'get'], true))->addClass('search-results-form');
70        $searchForm->setHiddenField('do', 'search');
71        $searchForm->setHiddenField('id', $ID);
72        $searchForm->setHiddenField('sf', '1');
73        if ($INPUT->has('min')) {
74            $searchForm->setHiddenField('min', $INPUT->str('min'));
75        }
76        if ($INPUT->has('max')) {
77            $searchForm->setHiddenField('max', $INPUT->str('max'));
78        }
79        if ($INPUT->has('srt')) {
80            $searchForm->setHiddenField('srt', $INPUT->str('srt'));
81        }
82        $searchForm->addFieldsetOpen()->addClass('search-form');
83        $searchForm->addTextInput('q')->val($query)->useInput(false);
84        $searchForm->addButton('', $lang['btn_search'])->attr('type', 'submit');
85
86        $this->addSearchAssistanceElements($searchForm);
87
88        $searchForm->addFieldsetClose();
89
90        return $searchForm->toHTML('Search');
91    }
92
93    /**
94     * Add elements to adjust how the results are sorted
95     *
96     * @param Form $searchForm
97     */
98    protected function addSortTool(Form $searchForm)
99    {
100        global $INPUT, $lang;
101
102        $options = [
103            'hits' => [
104                'label' => $lang['search_sort_by_hits'],
105                'sort' => '',
106            ],
107            'mtime' => [
108                'label' => $lang['search_sort_by_mtime'],
109                'sort' => 'mtime',
110            ],
111        ];
112        $activeOption = 'hits';
113
114        if ($INPUT->str('srt') === 'mtime') {
115            $activeOption = 'mtime';
116        }
117
118        $searchForm->addTagOpen('div')->addClass('toggle')->attr('aria-haspopup', 'true');
119        // render current
120        $currentWrapper = $searchForm->addTagOpen('div')->addClass('current');
121        if ($activeOption !== 'hits') {
122            $currentWrapper->addClass('changed');
123        }
124        $searchForm->addHTML($options[$activeOption]['label']);
125        $searchForm->addTagClose('div');
126
127        // render options list
128        $searchForm->addTagOpen('ul')->attr('aria-expanded', 'false');
129
130        foreach ($options as $key => $option) {
131            $listItem = $searchForm->addTagOpen('li');
132
133            if ($key === $activeOption) {
134                $listItem->addClass('active');
135                $searchForm->addHTML($option['label']);
136            } else {
137                $link = $this->searchState->withSorting($option['sort'])->getSearchLink($option['label']);
138                $searchForm->addHTML($link);
139            }
140            $searchForm->addTagClose('li');
141        }
142        $searchForm->addTagClose('ul');
143
144        $searchForm->addTagClose('div');
145
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('@' . $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 = $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    /**
345     * Parse the full text results for their top namespaces below the given base namespace
346     *
347     * @param string $baseNS the namespace within which was searched, empty string for root namespace
348     *
349     * @return array an associative array with namespace => #number of found pages, sorted descending
350     */
351    protected function getAdditionalNamespacesFromResults($baseNS)
352    {
353        $namespaces = [];
354        $baseNSLength = strlen($baseNS);
355        foreach ($this->fullTextResults as $page => $numberOfHits) {
356            $namespace = getNS($page);
357            if (!$namespace) {
358                continue;
359            }
360            if ($namespace === $baseNS) {
361                continue;
362            }
363            $firstColon = strpos((string)$namespace, ':', $baseNSLength + 1) ?: strlen($namespace);
364            $subtopNS = substr($namespace, 0, $firstColon);
365            if (empty($namespaces[$subtopNS])) {
366                $namespaces[$subtopNS] = 0;
367            }
368            ++$namespaces[$subtopNS];
369        }
370        Sort::ksort($namespaces);
371        arsort($namespaces);
372        return $namespaces;
373    }
374
375    /**
376     * @ToDo: custom date input
377     *
378     * @param Form $searchForm
379     */
380    protected function addDateSelector(Form $searchForm)
381    {
382        global $INPUT, $lang;
383
384        $options = [
385            'any' => [
386                'before' => false,
387                'after' => false,
388                'label' => $lang['search_any_time'],
389            ],
390            'week' => [
391                'before' => false,
392                'after' => '1 week ago',
393                'label' => $lang['search_past_7_days'],
394            ],
395            'month' => [
396                'before' => false,
397                'after' => '1 month ago',
398                'label' => $lang['search_past_month'],
399            ],
400            'year' => [
401                'before' => false,
402                'after' => '1 year ago',
403                'label' => $lang['search_past_year'],
404            ],
405        ];
406        $activeOption = 'any';
407        foreach ($options as $key => $option) {
408            if ($INPUT->str('min') === $option['after']) {
409                $activeOption = $key;
410                break;
411            }
412        }
413
414        $searchForm->addTagOpen('div')->addClass('toggle')->attr('aria-haspopup', 'true');
415        // render current
416        $currentWrapper = $searchForm->addTagOpen('div')->addClass('current');
417        if ($INPUT->has('max') || $INPUT->has('min')) {
418            $currentWrapper->addClass('changed');
419        }
420        $searchForm->addHTML($options[$activeOption]['label']);
421        $searchForm->addTagClose('div');
422
423        // render options list
424        $searchForm->addTagOpen('ul')->attr('aria-expanded', 'false');
425
426        foreach ($options as $key => $option) {
427            $listItem = $searchForm->addTagOpen('li');
428
429            if ($key === $activeOption) {
430                $listItem->addClass('active');
431                $searchForm->addHTML($option['label']);
432            } else {
433                $link = $this->searchState
434                    ->withTimeLimitations($option['after'], $option['before'])
435                    ->getSearchLink($option['label']);
436                $searchForm->addHTML($link);
437            }
438            $searchForm->addTagClose('li');
439        }
440        $searchForm->addTagClose('ul');
441
442        $searchForm->addTagClose('div');
443    }
444
445
446    /**
447     * Build the intro text for the search page
448     *
449     * @param string $query the search query
450     *
451     * @return string
452     */
453    protected function getSearchIntroHTML($query)
454    {
455        global $lang;
456
457        $intro = p_locale_xhtml('searchpage');
458
459        $queryPagename = $this->createPagenameFromQuery($this->parsedQuery);
460        $createQueryPageLink = html_wikilink($queryPagename . '?do=edit', $queryPagename);
461
462        $pagecreateinfo = '';
463        if (auth_quickaclcheck($queryPagename) >= AUTH_CREATE) {
464            $pagecreateinfo = sprintf($lang['searchcreatepage'], $createQueryPageLink);
465        }
466        return str_replace(
467            ['@QUERY@', '@SEARCH@', '@CREATEPAGEINFO@'],
468            [hsc(rawurlencode($query)), hsc($query), $pagecreateinfo],
469            $intro
470        );
471    }
472
473    /**
474     * Create a pagename based the parsed search query
475     *
476     * @param array $parsedQuery
477     *
478     * @return string pagename constructed from the parsed query
479     */
480    public function createPagenameFromQuery($parsedQuery)
481    {
482        $cleanedQuery = cleanID($parsedQuery['query']); // already strtolowered
483        if ($cleanedQuery === PhpString::strtolower($parsedQuery['query'])) {
484            return ':' . $cleanedQuery;
485        }
486        $pagename = '';
487        if (!empty($parsedQuery['ns'])) {
488            $pagename .= ':' . cleanID($parsedQuery['ns'][0]);
489        }
490        $pagename .= ':' . cleanID(implode(' ', $parsedQuery['highlight']));
491        return $pagename;
492    }
493
494    /**
495     * Build HTML for a list of pages with matching pagenames
496     *
497     * @param array $data search results
498     *
499     * @return string
500     */
501    protected function getPageLookupHTML($data)
502    {
503        if (empty($data)) {
504            return '';
505        }
506
507        global $lang;
508
509        $html = '<div class="search_quickresult">';
510        $html .= '<h2>' . $lang['quickhits'] . ':</h2>';
511        $html .= '<ul class="search_quickhits">';
512        foreach (array_keys($data) as $id) {
513            $name = null;
514            if (!useHeading('navigation') && $ns = getNS($id)) {
515                $name = shorten(noNS($id), ' (' . $ns . ')', 30);
516            }
517            $link = html_wikilink(':' . $id, $name);
518            $eventData = [
519                'listItemContent' => [$link],
520                'page' => $id,
521            ];
522            Event::createAndTrigger('SEARCH_RESULT_PAGELOOKUP', $eventData);
523            $html .= '<li>' . implode('', $eventData['listItemContent']) . '</li>';
524        }
525        $html .= '</ul> ';
526        //clear float (see http://www.complexspiral.com/publications/containing-floats/)
527        $html .= '<div class="clearer"></div>';
528        $html .= '</div>';
529
530        return $html;
531    }
532
533    /**
534     * Build HTML for fulltext search results or "no results" message
535     *
536     * @param array $data the results of the fulltext search
537     * @param array $highlight the terms to be highlighted in the results
538     *
539     * @return string
540     */
541    protected function getFulltextResultsHTML($data, $highlight)
542    {
543        global $lang;
544
545        if (empty($data)) {
546            return '<div class="nothing">' . $lang['nothingfound'] . '</div>';
547        }
548
549        $html = '<div class="search_fulltextresult">';
550        $html .= '<h2>' . $lang['search_fullresults'] . ':</h2>';
551
552        $html .= '<dl class="search_results">';
553        $num = 0;
554        $position = 0;
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 <= FT_SNIPPET_NUMBER) { // create snippets for the first number of matches only
580                    $resultBody['snippet'] = ft_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