xref: /dokuwiki/inc/Ui/Search.php (revision c6b5b74a3fbcca16cc05abbfc719529279fb6ab0)
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->getSearchFormHTML($this->query);
47
48        $searchHTML .= $this->getSearchIntroHTML($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('searchPageForm', '1');
72        if ($INPUT->has('after')) {
73            $searchForm->setHiddenField('after', $INPUT->str('after'));
74        }
75        if ($INPUT->has('before')) {
76            $searchForm->setHiddenField('before', $INPUT->str('before'));
77        }
78        if ($INPUT->has('sort')) {
79            $searchForm->setHiddenField('sort', $INPUT->str('sort'));
80        }
81        $searchForm->addFieldsetOpen()->addClass('search-results-form__fieldset');
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('SEARCH_FORM_DISPLAY', $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('sort') === 'mtime') {
111            $activeOption = 'mtime';
112        }
113
114        $searchForm->addTagOpen('div')->addClass('search-tool js-search-tool');
115        // render current
116        $currentWrapper = $searchForm->addTagOpen('div')->addClass('search-tool__current js-current');
117        if ($activeOption !== 'hits') {
118            $currentWrapper->addClass('search-tool__current--changed');
119        }
120        $searchForm->addHTML($options[$activeOption]['label']);
121        $searchForm->addTagClose('div');
122
123        // render options list
124        $searchForm->addTagOpen('ul')->addClass('search-tool__options-list js-optionsList');
125
126        foreach ($options as $key => $option) {
127            $listItem = $searchForm->addTagOpen('li')->addClass('search-tool__options-list-item');
128
129            if ($key === $activeOption) {
130                $listItem->addClass('search-tool__options-list-item--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        $searchForm->addButton('toggleAssistant', 'toggle search assistant')
175            ->attr('type', 'button')
176            ->id('search-results-form__show-assistance-button')
177            ->addClass('search-results-form__show-assistance-button');
178
179        $searchForm->addTagOpen('div')
180            ->addClass('js-advancedSearchOptions')
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('search-tool js-search-tool');
251        // render current
252        $currentWrapper = $searchForm->addTagOpen('div')->addClass('search-tool__current js-current');
253        if ($activeOption !== 'exact') {
254            $currentWrapper->addClass('search-tool__current--changed');
255        }
256        $searchForm->addHTML($options[$activeOption]['label']);
257        $searchForm->addTagClose('div');
258
259        // render options list
260        $searchForm->addTagOpen('ul')->addClass('search-tool__options-list js-optionsList');
261
262        foreach ($options as $key => $option) {
263            $listItem = $searchForm->addTagOpen('li')->addClass('search-tool__options-list-item');
264
265            if ($key === $activeOption) {
266                $listItem->addClass('search-tool__options-list-item--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('search-tool js-search-tool');
302        // render current
303        $currentWrapper = $searchForm->addTagOpen('div')->addClass('search-tool__current js-current');
304        if ($baseNS) {
305            $currentWrapper->addClass('search-tool__current--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')->addClass('search-tool__options-list js-optionsList');
314
315        $listItem = $searchForm->addTagOpen('li')->addClass('search-tool__options-list-item');
316        if ($baseNS) {
317            $listItem->addClass('search-tool__options-list-item--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')->addClass('search-tool__options-list-item');
330            $label = $ns . ($count ? " ($count)" : '');
331
332            if ($ns === $baseNS) {
333                $listItem->addClass('search-tool__options-list-item--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        arsort($namespaces);
377        return $namespaces;
378    }
379
380    /**
381     * @ToDo: custom date input
382     *
383     * @param Form $searchForm
384     */
385    protected function addDateSelector(Form $searchForm)
386    {
387        global $INPUT, $lang;
388
389        $options = [
390            'any' => [
391                'before' => false,
392                'after' => false,
393                'label' => $lang['search_any_time'],
394            ],
395            'week' => [
396                'before' => false,
397                'after' => '1 week ago',
398                'label' => $lang['search_past_7_days'],
399            ],
400            'month' => [
401                'before' => false,
402                'after' => '1 month ago',
403                'label' => $lang['search_past_month'],
404            ],
405            'year' => [
406                'before' => false,
407                'after' => '1 year ago',
408                'label' => $lang['search_past_year'],
409            ],
410        ];
411        $activeOption = 'any';
412        foreach ($options as $key => $option) {
413            if ($INPUT->str('after') === $option['after']) {
414                $activeOption = $key;
415                break;
416            }
417        }
418
419        $searchForm->addTagOpen('div')->addClass('search-tool js-search-tool');
420        // render current
421        $currentWrapper = $searchForm->addTagOpen('div')->addClass('search-tool__current js-current');
422        if ($INPUT->has('before') || $INPUT->has('after')) {
423            $currentWrapper->addClass('search-tool__current--changed');
424        }
425        $searchForm->addHTML($options[$activeOption]['label']);
426        $searchForm->addTagClose('div');
427
428        // render options list
429        $searchForm->addTagOpen('ul')->addClass('search-tool__options-list js-optionsList');
430
431        foreach ($options as $key => $option) {
432            $listItem = $searchForm->addTagOpen('li')->addClass('search-tool__options-list-item');
433
434            if ($key === $activeOption) {
435                $listItem->addClass('search-tool__options-list-item--active');
436                $searchForm->addHTML($option['label']);
437            } else {
438                $this->searchState->addSearchLinkTime(
439                    $searchForm,
440                    $option['label'],
441                    $option['after'],
442                    $option['before']
443                );
444            }
445            $searchForm->addTagClose('li');
446        }
447        $searchForm->addTagClose('ul');
448
449        $searchForm->addTagClose('div');
450    }
451
452
453    /**
454     * Build the intro text for the search page
455     *
456     * @param string $query the search query
457     *
458     * @return string
459     */
460    protected function getSearchIntroHTML($query)
461    {
462        global $ID, $lang;
463
464        $intro = p_locale_xhtml('searchpage');
465        // allow use of placeholder in search intro
466        $pagecreateinfo = (auth_quickaclcheck($ID) >= AUTH_CREATE) ? $lang['searchcreatepage'] : '';
467        $intro = str_replace(
468            array('@QUERY@', '@SEARCH@', '@CREATEPAGEINFO@'),
469            array(hsc(rawurlencode($query)), hsc($query), $pagecreateinfo),
470            $intro
471        );
472        return $intro;
473    }
474
475    /**
476     * Build HTML for a list of pages with matching pagenames
477     *
478     * @param array $data search results
479     *
480     * @return string
481     */
482    protected function getPageLookupHTML($data)
483    {
484        if (empty($data)) {
485            return '';
486        }
487
488        global $lang;
489
490        $html = '<div class="search_quickresult">';
491        $html .= '<h3>' . $lang['quickhits'] . ':</h3>';
492        $html .= '<ul class="search_quickhits">';
493        foreach ($data as $id => $title) {
494            $link = html_wikilink(':' . $id);
495            $eventData = [
496                'listItemContent' => [$link],
497                'page' => $id,
498            ];
499            trigger_event('SEARCH_RESULT_PAGELOOKUP', $eventData);
500            $html .= '<li>' . implode('', $eventData['listItemContent']) . '</li>';
501        }
502        $html .= '</ul> ';
503        //clear float (see http://www.complexspiral.com/publications/containing-floats/)
504        $html .= '<div class="clearer"></div>';
505        $html .= '</div>';
506
507        return $html;
508    }
509
510    /**
511     * Build HTML for fulltext search results or "no results" message
512     *
513     * @param array $data      the results of the fulltext search
514     * @param array $highlight the terms to be highlighted in the results
515     *
516     * @return string
517     */
518    protected function getFulltextResultsHTML($data, $highlight)
519    {
520        global $lang;
521
522        if (empty($data)) {
523            return '<div class="nothing">' . $lang['nothingfound'] . '</div>';
524        }
525
526        $html = '';
527        $html .= '<dl class="search_results">';
528        $num = 1;
529
530        foreach ($data as $id => $cnt) {
531            $resultLink = html_wikilink(':' . $id, null, $highlight);
532
533            $resultHeader = [$resultLink];
534
535
536            $restrictQueryToNSLink = $this->restrictQueryToNSLink(getNS($id));
537            if ($restrictQueryToNSLink) {
538                $resultHeader[] = $restrictQueryToNSLink;
539            }
540
541            $snippet = '';
542            $lastMod = '';
543            $mtime = filemtime(wikiFN($id));
544            if ($cnt !== 0) {
545                $resultHeader[] = $cnt . ' ' . $lang['hits'];
546                if ($num < FT_SNIPPET_NUMBER) { // create snippets for the first number of matches only
547                    $snippet = '<dd>' . ft_snippet($id, $highlight) . '</dd>';
548                    $lastMod = '<span class="search_results__lastmod">' . $lang['lastmod'] . ' ';
549                    $lastMod .= '<time datetime="' . date_iso8601($mtime) . '">' . dformat($mtime) . '</time>';
550                    $lastMod .= '</span>';
551                }
552                $num++;
553            }
554
555            $metaLine = '<div class="search_results__metaLine">';
556            $metaLine .= $lastMod;
557            $metaLine .= '</div>';
558
559
560            $eventData = [
561                'resultHeader' => $resultHeader,
562                'resultBody' => [$metaLine, $snippet],
563                'page' => $id,
564            ];
565            trigger_event('SEARCH_RESULT_FULLPAGE', $eventData);
566            $html .= '<div class="search_fullpage_result">';
567            $html .= '<dt>' . implode(' ', $eventData['resultHeader']) . '</dt>';
568            $html .= implode('', $eventData['resultBody']);
569            $html .= '</div>';
570        }
571        $html .= '</dl>';
572
573        return $html;
574    }
575
576    /**
577     * create a link to restrict the current query to a namespace
578     *
579     * @param bool|string $ns the namespace to which to restrict the query
580     *
581     * @return bool|string
582     */
583    protected function restrictQueryToNSLink($ns)
584    {
585        if (!$ns) {
586            return false;
587        }
588        if (!$this->isNamespaceAssistanceAvailable($this->parsedQuery)) {
589            return false;
590        }
591        if (!empty($this->parsedQuery['ns']) && $this->parsedQuery['ns'][0] === $ns) {
592            return false;
593        }
594        $name = '@' . $ns;
595        $tmpForm = new Form();
596        $this->searchState->addSeachLinkNS($tmpForm, $name, $ns);
597        return $tmpForm->toHTML();
598    }
599}
600