1<?php 2 3namespace dokuwiki\Ui; 4 5use \dokuwiki\Form\Form; 6 7class Search extends Ui 8{ 9 protected $query; 10 protected $parsedQuery; 11 protected $pageLookupResults = array(); 12 protected $fullTextResults = array(); 13 protected $highlight = array(); 14 15 /** 16 * Search constructor. 17 * 18 * @param string $query the search query 19 */ 20 public function __construct() 21 { 22 global $QUERY; 23 $Indexer = idx_get_indexer(); 24 25 $this->query = $QUERY; 26 $this->parsedQuery = ft_queryParser($Indexer, $QUERY); 27 } 28 29 /** 30 * run the search 31 */ 32 public function execute() 33 { 34 $this->pageLookupResults = ft_pageLookup($this->query, true, useHeading('navigation')); 35 $this->fullTextResults = ft_pageSearch($this->query, $highlight); 36 $this->highlight = $highlight; 37 } 38 39 /** 40 * display the search result 41 * 42 * @return void 43 */ 44 public function show() 45 { 46 $searchHTML = ''; 47 48 $searchHTML .= $this->getSearchFormHTML($this->query); 49 50 $searchHTML .= $this->getSearchIntroHTML($this->query); 51 52 $searchHTML .= $this->getPageLookupHTML($this->pageLookupResults); 53 54 $searchHTML .= $this->getFulltextResultsHTML($this->fullTextResults, $this->highlight); 55 56 echo $searchHTML; 57 } 58 59 /** 60 * Get a form which can be used to adjust/refine the search 61 * 62 * @param string $query 63 * 64 * @return string 65 */ 66 protected function getSearchFormHTML($query) 67 { 68 global $lang, $ID, $INPUT; 69 70 $searchForm = (new Form())->attrs(['method' => 'get'])->addClass('search-results-form'); 71 $searchForm->setHiddenField('do', 'search'); 72 $searchForm->setHiddenField('id', $ID); 73 $searchForm->setHiddenField('searchPageForm', '1'); 74 if ($INPUT->has('after')) { 75 $searchForm->setHiddenField('after', $INPUT->str('after')); 76 } 77 if ($INPUT->has('before')) { 78 $searchForm->setHiddenField('before', $INPUT->str('before')); 79 } 80 $searchForm->addFieldsetOpen()->addClass('search-results-form__fieldset'); 81 $searchForm->addTextInput('q')->val($query)->useInput(false); 82 $searchForm->addButton('', $lang['btn_search'])->attr('type', 'submit'); 83 84 if ($this->isSearchAssistanceAvailable($this->parsedQuery)) { 85 $this->addSearchAssistanceElements($searchForm, $this->parsedQuery); 86 } else { 87 $searchForm->addClass('search-results-form--no-assistance'); 88 $searchForm->addTagOpen('span')->addClass('search-results-form__no-assistance-message'); 89 $searchForm->addHTML('FIXME Your query is too complex. Search assistance is unavailable. See <a href="https://doku.wiki/search">doku.wiki/search</a> for more help.'); 90 $searchForm->addTagClose('span'); 91 } 92 93 $searchForm->addFieldsetClose(); 94 95 trigger_event('SEARCH_FORM_DISPLAY', $searchForm); 96 97 return $searchForm->toHTML(); 98 } 99 100 /** 101 * Decide if the given query is simple enough to provide search assistance 102 * 103 * @param array $parsedQuery 104 * 105 * @return bool 106 */ 107 protected function isSearchAssistanceAvailable(array $parsedQuery) 108 { 109 if (count($parsedQuery['words']) > 1) { 110 return false; 111 } 112 if (!empty($parsedQuery['not'])) { 113 return false; 114 } 115 116 if (!empty($parsedQuery['phrases'])) { 117 return false; 118 } 119 120 if (!empty($parsedQuery['notns'])) { 121 return false; 122 } 123 if (count($parsedQuery['ns']) > 1) { 124 return false; 125 } 126 127 return true; 128 } 129 130 /** 131 * Add the elements to be used for search assistance 132 * 133 * @param Form $searchForm 134 * @param array $parsedQuery 135 */ 136 protected function addSearchAssistanceElements(Form $searchForm, array $parsedQuery) 137 { 138 $searchForm->addButton('toggleAssistant', 'toggle search assistant') 139 ->attr('type', 'button') 140 ->id('search-results-form__show-assistance-button') 141 ->addClass('search-results-form__show-assistance-button'); 142 143 $searchForm->addTagOpen('div') 144 ->addClass('js-advancedSearchOptions') 145 ->attr('style', 'display: none;'); 146 147 $this->addFragmentBehaviorLinks($searchForm, $parsedQuery); 148 $this->addNamespaceSelector($searchForm, $parsedQuery); 149 $this->addDateSelector($searchForm, $parsedQuery); 150 151 $searchForm->addTagClose('div'); 152 } 153 154 protected function addFragmentBehaviorLinks(Form $searchForm, array $parsedQuery) 155 { 156 $searchForm->addTagOpen('div')->addClass('search-results-form__subwrapper'); 157 $searchForm->addHTML('fragment behavior: '); 158 159 $this->addSearchLink( 160 $searchForm, 161 'exact match', 162 array_map(function($term){return trim($term, '*');},$this->parsedQuery['and']) 163 ); 164 165 $searchForm->addHTML(', '); 166 167 $this->addSearchLink( 168 $searchForm, 169 'starts with', 170 array_map(function($term){return trim($term, '*') . '*';},$this->parsedQuery['and']) 171 ); 172 173 $searchForm->addHTML(', '); 174 175 $this->addSearchLink( 176 $searchForm, 177 'ends with', 178 array_map(function($term){return '*' . trim($term, '*');},$this->parsedQuery['and']) 179 ); 180 181 $searchForm->addHTML(', '); 182 183 $this->addSearchLink( 184 $searchForm, 185 'contains', 186 array_map(function($term){return '*' . trim($term, '*') . '*';},$this->parsedQuery['and']) 187 ); 188 189 $searchForm->addTagClose('div'); 190 } 191 192 protected function addSearchLink( 193 Form $searchForm, 194 $label, 195 array $and = null, 196 array $ns = null, 197 array $not = null, 198 array $notns = null, 199 array $phrases = null, 200 $after = null, 201 $before = null 202 ) { 203 global $INPUT, $ID; 204 if (null === $and) { 205 $and = $this->parsedQuery['and']; 206 } 207 if (null === $ns) { 208 $ns = $this->parsedQuery['ns']; 209 } 210 if (null === $not) { 211 $not = $this->parsedQuery['not']; 212 } 213 if (null === $phrases) { 214 $phrases = $this->parsedQuery['phrases']; 215 } 216 if (null === $notns) { 217 $notns = $this->parsedQuery['notns']; 218 } 219 if (null === $after) { 220 $after = $INPUT->str('after'); 221 } 222 if (null === $before) { 223 $before = $INPUT->str('before'); 224 } 225 226 $newQuery = ft_queryUnparser_simple( 227 $and, 228 $not, 229 $phrases, 230 $ns, 231 $notns 232 ); 233 $hrefAttributes = ['do' => 'search', 'searchPageForm' => '1', 'q' => $newQuery]; 234 if ($after) { 235 $hrefAttributes['after'] = $after; 236 } 237 if ($before) { 238 $hrefAttributes['before'] = $before; 239 } 240 $searchForm->addTagOpen('a') 241 ->attrs([ 242 'href' => wl($ID, $hrefAttributes, false, '&') 243 ]) 244 ; 245 $searchForm->addHTML($label); 246 $searchForm->addTagClose('a'); 247 } 248 249 /** 250 * Add the elements for the namespace selector 251 * 252 * @param Form $searchForm 253 * @param array $parsedQuery 254 */ 255 protected function addNamespaceSelector(Form $searchForm, array $parsedQuery) 256 { 257 $baseNS = empty($parsedQuery['ns']) ? '' : $parsedQuery['ns'][0]; 258 $searchForm->addTagOpen('div')->addClass('search-results-form__subwrapper'); 259 260 $extraNS = $this->getAdditionalNamespacesFromResults($baseNS); 261 if (!empty($extraNS) || $baseNS) { 262 $searchForm->addTagOpen('div'); 263 $searchForm->addHTML('limit to namespace: '); 264 265 if ($baseNS) { 266 $this->addSearchLink( 267 $searchForm, 268 '(remove limit)', 269 null, 270 [], 271 null, 272 [] 273 ); 274 } 275 276 foreach ($extraNS as $extraNS => $count) { 277 $searchForm->addHTML(' '); 278 $label = $extraNS . ($count ? " ($count)" : ''); 279 280 $this->addSearchLink($searchForm, $label, null, [$extraNS], null, []); 281 } 282 $searchForm->addTagClose('div'); 283 } 284 285 $searchForm->addTagClose('div'); 286 } 287 288 /** 289 * Parse the full text results for their top namespaces below the given base namespace 290 * 291 * @param string $baseNS the namespace within which was searched, empty string for root namespace 292 * 293 * @return array an associative array with namespace => #number of found pages, sorted descending 294 */ 295 protected function getAdditionalNamespacesFromResults($baseNS) 296 { 297 $namespaces = []; 298 $baseNSLength = strlen($baseNS); 299 foreach ($this->fullTextResults as $page => $numberOfHits) { 300 $namespace = getNS($page); 301 if (!$namespace) { 302 continue; 303 } 304 if ($namespace === $baseNS) { 305 continue; 306 } 307 $firstColon = strpos((string)$namespace, ':', $baseNSLength + 1) ?: strlen($namespace); 308 $subtopNS = substr($namespace, 0, $firstColon); 309 if (empty($namespaces[$subtopNS])) { 310 $namespaces[$subtopNS] = 0; 311 } 312 $namespaces[$subtopNS] += 1; 313 } 314 arsort($namespaces); 315 return $namespaces; 316 } 317 318 /** 319 * @ToDo: we need to remember this date when clicking on other links 320 * @ToDo: custom date input 321 * 322 * @param Form $searchForm 323 * @param $parsedQuery 324 */ 325 protected function addDateSelector(Form $searchForm, $parsedQuery) { 326 $searchForm->addTagOpen('div')->addClass('search-results-form__subwrapper'); 327 $searchForm->addHTML('limit by date: '); 328 329 global $INPUT; 330 if ($INPUT->has('before') || $INPUT->has('after')) { 331 $this->addSearchLink( 332 $searchForm, 333 '(remove limit)', 334 null, 335 null, 336 null, 337 null, 338 null, 339 false, 340 false 341 ); 342 343 $searchForm->addHTML(', '); 344 } 345 346 if ($INPUT->str('after') === '1 week ago') { 347 $searchForm->addHTML('<span class="active">past 7 days</span>'); 348 } else { 349 $this->addSearchLink( 350 $searchForm, 351 'past 7 days', 352 null, 353 null, 354 null, 355 null, 356 null, 357 '1 week ago', 358 false 359 ); 360 } 361 362 $searchForm->addHTML(', '); 363 364 if ($INPUT->str('after') === '1 month ago') { 365 $searchForm->addHTML('<span class="active">past month</span>'); 366 } else { 367 $this->addSearchLink( 368 $searchForm, 369 'past month', 370 null, 371 null, 372 null, 373 null, 374 null, 375 '1 month ago', 376 false 377 ); 378 } 379 380 $searchForm->addHTML(', '); 381 382 if ($INPUT->str('after') === '1 year ago') { 383 $searchForm->addHTML('<span class="active">past year</span>'); 384 } else { 385 $this->addSearchLink( 386 $searchForm, 387 'past year', 388 null, 389 null, 390 null, 391 null, 392 null, 393 '1 year ago', 394 false 395 ); 396 } 397 398 $searchForm->addTagClose('div'); 399 } 400 401 402 /** 403 * Build the intro text for the search page 404 * 405 * @param string $query the search query 406 * 407 * @return string 408 */ 409 protected function getSearchIntroHTML($query) 410 { 411 global $ID, $lang; 412 413 $intro = p_locale_xhtml('searchpage'); 414 // allow use of placeholder in search intro 415 $pagecreateinfo = (auth_quickaclcheck($ID) >= AUTH_CREATE) ? $lang['searchcreatepage'] : ''; 416 $intro = str_replace( 417 array('@QUERY@', '@SEARCH@', '@CREATEPAGEINFO@'), 418 array(hsc(rawurlencode($query)), hsc($query), $pagecreateinfo), 419 $intro 420 ); 421 return $intro; 422 } 423 424 /** 425 * Build HTML for a list of pages with matching pagenames 426 * 427 * @param array $data search results 428 * 429 * @return string 430 */ 431 protected function getPageLookupHTML($data) 432 { 433 if (empty($data)) { 434 return ''; 435 } 436 437 global $lang; 438 439 $html = '<div class="search_quickresult">'; 440 $html .= '<h3>' . $lang['quickhits'] . ':</h3>'; 441 $html .= '<ul class="search_quickhits">'; 442 foreach ($data as $id => $title) { 443 $link = html_wikilink(':' . $id); 444 $eventData = [ 445 'listItemContent' => [$link], 446 'page' => $id, 447 ]; 448 trigger_event('SEARCH_RESULT_PAGELOOKUP', $eventData); 449 $html .= '<li>' . implode('', $eventData['listItemContent']) . '</li>'; 450 } 451 $html .= '</ul> '; 452 //clear float (see http://www.complexspiral.com/publications/containing-floats/) 453 $html .= '<div class="clearer"></div>'; 454 $html .= '</div>'; 455 456 return $html; 457 } 458 459 /** 460 * Build HTML for fulltext search results or "no results" message 461 * 462 * @param array $data the results of the fulltext search 463 * @param array $highlight the terms to be highlighted in the results 464 * 465 * @return string 466 */ 467 protected function getFulltextResultsHTML($data, $highlight) 468 { 469 global $lang; 470 471 if (empty($data)) { 472 return '<div class="nothing">' . $lang['nothingfound'] . '</div>'; 473 } 474 475 $html = ''; 476 $html .= '<dl class="search_results">'; 477 $num = 1; 478 479 foreach ($data as $id => $cnt) { 480 $resultLink = html_wikilink(':' . $id, null, $highlight); 481 482 $resultHeader = [$resultLink]; 483 484 485 $restrictQueryToNSLink = $this->restrictQueryToNSLink(getNS($id)); 486 if ($restrictQueryToNSLink) { 487 $resultHeader[] = $restrictQueryToNSLink; 488 } 489 490 $snippet = ''; 491 $lastMod = ''; 492 $mtime = filemtime(wikiFN($id)); 493 if ($cnt !== 0) { 494 $resultHeader[] = $cnt . ' ' . $lang['hits']; 495 if ($num < FT_SNIPPET_NUMBER) { // create snippets for the first number of matches only 496 $snippet = '<dd>' . ft_snippet($id, $highlight) . '</dd>'; 497 $lastMod = '<span class="search_results__lastmod">'. $lang['lastmod'] . ' '; 498 $lastMod .= '<time datetime="' . date_iso8601($mtime) . '">'. dformat($mtime) . '</time>'; 499 $lastMod .= '</span>'; 500 } 501 $num++; 502 } 503 504 $metaLine = '<div class="search_results__metaLine">'; 505 $metaLine .= $lastMod; 506 $metaLine .= '</div>'; 507 508 509 $eventData = [ 510 'resultHeader' => $resultHeader, 511 'resultBody' => [$metaLine, $snippet], 512 'page' => $id, 513 ]; 514 trigger_event('SEARCH_RESULT_FULLPAGE', $eventData); 515 $html .= '<div class="search_fullpage_result">'; 516 $html .= '<dt>' . implode(' ', $eventData['resultHeader']) . '</dt>'; 517 $html .= implode('', $eventData['resultBody']); 518 $html .= '</div>'; 519 } 520 $html .= '</dl>'; 521 522 return $html; 523 } 524 525 /** 526 * create a link to restrict the current query to a namespace 527 * 528 * @param bool|string $ns the namespace to which to restrict the query 529 * 530 * @return bool|string 531 */ 532 protected function restrictQueryToNSLink($ns) 533 { 534 if (!$ns) { 535 return false; 536 } 537 if (!$this->isSearchAssistanceAvailable($this->parsedQuery)) { 538 return false; 539 } 540 if (!empty($this->parsedQuery['ns']) && $this->parsedQuery['ns'][0] === $ns) { 541 return false; 542 } 543 $name = '@' . $ns; 544 $tmpForm = new Form(); 545 $this->addSearchLink($tmpForm, $name, null, [$ns], null, []); 546 return $tmpForm->toHTML(); 547 } 548} 549