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