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