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