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