xref: /plugin/elasticsearch/action/search.php (revision f3e6b112ec339840410d0e60e308f13c65596972)
1<?php
2/**
3 * DokuWiki Plugin elasticsearch (Action Component)
4 *
5 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
6 * @author  Andreas Gohr <gohr@cosmocode.de>
7 */
8
9use dokuwiki\Extension\Event;
10
11/**
12 * Main search helper
13 */
14class action_plugin_elasticsearch_search extends DokuWiki_Action_Plugin {
15
16    /**
17     * Example array element for search field 'tagging':
18     * 'tagging' => [                       // also used as search query parameter
19     *   'label' => 'Tag',
20     *   'fieldPath' => 'tagging',          // dot notation in more complex mappings
21     *   'limit' => '50',
22     * ]
23     *
24     * @var Array
25     */
26    protected static $pluginSearchConfigs;
27
28    /**
29     * Search will be performed on those fields only.
30     *
31     * @var string[]
32     */
33    protected $searchFields = [
34        'title*',
35        'abstract*',
36        'content*',
37        'uri',
38    ];
39
40    /**
41     * Registers a callback function for a given event
42     *
43     * @param Doku_Event_Handler $controller DokuWiki's event controller object
44     * @return void
45     */
46    public function register(Doku_Event_Handler $controller) {
47
48        $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handle_preprocess');
49        $controller->register_hook('TPL_ACT_UNKNOWN', 'BEFORE', $this, 'handle_action');
50        $controller->register_hook('FORM_QUICKSEARCH_OUTPUT', 'BEFORE', $this, 'quicksearch');
51    }
52
53    /**
54     * allow our custom do command
55     *
56     * @param Doku_Event $event
57     * @param $param
58     */
59    public function handle_preprocess(Doku_Event $event, $param) {
60        if ($event->data !== 'search') return;
61        $event->preventDefault();
62        $event->stopPropagation();
63    }
64
65    /**
66     * do the actual search
67     *
68     * @param Doku_Event $event
69     * @param $param
70     */
71    public function handle_action(Doku_Event $event, $param) {
72        if ($event->data !== 'search') return;
73        $event->preventDefault();
74        $event->stopPropagation();
75        global $QUERY;
76        global $INPUT;
77        global $ID;
78
79        if (empty($QUERY)) $QUERY = $INPUT->str('q');
80        if (empty($QUERY)) $QUERY = $ID;
81
82        // get extended search configurations from plugins
83        Event::createAndTrigger('PLUGIN_ELASTICSEARCH_FILTERS', self::$pluginSearchConfigs);
84
85        /** @var helper_plugin_elasticsearch_client $hlp */
86        $hlp = plugin_load('helper', 'elasticsearch_client');
87
88        $client = $hlp->connect();
89        $index  = $client->getIndex($this->getConf('indexname'));
90
91        // store copy of the original query string
92        $q = $QUERY;
93        // let plugins manipulate the query
94        $additions = [];
95        Event::createAndTrigger('PLUGIN_ELASTICSEARCH_QUERY', $additions);
96        // if query is empty, return all results
97        if (empty(trim($QUERY))) $QUERY = '*';
98
99        // get fields to use in query
100        $fields = [];
101        Event::createAndTrigger('PLUGIN_ELASTICSEARCH_SEARCHFIELDS', $fields);
102
103        if ($this->getConf('searchSyntax')) {
104            array_push($this->searchFields, 'syntax*');
105        }
106
107        // finally define the elastic query
108        $qstring = new \Elastica\Query\SimpleQueryString($QUERY, array_merge($this->searchFields, $fields));
109        // restore the original query
110        $QUERY = $q;
111        // append additions provided by plugins
112        if (!empty($additions)) {
113            $QUERY .= ' ' . implode(' ', $additions);
114        }
115
116        // create the actual search object
117        $equery = new \Elastica\Query();
118        $subqueries = new \Elastica\Query\BoolQuery();
119        $subqueries->addMust($qstring);
120
121        $equery->setHighlight(
122            [
123                "pre_tags"  => ['ELASTICSEARCH_MARKER_IN'],
124                "post_tags" => ['ELASTICSEARCH_MARKER_OUT'],
125                "fields"    => [
126                    $this->getConf('snippets') => new \stdClass(),
127                    'title' => new \stdClass()]
128            ]
129        );
130
131        // paginate
132        $equery->setSize($this->getConf('perpage'));
133        $equery->setFrom($this->getConf('perpage') * ($INPUT->int('p', 1, true) - 1));
134
135        // add ACL subqueries
136        $this->addACLSubqueries($subqueries);
137
138        // add language subquery
139        $this->addLanguageSubquery($subqueries, $this->getLanguageFilter());
140
141        // add date subquery
142        if ($INPUT->has('min')) {
143            $this->addDateSubquery($subqueries, $INPUT->str('min'));
144        }
145
146        // add namespace filter
147        if($INPUT->has('ns')) {
148            $nsSubquery = new \Elastica\Query\BoolQuery();
149            foreach($INPUT->arr('ns') as $ns) {
150                $term = new \Elastica\Query\Term();
151                $term->setTerm('namespace', $ns);
152                $nsSubquery->addShould($term);
153            }
154            $equery->setPostFilter($nsSubquery);
155        }
156
157        $equery->setQuery($subqueries);
158
159        // add aggregations for namespaces
160        $agg = new \Elastica\Aggregation\Terms('namespace');
161        $agg->setField('namespace.keyword');
162        $agg->setSize(25);
163        $equery->addAggregation($agg);
164
165        // add search configurations from other plugins
166        $this->addPluginConfigurations($equery);
167
168        try {
169            $result = $index->search($equery);
170            $aggs = $result->getAggregations();
171
172            $this->print_intro();
173            /** @var helper_plugin_elasticsearch_form $hlpform */
174            $hlpform = plugin_load('helper', 'elasticsearch_form');
175            $hlpform->tpl($aggs);
176            $this->print_results($result) && $this->print_pagination($result);
177        } catch(Exception $e) {
178            msg('Something went wrong on searching please try again later or ask an admin for help.<br /><pre>' . hsc($e->getMessage()) . '</pre>', -1);
179        }
180    }
181
182    /**
183     * Optionally disable "quick search"
184     *
185     * @param Doku_Event $event
186     */
187    public function quicksearch(Doku_Event $event)
188    {
189        if (!$this->getConf('disableQuicksearch')) return;
190
191        /** @var \dokuwiki\Form\Form $form */
192        $form = $event->data;
193        $pos = $form->findPositionByAttribute('id', 'qsearch__out');
194        $form->removeElement($pos);
195        $form->removeElement($pos + 1); // div closing tag
196    }
197
198    /**
199     * @return array
200     */
201    public static function getRawPluginSearchConfigs()
202    {
203        return self::$pluginSearchConfigs;
204    }
205
206    /**
207     * Add search configurations supplied by other plugins
208     *
209     * @param \Elastica\Query $equery
210     */
211    protected function addPluginConfigurations($equery)
212    {
213        global $INPUT;
214
215        if (!empty(self::$pluginSearchConfigs)) {
216            foreach (self::$pluginSearchConfigs as $param => $config) {
217                // handle search parameter
218                if ($INPUT->has($param)) {
219                    $pluginSubquery = new \Elastica\Query\BoolQuery();
220                    foreach($INPUT->arr($param) as $item) {
221                        $eterm = new \Elastica\Query\Term();
222                        $eterm->setTerm($param, $item);
223                        $pluginSubquery->addShould($eterm);
224                    }
225                    $equery->setPostFilter($pluginSubquery);
226                }
227                // build aggregation for use as filter in advanced search
228                $agg = new \Elastica\Aggregation\Terms($param);
229                $agg->setField($config['fieldPath']);
230                if (isset($config['limit'])) {
231                    $agg->setSize($config['limit']);
232                }
233                $equery->addAggregation($agg);
234            }
235        }
236    }
237
238    /**
239     * Adds date subquery
240     *
241     * @param Elastica\Query\BoolQuery $subqueries
242     * @param string $min Modified at the latest one {year|month|week} ago
243     */
244    protected function addDateSubquery($subqueries, $min)
245    {
246        if (!in_array($min, ['year', 'month', 'week'])) return;
247
248        $dateSubquery = new \Elastica\Query\Range(
249            'modified',
250            ['gte' => date('Y-m-d', strtotime('1 ' . $min . ' ago'))]
251        );
252        $subqueries->addMust($dateSubquery);
253    }
254
255    /**
256     * Adds language subquery
257     *
258     * @param Elastica\Query\BoolQuery $subqueries
259     * @param array $langFilter
260     */
261    protected function addLanguageSubquery($subqueries, $langFilter)
262    {
263        if (empty($langFilter)) return;
264
265        $langSubquery = new \Elastica\Query\Match();
266        $langSubquery->setField('language', implode(',', $langFilter));
267        $subqueries->addMust($langSubquery);
268    }
269
270    /**
271     * Languages to be used in the current search, determined by:
272     * 1. $INPUT variables, or 2. translation plugin
273     *
274     * @return array
275     */
276    protected function getLanguageFilter()
277    {
278        global $ID;
279        global $INPUT;
280
281        $ns = getNS($ID);
282        $langFilter = $INPUT->arr('lang');
283
284        /** @var helper_plugin_translation $transplugin */
285        $transplugin = plugin_load('helper', 'translation');
286
287        // optional translation detection: use current top namespace if it matches translation config
288        if (empty($langFilter) && $transplugin && $this->getConf('detectTranslation') && $ns) {
289            $topNs = strtok($ns, ':');
290            if (in_array($topNs, $transplugin->translations)) {
291                $langFilter = [$topNs];
292                $INPUT->set('lang', $langFilter);
293            }
294        } else if (empty($langFilter) && $transplugin) {
295            // select all available translations
296            $INPUT->set('lang', $transplugin->translations);
297        }
298
299        return $langFilter;
300    }
301
302    /**
303     * Inserts subqueries based on current user's ACLs, none for superusers
304     *
305     * @param \Elastica\Query\BoolQuery $subqueries
306     */
307    protected function addACLSubqueries($subqueries)
308    {
309        global $USERINFO;
310        global $conf;
311
312        $groups = array_merge(['ALL'], $USERINFO['grps'] ?: []);
313
314        // no ACL filters for superusers
315        if (in_array(ltrim($conf['superuser'], '@'), $groups)) return;
316
317        // include if group OR user have read permissions, allows for ACLs such as "block @group except user"
318        $includeSubquery = new \Elastica\Query\BoolQuery();
319        foreach($groups as $group) {
320            $term = new \Elastica\Query\Term();
321            $term->setTerm('groups_include', $group);
322            $includeSubquery->addShould($term);
323        }
324        if (isset($_SERVER['REMOTE_USER'])) {
325            $userIncludeSubquery = new \Elastica\Query\BoolQuery();
326            $term = new \Elastica\Query\Term();
327            $term->setTerm('users_include', $_SERVER['REMOTE_USER']);
328            $userIncludeSubquery->addMust($term);
329            $includeSubquery->addShould($userIncludeSubquery);
330        }
331        $subqueries->addMust($includeSubquery);
332
333        // groups exclusion SHOULD be respected, not MUST, since that would not allow for exceptions
334        $groupExcludeSubquery = new \Elastica\Query\BoolQuery();
335        foreach($groups as $group) {
336            $term = new \Elastica\Query\Term();
337            $term->setTerm('groups_exclude', $group);
338            $groupExcludeSubquery->addShould($term);
339        }
340        $excludeSubquery = new \Elastica\Query\BoolQuery();
341        $excludeSubquery->addMustNot($groupExcludeSubquery);
342        $subqueries->addShould($excludeSubquery);
343
344        // user specific excludes must always be respected
345        if (isset($_SERVER['REMOTE_USER'])) {
346            $term = new \Elastica\Query\Term();
347            $term->setTerm('users_exclude', $_SERVER['REMOTE_USER']);
348            $subqueries->addMustNot($term);
349        }
350    }
351
352    /**
353     * Prints the introduction text
354     */
355    protected function print_intro() {
356        global $QUERY;
357        global $ID;
358        global $lang;
359
360        // just reuse the standard search page intro:
361        $intro = p_locale_xhtml('searchpage');
362        // allow use of placeholder in search intro
363        $pagecreateinfo = '';
364        if (auth_quickaclcheck($ID) >= AUTH_CREATE) {
365            $pagecreateinfo = sprintf($lang['searchcreatepage'], $QUERY);
366        }
367        $intro          = str_replace(
368            ['@QUERY@', '@SEARCH@', '@CREATEPAGEINFO@'],
369            [hsc(rawurlencode($QUERY)), hsc($QUERY), $pagecreateinfo],
370            $intro
371        );
372        echo $intro;
373        flush();
374    }
375
376    /**
377     * Output the search results
378     *
379     * @param \Elastica\ResultSet $results
380     * @return bool true when results where shown
381     */
382    protected function print_results($results) {
383        global $lang;
384
385        // output results
386        $found = $results->getTotalHits();
387
388        if(!$found) {
389            echo '<h2>' . $lang['nothingfound'] . '</h2>';
390            return (bool)$found;
391        }
392
393        echo '<dl class="search_results">';
394        echo '<h2>' . sprintf($this->getLang('totalfound'), $found) . '</h2>';
395        foreach ($results as $row) {
396
397            /** @var Elastica\Result $row */
398            $doc = $row->getSource();
399            $page = $doc['uri'];
400            if (!page_exists($page) || isHiddenPage($page) || is_file(mediaFN($page)) || auth_quickaclcheck($page) < AUTH_READ) continue;
401
402            // get highlighted title
403            $highlightsTitle = $row->getHighlights()['title'] ?? '';
404            $title = str_replace(
405                ['ELASTICSEARCH_MARKER_IN', 'ELASTICSEARCH_MARKER_OUT'],
406                ['<strong class="search_hit">', '</strong>'],
407                hsc(join(' … ', (array) $highlightsTitle))
408            );
409            if (!$title) $title = hsc($doc['title']);
410            if (!$title) $title = hsc(p_get_first_heading($page));
411            if (!$title) $title = hsc($page);
412
413            // get highlighted snippet
414            $highlightedSnippets = $row->getHighlights()[$this->getConf('snippets')] ?? [];
415            $snippet = str_replace(
416                ['ELASTICSEARCH_MARKER_IN', 'ELASTICSEARCH_MARKER_OUT'],
417                ['<strong class="search_hit">', '</strong>'],
418                hsc(join(' … ', $highlightedSnippets))
419            );
420            if (!$snippet) $snippet = hsc($doc['abstract']); // always fall back to abstract
421
422            $isPage = $doc['doctype'] === \action_plugin_elasticsearch_indexing::DOCTYPE_PAGE;
423            $href = $isPage ? wl($page) : ml($page);
424
425            echo '<dt>';
426            if (!$isPage && is_file(DOKU_INC . 'lib/images/fileicons/'. $doc['ext'] .'.png')) {
427                echo sprintf(
428                    '<img src="%s" alt="%s" /> ',
429                    DOKU_BASE . 'lib/images/fileicons/'. $doc['ext'] .'.png',
430                    $doc['ext']
431                );
432            }
433            echo '<a href="' . $href . '" class="wikilink1" title="'.hsc($page).'">';
434            echo $title;
435            echo '</a>';
436            echo '</dt>';
437
438            // meta
439            echo '<dd class="meta elastic-resultmeta">';
440            if (!empty($doc['namespace'])) {
441                echo '<span class="ns">' . $this->getLang('ns') . ' ' . hsc($doc['namespace']) . '</span>';
442            }
443            if ($doc['user']) {
444                echo ' <span class="author">' . $this->getLang('author') . ' ' . userlink($doc['user']) . '</span>';
445            }
446            if ($doc['modified']) {
447                $lastmod = strtotime($doc['modified']);
448                echo ' <span class="">' . $lang['lastmod'] . ' ' . dformat($lastmod) . '</span>';
449            }
450            echo '</dd>';
451
452            // snippets
453            echo '<dd class="snippet">';
454            echo $snippet;
455            echo '</dd>';
456
457        }
458        echo '</dl>';
459
460        return (bool) $found;
461    }
462
463    /**
464     * @param \Elastica\ResultSet $result
465     */
466    protected function print_pagination($result) {
467        global $INPUT;
468        global $QUERY;
469
470        $all   = $result->getTotalHits();
471        $pages = ceil($all / $this->getConf('perpage'));
472        $cur   = $INPUT->int('p', 1, true);
473
474        if($pages < 2) return;
475
476        // which pages to show
477        $toshow = [1, 2, $cur, $pages, $pages - 1];
478        if($cur - 1 > 1) $toshow[] = $cur - 1;
479        if($cur + 1 < $pages) $toshow[] = $cur + 1;
480        $toshow = array_unique($toshow);
481        // fill up to seven, if possible
482        if(count($toshow) < 7) {
483            if($cur < 4) {
484                if($cur + 2 < $pages && count($toshow) < 7) $toshow[] = $cur + 2;
485                if($cur + 3 < $pages && count($toshow) < 7) $toshow[] = $cur + 3;
486                if($cur + 4 < $pages && count($toshow) < 7) $toshow[] = $cur + 4;
487            } else {
488                if($cur - 2 > 1 && count($toshow) < 7) $toshow[] = $cur - 2;
489                if($cur - 3 > 1 && count($toshow) < 7) $toshow[] = $cur - 3;
490                if($cur - 4 > 1 && count($toshow) < 7) $toshow[] = $cur - 4;
491            }
492        }
493        sort($toshow);
494        $showlen = count($toshow);
495
496        echo '<ul class="elastic_pagination">';
497        if($cur > 1) {
498            echo '<li class="prev">';
499            echo '<a href="' . wl('', http_build_query(['q' => $QUERY, 'do' => 'search', 'ns' => $INPUT->arr('ns'), 'min' => $INPUT->arr('min'), 'p' => ($cur-1)])) . '">';
500            echo '«';
501            echo '</a>';
502            echo '</li>';
503        }
504
505        for($i = 0; $i < $showlen; $i++) {
506            if($toshow[$i] == $cur) {
507                echo '<li class="cur">' . $toshow[$i] . '</li>';
508            } else {
509                echo '<li>';
510                echo '<a href="' . wl('', http_build_query(['q' => $QUERY, 'do' => 'search', 'ns' => $INPUT->arr('ns'), 'min' => $INPUT->arr('min'), 'p' => $toshow[$i]])) . '">';
511                echo $toshow[$i];
512                echo '</a>';
513                echo '</li>';
514            }
515
516            // show seperator when a jump follows
517            if(isset($toshow[$i + 1]) && $toshow[$i + 1] - $toshow[$i] > 1) {
518                echo '<li class="sep">…</li>';
519            }
520        }
521
522        if($cur < $pages) {
523            echo '<li class="next">';
524            echo '<a href="' . wl('', http_build_query(['q' => $QUERY, 'do' => 'search', 'ns' => $INPUT->arr('ns'), 'min' => $INPUT->arr('min'), 'p' => ($cur+1)])) . '">';
525            echo '»';
526            echo '</a>';
527            echo '</li>';
528        }
529
530        echo '</ul>';
531    }
532
533}
534