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