xref: /plugin/elasticsearch/action/search.php (revision b30042c1e1ed4d5dd7667d1d25fccd0363de335f) !
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
9// must be run within Dokuwiki
10if(!defined('DOKU_INC')) die();
11
12/**
13 * Main search helper
14 */
15class action_plugin_elasticsearch_search extends DokuWiki_Action_Plugin {
16
17    /**
18     * Registers a callback function for a given event
19     *
20     * @param Doku_Event_Handler $controller DokuWiki's event controller object
21     * @return void
22     */
23    public function register(Doku_Event_Handler $controller) {
24
25        $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handle_preprocess');
26        $controller->register_hook('TPL_ACT_UNKNOWN', 'BEFORE', $this, 'handle_action');
27
28    }
29
30    /**
31     * allow our custom do command
32     *
33     * @param Doku_Event $event
34     * @param $param
35     */
36    public function handle_preprocess(Doku_Event $event, $param) {
37        if($event->data != 'search') return;
38        $event->preventDefault();
39        $event->stopPropagation();
40    }
41
42    /**
43     * do the actual search
44     *
45     * @param Doku_Event $event
46     * @param $param
47     */
48    public function handle_action(Doku_Event $event, $param) {
49        if($event->data != 'search') return;
50        $event->preventDefault();
51        $event->stopPropagation();
52        global $QUERY;
53        global $INPUT;
54        global $ID;
55
56        if (empty($QUERY)) $QUERY = $INPUT->str('q');
57        if (empty($QUERY)) $QUERY = $ID;
58
59        /** @var helper_plugin_elasticsearch_client $hlp */
60        $hlp = plugin_load('helper', 'elasticsearch_client');
61
62        /** @var helper_plugin_elasticsearch_form $hlpform */
63        $hlpform = plugin_load('helper', 'elasticsearch_form');
64
65        $client = $hlp->connect();
66        $index  = $client->getIndex($this->getConf('indexname'));
67
68        // define the query string
69        $qstring = new \Elastica\Query\SimpleQueryString($QUERY);
70
71        // create the actual search object
72        $equery = new \Elastica\Query();
73        $subqueries = new \Elastica\Query\BoolQuery();
74        $subqueries->addMust($qstring);
75
76        $equery->setHighlight(
77            [
78                "pre_tags"  => ['ELASTICSEARCH_MARKER_IN'],
79                "post_tags" => ['ELASTICSEARCH_MARKER_OUT'],
80                "fields"    => [
81                    $this->getConf('snippets') => new \stdClass(),
82                    'title' => new \stdClass()]
83            ]
84        );
85
86        // paginate
87        $equery->setSize($this->getConf('perpage'));
88        $equery->setFrom($this->getConf('perpage') * ($INPUT->int('p', 1, true) - 1));
89
90        // add ACL subqueries
91        $this->addACLSubqueries($subqueries);
92
93        // add date subquery
94        if ($INPUT->has('min')) {
95            $this->addDateSubquery($subqueries, $INPUT->str('min'));
96        }
97
98        // add namespace filter
99        if($INPUT->has('ns')) {
100            $nsSubquery = new \Elastica\Query\BoolQuery();
101            foreach($INPUT->arr('ns') as $ns) {
102                $term = new \Elastica\Query\Term();
103                $term->setTerm('namespace', $ns);
104                $nsSubquery->addShould($term);
105            }
106            $equery->setPostFilter($nsSubquery);
107        }
108
109        $equery->setQuery($subqueries);
110
111        // add aggregations for namespaces
112        $agg = new \Elastica\Aggregation\Terms('namespace');
113        $agg->setField('namespace.keyword');
114        $agg->setSize(25);
115        $equery->addAggregation($agg);
116
117        try {
118            $result = $index->search($equery);
119            $aggs = $result->getAggregations();
120
121            $this->print_intro();
122            $hlpform->tpl($aggs['namespace']['buckets'] ?: []);
123            $this->print_results($result) && $this->print_pagination($result);
124        } catch(Exception $e) {
125            msg('Something went wrong on searching please try again later or ask an admin for help.<br /><pre>' . hsc($e->getMessage()) . '</pre>', -1);
126        }
127    }
128
129    /**
130     * Adds date subquery
131     *
132     * @param Elastica\Query\BoolQuery $subqueries
133     * @param string $min Modified at the latest one {year|month|week} ago
134     */
135    protected function addDateSubquery($subqueries, $min)
136    {
137        // FIXME
138        if (!in_array($min, ['year', 'month', 'week'])) return;
139
140        $dateSubquery = new \Elastica\Query\Range(
141            'modified',
142            ['gte' => date('Y-m-d', strtotime('1 ' . $min . ' ago'))]
143        );
144        $subqueries->addMust($dateSubquery);
145    }
146
147    /**
148     * Inserts subqueries based on current user's ACLs, none for superusers
149     *
150     * @param \Elastica\Query\BoolQuery $subqueries
151     */
152    protected function addACLSubqueries($subqueries)
153    {
154        global $USERINFO;
155        global $conf;
156
157        $groups = array_merge(['ALL'], $USERINFO['grps'] ?: []);
158
159        // no ACL filters for superusers
160        if (in_array(ltrim($conf['superuser'], '@'), $groups)) return;
161
162        // include if group OR user have read permissions, allows for ACLs such as "block @group except user"
163        $includeSubquery = new \Elastica\Query\BoolQuery();
164        foreach($groups as $group) {
165            $term = new \Elastica\Query\Term();
166            $term->setTerm('groups_include', $group);
167            $includeSubquery->addShould($term);
168        }
169        if (isset($_SERVER['REMOTE_USER'])) {
170            $userIncludeSubquery = new \Elastica\Query\BoolQuery();
171            $term = new \Elastica\Query\Term();
172            $term->setTerm('users_include', $_SERVER['REMOTE_USER']);
173            $userIncludeSubquery->addMust($term);
174            $includeSubquery->addShould($userIncludeSubquery);
175        }
176        $subqueries->addMust($includeSubquery);
177
178        // groups exclusion SHOULD be respected, not MUST, since that would not allow for exceptions
179        $groupExcludeSubquery = new \Elastica\Query\BoolQuery();
180        foreach($groups as $group) {
181            $term = new \Elastica\Query\Term();
182            $term->setTerm('groups_exclude', $group);
183            $groupExcludeSubquery->addShould($term);
184        }
185        $excludeSubquery = new \Elastica\Query\BoolQuery();
186        $excludeSubquery->addMustNot($groupExcludeSubquery);
187        $subqueries->addShould($excludeSubquery);
188
189        // user specific excludes must always be respected
190        if (isset($_SERVER['REMOTE_USER'])) {
191            $term = new \Elastica\Query\Term();
192            $term->setTerm('users_exclude', $_SERVER['REMOTE_USER']);
193            $subqueries->addMustNot($term);
194        }
195    }
196
197    /**
198     * Prints the introduction text
199     */
200    protected function print_intro() {
201        global $QUERY;
202        global $ID;
203        global $lang;
204
205        // just reuse the standard search page intro:
206        $intro = p_locale_xhtml('searchpage');
207        // allow use of placeholder in search intro
208        $pagecreateinfo = '';
209        if (auth_quickaclcheck($ID) >= AUTH_CREATE) {
210            $pagecreateinfo = sprintf($lang['searchcreatepage'], $QUERY);
211        }
212        $intro          = str_replace(
213            ['@QUERY@', '@SEARCH@', '@CREATEPAGEINFO@'],
214            [hsc(rawurlencode($QUERY)), hsc($QUERY), $pagecreateinfo],
215            $intro
216        );
217        echo $intro;
218        flush();
219    }
220
221    /**
222     * Output the search results
223     *
224     * @param \Elastica\ResultSet $results
225     * @return bool true when results where shown
226     */
227    protected function print_results($results) {
228        global $lang;
229
230        // output results
231        $found = $results->getTotalHits();
232
233        if(!$found) {
234            echo '<h2>' . $lang['nothingfound'] . '</h2>';
235            return (bool)$found;
236        }
237
238        echo '<dl class="search_results">';
239        echo '<h2>' . sprintf($this->getLang('totalfound'), $found) . '</h2>';
240        foreach($results as $row) {
241
242            /** @var Elastica\Result $row */
243            $page = $row->getSource()['uri'];
244            if(!page_exists($page) || auth_quickaclcheck($page) < AUTH_READ) continue;
245
246            // get highlighted title
247            $title = str_replace(
248                ['ELASTICSEARCH_MARKER_IN', 'ELASTICSEARCH_MARKER_OUT'],
249                ['<strong class="search_hit">', '</strong>'],
250                hsc(join(' … ', (array) $row->getHighlights()['title']))
251            );
252            if(!$title) $title = hsc($row->getSource()['title']);
253            if(!$title) $title = hsc(p_get_first_heading($page));
254            if(!$title) $title = hsc($page);
255
256            // get highlighted snippet
257            $snippet = str_replace(
258                ['ELASTICSEARCH_MARKER_IN', 'ELASTICSEARCH_MARKER_OUT'],
259                ['<strong class="search_hit">', '</strong>'],
260                hsc(join(' … ', (array) $row->getHighlights()[$this->getConf('snippets')]))
261            );
262            if(!$snippet) $snippet = hsc($row->getSource()['abstract']); // always fall back to abstract
263
264            echo '<dt>';
265            echo '<a href="'.wl($page).'" class="wikilink1" title="'.hsc($page).'">';
266            echo $title;
267            echo '</a>';
268            echo '</dt>';
269
270            // meta
271            echo '<dd class="meta elastic-resultmeta">';
272            if($row->getSource()['namespace']) {
273                echo '<span class="ns">' . $this->getLang('ns') . ' ' . hsc($row->getSource()['namespace']) . '</span>';
274            }
275            if($row->getSource()['user']) {
276                echo ' <span class="author">' . $this->getLang('author') . ' ' . userlink($row->getSource()['user']) . '</span>';
277            }
278            if($row->getSource()['modified']) {
279                $lastmod = strtotime($row->getSource()['modified']);
280                echo ' <span class="">' . $lang['lastmod'] . ' ' . dformat($lastmod) . '</span>';
281            }
282            echo '</dd>';
283
284            // snippets
285            echo '<dd class="snippet">';
286            echo $snippet;
287            echo '</dd>';
288
289        }
290        echo '</dl>';
291
292        return (bool) $found;
293    }
294
295    /**
296     * @param \Elastica\ResultSet $result
297     */
298    protected function print_pagination($result) {
299        global $INPUT;
300        global $QUERY;
301
302        $all   = $result->getTotalHits();
303        $pages = ceil($all / $this->getConf('perpage'));
304        $cur   = $INPUT->int('p', 1, true);
305
306        if($pages < 2) return;
307
308        // which pages to show
309        $toshow = [1, 2, $cur, $pages, $pages - 1];
310        if($cur - 1 > 1) $toshow[] = $cur - 1;
311        if($cur + 1 < $pages) $toshow[] = $cur + 1;
312        $toshow = array_unique($toshow);
313        // fill up to seven, if possible
314        if(count($toshow) < 7) {
315            if($cur < 4) {
316                if($cur + 2 < $pages && count($toshow) < 7) $toshow[] = $cur + 2;
317                if($cur + 3 < $pages && count($toshow) < 7) $toshow[] = $cur + 3;
318                if($cur + 4 < $pages && count($toshow) < 7) $toshow[] = $cur + 4;
319            } else {
320                if($cur - 2 > 1 && count($toshow) < 7) $toshow[] = $cur - 2;
321                if($cur - 3 > 1 && count($toshow) < 7) $toshow[] = $cur - 3;
322                if($cur - 4 > 1 && count($toshow) < 7) $toshow[] = $cur - 4;
323            }
324        }
325        sort($toshow);
326        $showlen = count($toshow);
327
328        echo '<ul class="elastic_pagination">';
329        if($cur > 1) {
330            echo '<li class="prev">';
331            echo '<a href="' . wl('', http_build_query(['q' => $QUERY, 'do' => 'search', 'ns' => $INPUT->arr('ns'), 'min' => $INPUT->arr('min'), 'p' => ($cur-1)])) . '">';
332            echo '«';
333            echo '</a>';
334            echo '</li>';
335        }
336
337        for($i = 0; $i < $showlen; $i++) {
338            if($toshow[$i] == $cur) {
339                echo '<li class="cur">' . $toshow[$i] . '</li>';
340            } else {
341                echo '<li>';
342                echo '<a href="' . wl('', http_build_query(['q' => $QUERY, 'do' => 'search', 'ns' => $INPUT->arr('ns'), 'min' => $INPUT->arr('min'), 'p' => $toshow[$i]])) . '">';
343                echo $toshow[$i];
344                echo '</a>';
345                echo '</li>';
346            }
347
348            // show seperator when a jump follows
349            if(isset($toshow[$i + 1]) && $toshow[$i + 1] - $toshow[$i] > 1) {
350                echo '<li class="sep">…</li>';
351            }
352        }
353
354        if($cur < $pages) {
355            echo '<li class="next">';
356            echo '<a href="' . wl('', http_build_query(['q' => $QUERY, 'do' => 'search', 'ns' => $INPUT->arr('ns'), 'min' => $INPUT->arr('min'), 'p' => ($cur+1)])) . '">';
357            echo '»';
358            echo '</a>';
359            echo '</li>';
360        }
361
362        echo '</ul>';
363    }
364
365}
366