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