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