xref: /dokuwiki/inc/Ui/Search.php (revision b3cfe85a261b366332dc92300bb46852820c27c7)
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 $pageLookupResults = array();
12    protected $fullTextResults = array();
13    protected $highlight = array();
14
15    /**
16     * Search constructor.
17     *
18     * @param string $query the search query
19     */
20    public function __construct()
21    {
22        global $QUERY;
23        $Indexer = idx_get_indexer();
24
25        $this->query = $QUERY;
26        $this->parsedQuery = ft_queryParser($Indexer, $QUERY);
27    }
28
29    /**
30     * run the search
31     */
32    public function execute()
33    {
34        $this->pageLookupResults = $this->filterResultsByTime(
35            ft_pageLookup($this->query, true, useHeading('navigation'))
36        );
37        $this->fullTextResults = $this->filterResultsByTime(
38            ft_pageSearch($this->query, $highlight)
39        );
40        $this->highlight = $highlight;
41    }
42
43    /**
44     * @param array $results search results in the form pageid => value
45     *
46     * @return array
47     */
48    protected function filterResultsByTime(array $results) {
49        global $INPUT;
50        if ($INPUT->has('after') || $INPUT->has('before')) {
51            $after = $INPUT->str('after');
52            $after = is_int($after) ? $after : strtotime($after);
53
54            $before = $INPUT->str('before');
55            $before = is_int($before) ? $before : strtotime($before);
56
57            // todo: should we filter $this->pageLookupResults as well?
58            foreach ($results as $id => $value) {
59                $mTime = filemtime(wikiFN($id));
60                if ($after && $after > $mTime) {
61                    unset($results[$id]);
62                    continue;
63                }
64                if ($before && $before < $mTime) {
65                    unset($results[$id]);
66                }
67            }
68        }
69
70        return $results;
71    }
72
73    /**
74     * display the search result
75     *
76     * @return void
77     */
78    public function show()
79    {
80        $searchHTML = '';
81
82        $searchHTML .= $this->getSearchFormHTML($this->query);
83
84        $searchHTML .= $this->getSearchIntroHTML($this->query);
85
86        $searchHTML .= $this->getPageLookupHTML($this->pageLookupResults);
87
88        $searchHTML .= $this->getFulltextResultsHTML($this->fullTextResults, $this->highlight);
89
90        echo $searchHTML;
91    }
92
93    /**
94     * Get a form which can be used to adjust/refine the search
95     *
96     * @param string $query
97     *
98     * @return string
99     */
100    protected function getSearchFormHTML($query)
101    {
102        global $lang, $ID, $INPUT;
103
104        $searchForm = (new Form())->attrs(['method' => 'get'])->addClass('search-results-form');
105        $searchForm->setHiddenField('do', 'search');
106        $searchForm->setHiddenField('id', $ID);
107        $searchForm->setHiddenField('searchPageForm', '1');
108        if ($INPUT->has('after')) {
109            $searchForm->setHiddenField('after', $INPUT->str('after'));
110        }
111        if ($INPUT->has('before')) {
112            $searchForm->setHiddenField('before', $INPUT->str('before'));
113        }
114        $searchForm->addFieldsetOpen()->addClass('search-results-form__fieldset');
115        $searchForm->addTextInput('q')->val($query)->useInput(false);
116        $searchForm->addButton('', $lang['btn_search'])->attr('type', 'submit');
117
118        if ($this->isSearchAssistanceAvailable($this->parsedQuery)) {
119            $this->addSearchAssistanceElements($searchForm, $this->parsedQuery);
120        } else {
121            $searchForm->addClass('search-results-form--no-assistance');
122            $searchForm->addTagOpen('span')->addClass('search-results-form__no-assistance-message');
123            $searchForm->addHTML('FIXME Your query is too complex. Search assistance is unavailable. See <a href="https://doku.wiki/search">doku.wiki/search</a> for more help.');
124            $searchForm->addTagClose('span');
125        }
126
127        $searchForm->addFieldsetClose();
128
129        trigger_event('SEARCH_FORM_DISPLAY', $searchForm);
130
131        return $searchForm->toHTML();
132    }
133
134    /**
135     * Decide if the given query is simple enough to provide search assistance
136     *
137     * @param array $parsedQuery
138     *
139     * @return bool
140     */
141    protected function isSearchAssistanceAvailable(array $parsedQuery)
142    {
143        if (count($parsedQuery['words']) > 1) {
144            return false;
145        }
146        if (!empty($parsedQuery['not'])) {
147            return false;
148        }
149
150        if (!empty($parsedQuery['phrases'])) {
151            return false;
152        }
153
154        if (!empty($parsedQuery['notns'])) {
155            return false;
156        }
157        if (count($parsedQuery['ns']) > 1) {
158            return false;
159        }
160
161        return true;
162    }
163
164    /**
165     * Add the elements to be used for search assistance
166     *
167     * @param Form  $searchForm
168     * @param array $parsedQuery
169     */
170    protected function addSearchAssistanceElements(Form $searchForm, array $parsedQuery)
171    {
172        $searchForm->addButton('toggleAssistant', 'toggle search assistant')
173            ->attr('type', 'button')
174            ->id('search-results-form__show-assistance-button')
175            ->addClass('search-results-form__show-assistance-button');
176
177        $searchForm->addTagOpen('div')
178            ->addClass('js-advancedSearchOptions')
179            ->attr('style', 'display: none;');
180
181        $this->addFragmentBehaviorLinks($searchForm, $parsedQuery);
182        $this->addNamespaceSelector($searchForm, $parsedQuery);
183        $this->addDateSelector($searchForm, $parsedQuery);
184
185        $searchForm->addTagClose('div');
186    }
187
188    protected function addFragmentBehaviorLinks(Form $searchForm, array $parsedQuery)
189    {
190        $searchForm->addTagOpen('div')->addClass('search-results-form__subwrapper');
191        $searchForm->addHTML('fragment behavior: ');
192
193        $this->addSearchLink(
194            $searchForm,
195            'exact match',
196            array_map(function($term){return trim($term, '*');},$this->parsedQuery['and'])
197        );
198
199        $searchForm->addHTML(', ');
200
201        $this->addSearchLink(
202            $searchForm,
203            'starts with',
204            array_map(function($term){return trim($term, '*') . '*';},$this->parsedQuery['and'])
205        );
206
207        $searchForm->addHTML(', ');
208
209        $this->addSearchLink(
210            $searchForm,
211            'ends with',
212            array_map(function($term){return '*' . trim($term, '*');},$this->parsedQuery['and'])
213        );
214
215        $searchForm->addHTML(', ');
216
217        $this->addSearchLink(
218            $searchForm,
219            'contains',
220            array_map(function($term){return '*' . trim($term, '*') . '*';},$this->parsedQuery['and'])
221        );
222
223        $searchForm->addTagClose('div');
224    }
225
226    protected function addSearchLink(
227        Form $searchForm,
228        $label,
229        array $and = null,
230        array $ns = null,
231        array $not = null,
232        array $notns = null,
233        array $phrases = null,
234        $after = null,
235        $before = null
236    ) {
237        global $INPUT, $ID;
238        if (null === $and) {
239            $and = $this->parsedQuery['and'];
240        }
241        if (null === $ns) {
242            $ns = $this->parsedQuery['ns'];
243        }
244        if (null === $not) {
245            $not = $this->parsedQuery['not'];
246        }
247        if (null === $phrases) {
248            $phrases = $this->parsedQuery['phrases'];
249        }
250        if (null === $notns) {
251            $notns = $this->parsedQuery['notns'];
252        }
253        if (null === $after) {
254            $after = $INPUT->str('after');
255        }
256        if (null === $before) {
257            $before = $INPUT->str('before');
258        }
259
260        $newQuery = ft_queryUnparser_simple(
261            $and,
262            $not,
263            $phrases,
264            $ns,
265            $notns
266        );
267        $hrefAttributes = ['do' => 'search', 'searchPageForm' => '1', 'q' => $newQuery];
268        if ($after) {
269            $hrefAttributes['after'] = $after;
270        }
271        if ($before) {
272            $hrefAttributes['before'] = $before;
273        }
274        $searchForm->addTagOpen('a')
275            ->attrs([
276                'href' => wl($ID, $hrefAttributes, false, '&')
277            ])
278        ;
279        $searchForm->addHTML($label);
280        $searchForm->addTagClose('a');
281    }
282
283    /**
284     * Add the elements for the namespace selector
285     *
286     * @param Form  $searchForm
287     * @param array $parsedQuery
288     */
289    protected function addNamespaceSelector(Form $searchForm, array $parsedQuery)
290    {
291        $baseNS = empty($parsedQuery['ns']) ? '' : $parsedQuery['ns'][0];
292        $searchForm->addTagOpen('div')->addClass('search-results-form__subwrapper');
293
294        $extraNS = $this->getAdditionalNamespacesFromResults($baseNS);
295        if (!empty($extraNS) || $baseNS) {
296            $searchForm->addTagOpen('div');
297            $searchForm->addHTML('limit to namespace: ');
298
299            if ($baseNS) {
300                $this->addSearchLink(
301                    $searchForm,
302                    '(remove limit)',
303                    null,
304                    [],
305                    null,
306                    []
307                );
308            }
309
310            foreach ($extraNS as $extraNS => $count) {
311                $searchForm->addHTML(' ');
312                $label = $extraNS . ($count ? " ($count)" : '');
313
314                $this->addSearchLink($searchForm, $label, null, [$extraNS], null, []);
315            }
316            $searchForm->addTagClose('div');
317        }
318
319        $searchForm->addTagClose('div');
320    }
321
322    /**
323     * Parse the full text results for their top namespaces below the given base namespace
324     *
325     * @param string $baseNS the namespace within which was searched, empty string for root namespace
326     *
327     * @return array an associative array with namespace => #number of found pages, sorted descending
328     */
329    protected function getAdditionalNamespacesFromResults($baseNS)
330    {
331        $namespaces = [];
332        $baseNSLength = strlen($baseNS);
333        foreach ($this->fullTextResults as $page => $numberOfHits) {
334            $namespace = getNS($page);
335            if (!$namespace) {
336                continue;
337            }
338            if ($namespace === $baseNS) {
339                continue;
340            }
341            $firstColon = strpos((string)$namespace, ':', $baseNSLength + 1) ?: strlen($namespace);
342            $subtopNS = substr($namespace, 0, $firstColon);
343            if (empty($namespaces[$subtopNS])) {
344                $namespaces[$subtopNS] = 0;
345            }
346            $namespaces[$subtopNS] += 1;
347        }
348        arsort($namespaces);
349        return $namespaces;
350    }
351
352    /**
353     * @ToDo: we need to remember this date when clicking on other links
354     * @ToDo: custom date input
355     *
356     * @param Form $searchForm
357     * @param      $parsedQuery
358     */
359    protected function addDateSelector(Form $searchForm, $parsedQuery) {
360        $searchForm->addTagOpen('div')->addClass('search-results-form__subwrapper');
361        $searchForm->addHTML('limit by date: ');
362
363        global $INPUT;
364        if ($INPUT->has('before') || $INPUT->has('after')) {
365            $this->addSearchLink(
366                $searchForm,
367                '(remove limit)',
368                null,
369                null,
370                null,
371                null,
372                null,
373                false,
374                false
375            );
376
377            $searchForm->addHTML(', ');
378        }
379
380        if ($INPUT->str('after') === '1 week ago') {
381            $searchForm->addHTML('<span class="active">past 7 days</span>');
382        } else {
383            $this->addSearchLink(
384                $searchForm,
385                'past 7 days',
386                null,
387                null,
388                null,
389                null,
390                null,
391                '1 week ago',
392                false
393            );
394        }
395
396        $searchForm->addHTML(', ');
397
398        if ($INPUT->str('after') === '1 month ago') {
399            $searchForm->addHTML('<span class="active">past month</span>');
400        } else {
401            $this->addSearchLink(
402                $searchForm,
403                'past month',
404                null,
405                null,
406                null,
407                null,
408                null,
409                '1 month ago',
410                false
411            );
412        }
413
414        $searchForm->addHTML(', ');
415
416        if ($INPUT->str('after') === '1 year ago') {
417            $searchForm->addHTML('<span class="active">past year</span>');
418        } else {
419            $this->addSearchLink(
420                $searchForm,
421                'past year',
422                null,
423                null,
424                null,
425                null,
426                null,
427                '1 year ago',
428                false
429            );
430        }
431
432        $searchForm->addTagClose('div');
433    }
434
435
436    /**
437     * Build the intro text for the search page
438     *
439     * @param string $query the search query
440     *
441     * @return string
442     */
443    protected function getSearchIntroHTML($query)
444    {
445        global $ID, $lang;
446
447        $intro = p_locale_xhtml('searchpage');
448        // allow use of placeholder in search intro
449        $pagecreateinfo = (auth_quickaclcheck($ID) >= AUTH_CREATE) ? $lang['searchcreatepage'] : '';
450        $intro = str_replace(
451            array('@QUERY@', '@SEARCH@', '@CREATEPAGEINFO@'),
452            array(hsc(rawurlencode($query)), hsc($query), $pagecreateinfo),
453            $intro
454        );
455        return $intro;
456    }
457
458    /**
459     * Build HTML for a list of pages with matching pagenames
460     *
461     * @param array $data search results
462     *
463     * @return string
464     */
465    protected function getPageLookupHTML($data)
466    {
467        if (empty($data)) {
468            return '';
469        }
470
471        global $lang;
472
473        $html = '<div class="search_quickresult">';
474        $html .= '<h3>' . $lang['quickhits'] . ':</h3>';
475        $html .= '<ul class="search_quickhits">';
476        foreach ($data as $id => $title) {
477            $link = html_wikilink(':' . $id);
478            $eventData = [
479                'listItemContent' => [$link],
480                'page' => $id,
481            ];
482            trigger_event('SEARCH_RESULT_PAGELOOKUP', $eventData);
483            $html .= '<li>' . implode('', $eventData['listItemContent']) . '</li>';
484        }
485        $html .= '</ul> ';
486        //clear float (see http://www.complexspiral.com/publications/containing-floats/)
487        $html .= '<div class="clearer"></div>';
488        $html .= '</div>';
489
490        return $html;
491    }
492
493    /**
494     * Build HTML for fulltext search results or "no results" message
495     *
496     * @param array $data      the results of the fulltext search
497     * @param array $highlight the terms to be highlighted in the results
498     *
499     * @return string
500     */
501    protected function getFulltextResultsHTML($data, $highlight)
502    {
503        global $lang;
504
505        if (empty($data)) {
506            return '<div class="nothing">' . $lang['nothingfound'] . '</div>';
507        }
508
509        $html = '';
510        $html .= '<dl class="search_results">';
511        $num = 1;
512
513        foreach ($data as $id => $cnt) {
514            $resultLink = html_wikilink(':' . $id, null, $highlight);
515
516            $resultHeader = [$resultLink];
517
518
519            $restrictQueryToNSLink = $this->restrictQueryToNSLink(getNS($id));
520            if ($restrictQueryToNSLink) {
521                $resultHeader[] = $restrictQueryToNSLink;
522            }
523
524            $snippet = '';
525            $lastMod = '';
526            $mtime = filemtime(wikiFN($id));
527            if ($cnt !== 0) {
528                $resultHeader[] = $cnt . ' ' . $lang['hits'];
529                if ($num < FT_SNIPPET_NUMBER) { // create snippets for the first number of matches only
530                    $snippet = '<dd>' . ft_snippet($id, $highlight) . '</dd>';
531                    $lastMod = '<span class="search_results__lastmod">'. $lang['lastmod'] . ' ';
532                    $lastMod .= '<time datetime="' . date_iso8601($mtime) . '">'. dformat($mtime) . '</time>';
533                    $lastMod .= '</span>';
534                }
535                $num++;
536            }
537
538            $metaLine = '<div class="search_results__metaLine">';
539            $metaLine .= $lastMod;
540            $metaLine .= '</div>';
541
542
543            $eventData = [
544                'resultHeader' => $resultHeader,
545                'resultBody' => [$metaLine, $snippet],
546                'page' => $id,
547            ];
548            trigger_event('SEARCH_RESULT_FULLPAGE', $eventData);
549            $html .= '<div class="search_fullpage_result">';
550            $html .= '<dt>' . implode(' ', $eventData['resultHeader']) . '</dt>';
551            $html .= implode('', $eventData['resultBody']);
552            $html .= '</div>';
553        }
554        $html .= '</dl>';
555
556        return $html;
557    }
558
559    /**
560     * create a link to restrict the current query to a namespace
561     *
562     * @param bool|string $ns the namespace to which to restrict the query
563     *
564     * @return bool|string
565     */
566    protected function restrictQueryToNSLink($ns)
567    {
568        if (!$ns) {
569            return false;
570        }
571        if (!$this->isSearchAssistanceAvailable($this->parsedQuery)) {
572            return false;
573        }
574        if (!empty($this->parsedQuery['ns']) && $this->parsedQuery['ns'][0] === $ns) {
575            return false;
576        }
577        $name = '@' . $ns;
578        $tmpForm = new Form();
579        $this->addSearchLink($tmpForm, $name, null, [$ns], null, []);
580        return $tmpForm->toHTML();
581    }
582}
583