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