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