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