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