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