xref: /plugin/tagging/action/search.php (revision df43a7be9125b55733c41a4f1a13770bf4033169)
1<?php
2
3use dokuwiki\Extension\ActionPlugin;
4use dokuwiki\Extension\EventHandler;
5use dokuwiki\Extension\Event;
6use dokuwiki\Ui\SearchState;
7use dokuwiki\Form\InputElement;
8
9/**
10 * Class action_plugin_tagging_search
11 */
12class action_plugin_tagging_search extends ActionPlugin
13{
14    /**
15     * @var array
16     */
17    protected $tagFilter = [];
18
19    /**
20     * @var array
21     */
22    protected $allTagsByPage = [];
23
24    /**
25     * @var string
26     */
27    protected $originalQuery = '';
28
29    /**
30     * Register handlers
31     *
32     * @param EventHandler $controller
33     */
34    public function register(EventHandler $controller)
35    {
36        $controller->register_hook(
37            'TPL_CONTENT_DISPLAY',
38            'BEFORE',
39            $this,
40            'echo_searchresults'
41        );
42
43        $controller->register_hook(
44            'TPL_ACT_RENDER',
45            'BEFORE',
46            $this,
47            'setupTagSearchDoku'
48        );
49
50        $controller->register_hook(
51            'SEARCH_QUERY_FULLPAGE',
52            'AFTER',
53            $this,
54            'filterSearchResults'
55        );
56
57        $controller->register_hook(
58            'SEARCH_RESULT_FULLPAGE',
59            'AFTER',
60            $this,
61            'tagResults'
62        );
63
64        $controller->register_hook(
65            'FORM_SEARCH_OUTPUT',
66            'BEFORE',
67            $this,
68            'addSwitchToSearchForm'
69        );
70    }
71
72    /**
73     * Add AND/OR switch to advanced search tools
74     *
75     * @param Event $event
76     * @param            $param
77     */
78    public function addSwitchToSearchForm(Event $event, $param)
79    {
80        global $INPUT;
81
82        /* @var dokuwiki\Form\Form $searchForm */
83        $searchForm = $event->data;
84        $currElemPos = $searchForm->findPositionByAttribute('class', 'advancedOptions');
85
86        // the actual filter is built in Javascript
87        $searchForm->addTagOpen('div', ++$currElemPos)
88            ->addClass('toggle')
89            ->attr('aria-haspopup', 'true')
90            ->id('plugin__tagging-tags');
91        // this element needs to be rendered by the backend so that all JS events properly attach
92        $searchForm->addTagOpen('div', ++$currElemPos)
93            ->addClass('current');
94        $searchForm->addHTML($this->getLang('search_filter_label'), ++$currElemPos);
95        $searchForm->addTagClose('div', ++$currElemPos);
96        $searchForm->addTagClose('div', ++$currElemPos);
97
98        // set active setting
99        $active = '';
100        if ($INPUT->has('tagging-logic')) {
101            $active = $INPUT->str('tagging-logic');
102        }
103        $searchForm->setHiddenField('tagging-logic', $active);
104
105        $searchForm->addTagOpen('div', ++$currElemPos)
106            ->addClass('toggle')
107            ->attr('aria-haspopup', 'true')
108            ->id('plugin__tagging-logic');
109
110        // popup toggler
111        $toggler = $searchForm->addTagOpen('div', ++$currElemPos)->addClass('current');
112
113        // current item
114        if ($active === 'and') {
115            $currentLabel = $this->getLang('search_all_tags');
116            $toggler->addClass('changed');
117        } else {
118            $currentLabel = $this->getLang('search_any_tag');
119        }
120
121        $searchForm->addHTML($currentLabel, ++$currElemPos);
122        $searchForm->addTagClose('div', ++$currElemPos);
123
124        // options
125        $options = [
126            'or' => $this->getLang('search_any_tag'),
127            'and' => $this->getLang('search_all_tags'),
128        ];
129        $searchForm->addTagOpen('ul', ++$currElemPos)->attr('aria-expanded', 'false');
130        foreach ($options as $key => $label) {
131            $listItem = $searchForm->addTagOpen('li', ++$currElemPos);
132            if ($active && $key === $active) {
133                $listItem->addClass('active');
134            }
135            $link = $this->getSettingsLink($label, 'tagging-logic', $key);
136            $searchForm->addHTML($link, ++$currElemPos);
137            $searchForm->addTagClose('li', ++$currElemPos);
138        }
139        $searchForm->addTagClose('ul', ++$currElemPos);
140
141        $searchForm->addTagClose('div', ++$currElemPos);
142
143        // restore query with tags in the search form
144        if ($this->tagFilter) {
145            /** @var InputElement $q */
146            $q = $searchForm->getElementAt($searchForm->findPositionByAttribute('name', 'q'));
147            $q->val($this->originalQuery);
148        }
149    }
150
151    /**
152     * Extracts tags from query and temporarily removes them
153     * to prevent running fulltext search on tags as simple terms.
154     *
155     * @param Event $event
156     * @param $param
157     */
158    public function setupTagSearchDoku(Event $event, $param)
159    {
160        if ($event->data !== 'search' || !plugin_isdisabled('elasticsearch')) {
161            return;
162        }
163
164        // allTagsByPage will be accessed by individual search results in SEARCH_RESULT_FULLPAGE event
165        // and when displaying tag suggestions
166        /** @var helper_plugin_tagging $hlp */
167        $hlp = plugin_load('helper', 'tagging');
168        $this->allTagsByPage = $hlp->getAllTagsByPage();
169
170        global $QUERY;
171        if (!str_contains($QUERY, '#')) {
172            return;
173        }
174
175        $this->originalQuery = $QUERY;
176
177        // get (hash)tags from query
178        preg_match_all('/(?:#)(\w+)/u', $QUERY, $matches);
179        if (isset($matches[1])) {
180            $this->tagFilter += array_map([$hlp, 'cleanTag'], $matches[1]);
181        }
182
183        // remove tags from query before search is executed
184        self::removeTagsFromQuery($QUERY);
185    }
186
187    /**
188     * If tags are found in query, the results are filtered,
189     * or, with an empty query, tag search results are returned.
190     *
191     * @param Event $event
192     * @param $param
193     */
194    public function filterSearchResults(Event $event, $param)
195    {
196        if (!$this->tagFilter) {
197            return;
198        }
199
200        /** @var helper_plugin_tagging $hlp */
201        $hlp = plugin_load('helper', 'tagging');
202
203        // search for tagged pages
204        $pages = $hlp->searchPages($this->tagFilter);
205        if (!$pages) {
206            $event->result = [];
207            return;
208        }
209
210        // tag search only, without additional terms
211        if (!trim($event->data['query'])) {
212            $event->result = $pages;
213        }
214
215        // apply filter
216        $tagged = array_keys($pages);
217        foreach ($event->result as $id => $count) {
218            if (!in_array($id, $tagged)) {
219                unset($event->result[$id]);
220            }
221        }
222    }
223
224    /**
225     * Add tag links to all search results
226     *
227     * @param Event $event
228     * @param $param
229     */
230    public function tagResults(Event $event, $param)
231    {
232        $page = $event->data['page'];
233        $tags = $this->allTagsByPage[$page] ?? null;
234        if ($tags) {
235            foreach ($tags as $tag) {
236                $event->data['resultHeader'][] = $this->getSettingsLink('#' . $tag, 'q', '#' . $tag);
237            }
238        }
239    }
240
241    /**
242     * Show tags that are similar to the terms used in search
243     *
244     * @param Event $event
245     * @param $param
246     */
247    public function echo_searchresults(Event $event, $param)
248    {
249        global $ACT;
250        global $QUERY;
251
252        if ($ACT !== 'search') {
253            return;
254        }
255
256        if (!plugin_isdisabled('elasticsearch')) return;
257
258        /** @var helper_plugin_tagging $hlp */
259        $hlp = plugin_load('helper', 'tagging');
260
261        $terms = $hlp->extractFromQuery(ft_queryParser(idx_get_indexer(), $QUERY));
262        if (!$terms) {
263            return;
264        }
265
266        $allTags = [];
267        foreach ($this->allTagsByPage as $tags) {
268            $allTags = array_merge($allTags, $tags);
269        }
270        $allTags = array_unique($allTags);
271
272        $suggestedTags = [];
273        foreach ($terms as $term) {
274            $term = str_replace('*', '', $term);
275            $suggestedTags = array_merge($suggestedTags, preg_grep("/$term/i", $allTags));
276        }
277        sort($suggestedTags);
278
279        if (!$suggestedTags) {
280            $this->originalQuery && $this->restoreSearchQuery();
281            return;
282        }
283
284        // create output HTML: tag search links
285        $results = '<div class="search_quickresult">';
286        $results .= '<h2>' . $this->getLang('search_suggestions')  . '</h2>';
287        $results .= '<ul class="search_quickhits">';
288
289        foreach ($suggestedTags as $tag) {
290            $results .= '<li><div class="li">';
291            $results .= $this->getSettingsLink('#' . $tag, 'q', '#' . $tag);
292            $results .= '</div></li>';
293        }
294        $results .= '</ul>';
295        $results .= '<div class="clearer"></div>';
296        $results .= '</div>';
297
298        if (preg_match('/<div class="nothing">.*?<\/div>/', $event->data)) {
299            // there are no other hits, replace the nothing found
300            $event->data = preg_replace('/<div class="nothing">.*?<\/div>/', $results, $event->data, 1);
301        } elseif (preg_match('/(<\/h2>)/', $event->data)) {
302            // insert it right before second level headline
303            $event->data = preg_replace('/(<h2>)/', $results . "\\1\n", $event->data, 1);
304        } else {
305            // unclear what happened, let's just append
306            $event->data .= $results;
307        }
308
309        // all done, finally restore the original query
310        $this->restoreSearchQuery();
311    }
312
313    /**
314     * Remove tags from query
315     *
316     * @param string $q
317     */
318    public static function removeTagsFromQuery(&$q)
319    {
320        $q = preg_replace('/#\w+/u', '', $q);
321    }
322
323    /**
324     * Restore original query on exit
325     */
326    protected function restoreSearchQuery()
327    {
328        global $QUERY;
329        $QUERY = $this->originalQuery;
330    }
331
332
333    /**
334     * Returns a link that includes all parameters set by inbuilt search tools
335     * and an optional additional parameter.
336     * If the passed parameter is q, its value will be REPLACED.
337     *
338     * @param string $label
339     * @param string $param
340     * @param string $value
341     * @return string
342     */
343    protected function getSettingsLink($label, $param = '', $value = '')
344    {
345        global $QUERY;
346
347        $Indexer = idx_get_indexer();
348        $parsedQuery = ft_queryParser($Indexer, $QUERY);
349        $searchState = new SearchState($parsedQuery);
350        $linkTag =  $searchState->getSearchLink($label);
351
352        // manipulate the link string because there is yet no way for inbuilt search to allow plugins
353        // to extend search queries
354        if ($param === '') {
355            return $linkTag;
356        } elseif ($param === 'q') {
357            return preg_replace('/q=[^&\'" ]*/', 'q=' . urlencode($value), $linkTag);
358        }
359        // FIXME current links have a strange format where href is set in single quotes
360        // and followed by a space so preg_replace would make more sense
361        return str_replace("' >", '&' . $param . '=' . $value . "'> ", $linkTag);
362    }
363}
364