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