xref: /dokuwiki/inc/Ui/Search.php (revision 2ce8affc66a5825b8eb9f401e287a2315987d62a)
1<?php
2
3namespace dokuwiki\Ui;
4
5use \dokuwiki\Form\Form;
6
7class Search extends Ui
8{
9    protected $query;
10    protected $parsedQuery;
11    protected $searchState;
12    protected $pageLookupResults = array();
13    protected $fullTextResults = array();
14    protected $highlight = array();
15
16    /**
17     * Search constructor.
18     *
19     * @param array  $pageLookupResults
20     * @param array  $fullTextResults
21     * @param string $highlight
22     */
23    public function __construct(array $pageLookupResults, array $fullTextResults, $highlight)
24    {
25        global $QUERY;
26        $Indexer = idx_get_indexer();
27
28        $this->query = $QUERY;
29        $this->parsedQuery = ft_queryParser($Indexer, $QUERY);
30        $this->searchState = new SearchState($this->parsedQuery);
31
32        $this->pageLookupResults = $pageLookupResults;
33        $this->fullTextResults = $fullTextResults;
34        $this->highlight = $highlight;
35    }
36
37    /**
38     * display the search result
39     *
40     * @return void
41     */
42    public function show()
43    {
44        $searchHTML = '';
45
46        $searchHTML .= $this->getSearchIntroHTML($this->query);
47
48        $searchHTML .= $this->getSearchFormHTML($this->query);
49
50        $searchHTML .= $this->getPageLookupHTML($this->pageLookupResults);
51
52        $searchHTML .= $this->getFulltextResultsHTML($this->fullTextResults, $this->highlight);
53
54        echo $searchHTML;
55    }
56
57    /**
58     * Get a form which can be used to adjust/refine the search
59     *
60     * @param string $query
61     *
62     * @return string
63     */
64    protected function getSearchFormHTML($query)
65    {
66        global $lang, $ID, $INPUT;
67
68        $searchForm = (new Form())->attrs(['method' => 'get'])->addClass('search-results-form');
69        $searchForm->setHiddenField('do', 'search');
70        $searchForm->setHiddenField('id', $ID);
71        $searchForm->setHiddenField('sf', '1');
72        if ($INPUT->has('dta')) {
73            $searchForm->setHiddenField('dta', $INPUT->str('dta'));
74        }
75        if ($INPUT->has('dtb')) {
76            $searchForm->setHiddenField('dtb', $INPUT->str('dtb'));
77        }
78        if ($INPUT->has('srt')) {
79            $searchForm->setHiddenField('srt', $INPUT->str('srt'));
80        }
81        $searchForm->addFieldsetOpen()->addClass('search-form');
82        $searchForm->addTextInput('q')->val($query)->useInput(false);
83        $searchForm->addButton('', $lang['btn_search'])->attr('type', 'submit');
84
85        $this->addSearchAssistanceElements($searchForm);
86
87        $searchForm->addFieldsetClose();
88
89        trigger_event('FORM_SEARCH_OUTPUT', $searchForm);
90
91        return $searchForm->toHTML();
92    }
93
94    protected function addSortTool(Form $searchForm)
95    {
96        global $INPUT, $lang;
97
98        $options = [
99            'hits' => [
100                'label' => $lang['search_sort_by_hits'],
101                'sort' => '',
102            ],
103            'mtime' => [
104                'label' => $lang['search_sort_by_mtime'],
105                'sort' => 'mtime',
106            ],
107        ];
108        $activeOption = 'hits';
109
110        if ($INPUT->str('srt') === 'mtime') {
111            $activeOption = 'mtime';
112        }
113
114        $searchForm->addTagOpen('div')->addClass('toggle');
115        // render current
116        $currentWrapper = $searchForm->addTagOpen('div')->addClass('current');
117        if ($activeOption !== 'hits') {
118            $currentWrapper->addClass('changed');
119        }
120        $searchForm->addHTML($options[$activeOption]['label']);
121        $searchForm->addTagClose('div');
122
123        // render options list
124        $searchForm->addTagOpen('ul');
125
126        foreach ($options as $key => $option) {
127            $listItem = $searchForm->addTagOpen('li');
128
129            if ($key === $activeOption) {
130                $listItem->addClass('active');
131                $searchForm->addHTML($option['label']);
132            } else {
133                $this->searchState->addSearchLinkSort(
134                    $searchForm,
135                    $option['label'],
136                    $option['sort']
137                );
138            }
139            $searchForm->addTagClose('li');
140        }
141        $searchForm->addTagClose('ul');
142
143        $searchForm->addTagClose('div');
144
145    }
146
147    protected function isNamespaceAssistanceAvailable(array $parsedQuery) {
148        if (preg_match('/[\(\)\|]/', $parsedQuery['query']) === 1) {
149            return false;
150        }
151
152        return true;
153    }
154
155    protected function isFragmentAssistanceAvailable(array $parsedQuery) {
156        if (preg_match('/[\(\)\|]/', $parsedQuery['query']) === 1) {
157            return false;
158        }
159
160        if (!empty($parsedQuery['phrases'])) {
161            return false;
162        }
163
164        return true;
165    }
166
167    /**
168     * Add the elements to be used for search assistance
169     *
170     * @param Form $searchForm
171     */
172    protected function addSearchAssistanceElements(Form $searchForm)
173    {
174        // FIXME localize
175        $searchForm->addButton('toggleAssistant', 'toggle search assistant')
176            ->attr('type', 'button')
177            ->addClass('toggleAssistant');
178
179        $searchForm->addTagOpen('div')
180            ->addClass('advancedOptions')
181            ->attr('style', 'display: none;');
182
183        $this->addFragmentBehaviorLinks($searchForm);
184        $this->addNamespaceSelector($searchForm);
185        $this->addDateSelector($searchForm);
186        $this->addSortTool($searchForm);
187
188        $searchForm->addTagClose('div');
189    }
190
191    protected function addFragmentBehaviorLinks(Form $searchForm)
192    {
193        if (!$this->isFragmentAssistanceAvailable($this->parsedQuery)) {
194            return;
195        }
196        global $lang;
197
198        $options = [
199            'exact' => [
200                'label' => $lang['search_exact_match'],
201                'and' => array_map(function ($term) {
202                    return trim($term, '*');
203                }, $this->parsedQuery['and']),
204                'not' => array_map(function ($term) {
205                    return trim($term, '*');
206                }, $this->parsedQuery['not']),
207            ],
208            'starts' => [
209                'label' => $lang['search_starts_with'],
210                'and' => array_map(function ($term) {
211                    return trim($term, '*') . '*';
212                }, $this->parsedQuery['and']),
213                'not' => array_map(function ($term) {
214                    return trim($term, '*') . '*';
215                }, $this->parsedQuery['not']),
216            ],
217            'ends' => [
218                'label' => $lang['search_ends_with'],
219                'and' => array_map(function ($term) {
220                    return '*' . trim($term, '*');
221                }, $this->parsedQuery['and']),
222                'not' => array_map(function ($term) {
223                    return '*' . trim($term, '*');
224                }, $this->parsedQuery['not']),
225            ],
226            'contains' => [
227                'label' => $lang['search_contains'],
228                'and' => array_map(function ($term) {
229                    return '*' . trim($term, '*') . '*';
230                }, $this->parsedQuery['and']),
231                'not' => array_map(function ($term) {
232                    return '*' . trim($term, '*') . '*';
233                }, $this->parsedQuery['not']),
234            ]
235        ];
236
237        // detect current
238        $activeOption = 'custom';
239        foreach ($options as $key => $option) {
240            if ($this->parsedQuery['and'] === $option['and']) {
241                $activeOption = $key;
242            }
243        }
244        if ($activeOption === 'custom') {
245            $options = array_merge(['custom' => [
246                'label' => $lang['search_custom_match'],
247            ]], $options);
248        }
249
250        $searchForm->addTagOpen('div')->addClass('toggle');
251        // render current
252        $currentWrapper = $searchForm->addTagOpen('div')->addClass('current');
253        if ($activeOption !== 'exact') {
254            $currentWrapper->addClass('changed');
255        }
256        $searchForm->addHTML($options[$activeOption]['label']);
257        $searchForm->addTagClose('div');
258
259        // render options list
260        $searchForm->addTagOpen('ul');
261
262        foreach ($options as $key => $option) {
263            $listItem = $searchForm->addTagOpen('li');
264
265            if ($key === $activeOption) {
266                $listItem->addClass('active');
267                $searchForm->addHTML($option['label']);
268            } else {
269                $this->searchState->addSearchLinkFragment(
270                    $searchForm,
271                    $option['label'],
272                    $option['and'],
273                    $option['not']
274                );
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');
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');
314
315        $listItem = $searchForm->addTagOpen('li');
316        if ($baseNS) {
317            $listItem->addClass('active');
318            $this->searchState->addSeachLinkNS(
319                $searchForm,
320                $lang['search_any_ns'],
321                ''
322            );
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 = $ns . ($count ? " ($count)" : '');
331
332            if ($ns === $baseNS) {
333                $listItem->addClass('active');
334                $searchForm->addHTML($label);
335            } else {
336                $this->searchState->addSeachLinkNS(
337                    $searchForm,
338                    $label,
339                    $ns
340                );
341            }
342            $searchForm->addTagClose('li');
343        }
344        $searchForm->addTagClose('ul');
345
346        $searchForm->addTagClose('div');
347
348    }
349
350    /**
351     * Parse the full text results for their top namespaces below the given base namespace
352     *
353     * @param string $baseNS the namespace within which was searched, empty string for root namespace
354     *
355     * @return array an associative array with namespace => #number of found pages, sorted descending
356     */
357    protected function getAdditionalNamespacesFromResults($baseNS)
358    {
359        $namespaces = [];
360        $baseNSLength = strlen($baseNS);
361        foreach ($this->fullTextResults as $page => $numberOfHits) {
362            $namespace = getNS($page);
363            if (!$namespace) {
364                continue;
365            }
366            if ($namespace === $baseNS) {
367                continue;
368            }
369            $firstColon = strpos((string)$namespace, ':', $baseNSLength + 1) ?: strlen($namespace);
370            $subtopNS = substr($namespace, 0, $firstColon);
371            if (empty($namespaces[$subtopNS])) {
372                $namespaces[$subtopNS] = 0;
373            }
374            $namespaces[$subtopNS] += 1;
375        }
376        ksort($namespaces);
377        arsort($namespaces);
378        return $namespaces;
379    }
380
381    /**
382     * @ToDo: custom date input
383     *
384     * @param Form $searchForm
385     */
386    protected function addDateSelector(Form $searchForm)
387    {
388        global $INPUT, $lang;
389
390        $options = [
391            'any' => [
392                'before' => false,
393                'after' => false,
394                'label' => $lang['search_any_time'],
395            ],
396            'week' => [
397                'before' => false,
398                'after' => '1 week ago',
399                'label' => $lang['search_past_7_days'],
400            ],
401            'month' => [
402                'before' => false,
403                'after' => '1 month ago',
404                'label' => $lang['search_past_month'],
405            ],
406            'year' => [
407                'before' => false,
408                'after' => '1 year ago',
409                'label' => $lang['search_past_year'],
410            ],
411        ];
412        $activeOption = 'any';
413        foreach ($options as $key => $option) {
414            if ($INPUT->str('dta') === $option['after']) {
415                $activeOption = $key;
416                break;
417            }
418        }
419
420        $searchForm->addTagOpen('div')->addClass('toggle');
421        // render current
422        $currentWrapper = $searchForm->addTagOpen('div')->addClass('current');
423        if ($INPUT->has('dtb') || $INPUT->has('dta')) {
424            $currentWrapper->addClass('changed');
425        }
426        $searchForm->addHTML($options[$activeOption]['label']);
427        $searchForm->addTagClose('div');
428
429        // render options list
430        $searchForm->addTagOpen('ul');
431
432        foreach ($options as $key => $option) {
433            $listItem = $searchForm->addTagOpen('li');
434
435            if ($key === $activeOption) {
436                $listItem->addClass('active');
437                $searchForm->addHTML($option['label']);
438            } else {
439                $this->searchState->addSearchLinkTime(
440                    $searchForm,
441                    $option['label'],
442                    $option['after'],
443                    $option['before']
444                );
445            }
446            $searchForm->addTagClose('li');
447        }
448        $searchForm->addTagClose('ul');
449
450        $searchForm->addTagClose('div');
451    }
452
453
454    /**
455     * Build the intro text for the search page
456     *
457     * @param string $query the search query
458     *
459     * @return string
460     */
461    protected function getSearchIntroHTML($query)
462    {
463        global $lang;
464
465        $intro = p_locale_xhtml('searchpage');
466
467        $queryPagename = $this->createPagenameFromQuery($this->parsedQuery);
468        $createQueryPageLink = html_wikilink($queryPagename . '?do=edit', $queryPagename);
469
470        $pagecreateinfo = '';
471        if (auth_quickaclcheck($queryPagename) >= AUTH_CREATE) {
472            $pagecreateinfo = sprintf($lang['searchcreatepage'], $createQueryPageLink);
473        }
474        $intro = str_replace(
475            array('@QUERY@', '@SEARCH@', '@CREATEPAGEINFO@'),
476            array(hsc(rawurlencode($query)), hsc($query), $pagecreateinfo),
477            $intro
478        );
479
480        return $intro;
481    }
482
483    /**
484     * Create a pagename based the parsed search query
485     *
486     * @param array $parsedQuery
487     *
488     * @return string pagename constructed from the parsed query
489     */
490    protected function createPagenameFromQuery($parsedQuery)
491    {
492        $pagename = '';
493        if (!empty($parsedQuery['ns'])) {
494            $pagename .= cleanID($parsedQuery['ns'][0]);
495        }
496        $pagename .= ':' . cleanID(implode(' ' , $parsedQuery['highlight']));
497        return $pagename;
498    }
499
500    /**
501     * Build HTML for a list of pages with matching pagenames
502     *
503     * @param array $data search results
504     *
505     * @return string
506     */
507    protected function getPageLookupHTML($data)
508    {
509        if (empty($data)) {
510            return '';
511        }
512
513        global $lang;
514
515        $html = '<div class="search_quickresult">';
516        $html .= '<h3>' . $lang['quickhits'] . ':</h3>';
517        $html .= '<ul class="search_quickhits">';
518        foreach ($data as $id => $title) {
519            $link = html_wikilink(':' . $id);
520            $eventData = [
521                'listItemContent' => [$link],
522                'page' => $id,
523            ];
524            trigger_event('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 .= '<h3>' . $lang['search_fullresults'] . ':</h3>';
553
554        $html .= '<dl class="search_results">';
555        $num = 1;
556
557        foreach ($data as $id => $cnt) {
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            $snippet = '';
569            $lastMod = '';
570            $mtime = filemtime(wikiFN($id));
571            if ($cnt !== 0) {
572                $resultHeader[] = $cnt . ' ' . $lang['hits'];
573                if ($num < FT_SNIPPET_NUMBER) { // create snippets for the first number of matches only
574                    $snippet = '<dd>' . ft_snippet($id, $highlight) . '</dd>';
575                    $lastMod = '<span class="search_results__lastmod">' . $lang['lastmod'] . ' ';
576                    $lastMod .= '<time datetime="' . date_iso8601($mtime) . '">' . dformat($mtime) . '</time>';
577                    $lastMod .= '</span>';
578                }
579                $num++;
580            }
581
582            $metaLine = '<div class="search_results__metaLine">';
583            $metaLine .= $lastMod;
584            $metaLine .= '</div>';
585
586
587            $eventData = [
588                'resultHeader' => $resultHeader,
589                'resultBody' => [$metaLine, $snippet],
590                'page' => $id,
591            ];
592            trigger_event('SEARCH_RESULT_FULLPAGE', $eventData);
593            $html .= '<div class="search_fullpage_result">';
594            $html .= '<dt>' . implode(' ', $eventData['resultHeader']) . '</dt>';
595            $html .= implode('', $eventData['resultBody']);
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 bool|string $ns the namespace to which to restrict the query
609     *
610     * @return bool|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        $name = '@' . $ns;
624        $tmpForm = new Form();
625        $this->searchState->addSeachLinkNS($tmpForm, $name, $ns);
626        return $tmpForm->toHTML();
627    }
628}
629