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