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 = 'exact'; 239 foreach ($options as $key => $option) { 240 if ($this->parsedQuery['and'] === $option['and']) { 241 $activeOption = $key; 242 } 243 } 244 245 $searchForm->addTagOpen('div')->addClass('search-tool js-search-tool'); 246 // render current 247 $currentWrapper = $searchForm->addTagOpen('div')->addClass('search-tool__current js-current'); 248 if ($activeOption !== 'exact') { 249 $currentWrapper->addClass('search-tool__current--changed'); 250 } 251 $searchForm->addHTML($options[$activeOption]['label']); 252 $searchForm->addTagClose('div'); 253 254 // render options list 255 $searchForm->addTagOpen('ul')->addClass('search-tool__options-list js-optionsList'); 256 257 foreach ($options as $key => $option) { 258 $listItem = $searchForm->addTagOpen('li')->addClass('search-tool__options-list-item'); 259 260 if ($key === $activeOption) { 261 $listItem->addClass('search-tool__options-list-item--active'); 262 $searchForm->addHTML($option['label']); 263 } else { 264 $this->searchState->addSearchLinkFragment( 265 $searchForm, 266 $option['label'], 267 $option['and'], 268 $option['not'] 269 ); 270 } 271 $searchForm->addTagClose('li'); 272 } 273 $searchForm->addTagClose('ul'); 274 275 $searchForm->addTagClose('div'); 276 277 // render options list 278 } 279 280 /** 281 * Add the elements for the namespace selector 282 * 283 * @param Form $searchForm 284 */ 285 protected function addNamespaceSelector(Form $searchForm) 286 { 287 if (!$this->isNamespaceAssistanceAvailable($this->parsedQuery)) { 288 return; 289 } 290 291 global $lang; 292 293 $baseNS = empty($this->parsedQuery['ns']) ? '' : $this->parsedQuery['ns'][0]; 294 $extraNS = $this->getAdditionalNamespacesFromResults($baseNS); 295 296 $searchForm->addTagOpen('div')->addClass('search-tool js-search-tool'); 297 // render current 298 $currentWrapper = $searchForm->addTagOpen('div')->addClass('search-tool__current js-current'); 299 if ($baseNS) { 300 $currentWrapper->addClass('search-tool__current--changed'); 301 $searchForm->addHTML('@' . $baseNS); 302 } else { 303 $searchForm->addHTML($lang['search_any_ns']); 304 } 305 $searchForm->addTagClose('div'); 306 307 // render options list 308 $searchForm->addTagOpen('ul')->addClass('search-tool__options-list js-optionsList'); 309 310 $listItem = $searchForm->addTagOpen('li')->addClass('search-tool__options-list-item'); 311 if ($baseNS) { 312 $listItem->addClass('search-tool__options-list-item--active'); 313 $this->searchState->addSeachLinkNS( 314 $searchForm, 315 $lang['search_any_ns'], 316 '' 317 ); 318 } else { 319 $searchForm->addHTML($lang['search_any_ns']); 320 } 321 $searchForm->addTagClose('li'); 322 323 foreach ($extraNS as $ns => $count) { 324 $listItem = $searchForm->addTagOpen('li')->addClass('search-tool__options-list-item'); 325 $label = $ns . ($count ? " ($count)" : ''); 326 327 if ($ns === $baseNS) { 328 $listItem->addClass('search-tool__options-list-item--active'); 329 $searchForm->addHTML($label); 330 } else { 331 $this->searchState->addSeachLinkNS( 332 $searchForm, 333 $label, 334 $ns 335 ); 336 } 337 $searchForm->addTagClose('li'); 338 } 339 $searchForm->addTagClose('ul'); 340 341 $searchForm->addTagClose('div'); 342 343 } 344 345 /** 346 * Parse the full text results for their top namespaces below the given base namespace 347 * 348 * @param string $baseNS the namespace within which was searched, empty string for root namespace 349 * 350 * @return array an associative array with namespace => #number of found pages, sorted descending 351 */ 352 protected function getAdditionalNamespacesFromResults($baseNS) 353 { 354 $namespaces = []; 355 $baseNSLength = strlen($baseNS); 356 foreach ($this->fullTextResults as $page => $numberOfHits) { 357 $namespace = getNS($page); 358 if (!$namespace) { 359 continue; 360 } 361 if ($namespace === $baseNS) { 362 continue; 363 } 364 $firstColon = strpos((string)$namespace, ':', $baseNSLength + 1) ?: strlen($namespace); 365 $subtopNS = substr($namespace, 0, $firstColon); 366 if (empty($namespaces[$subtopNS])) { 367 $namespaces[$subtopNS] = 0; 368 } 369 $namespaces[$subtopNS] += 1; 370 } 371 arsort($namespaces); 372 return $namespaces; 373 } 374 375 /** 376 * @ToDo: custom date input 377 * 378 * @param Form $searchForm 379 */ 380 protected function addDateSelector(Form $searchForm) 381 { 382 global $INPUT, $lang; 383 384 $options = [ 385 'any' => [ 386 'before' => false, 387 'after' => false, 388 'label' => $lang['search_any_time'], 389 ], 390 'week' => [ 391 'before' => false, 392 'after' => '1 week ago', 393 'label' => $lang['search_past_7_days'], 394 ], 395 'month' => [ 396 'before' => false, 397 'after' => '1 month ago', 398 'label' => $lang['search_past_month'], 399 ], 400 'year' => [ 401 'before' => false, 402 'after' => '1 year ago', 403 'label' => $lang['search_past_year'], 404 ], 405 ]; 406 $activeOption = 'any'; 407 foreach ($options as $key => $option) { 408 if ($INPUT->str('after') === $option['after']) { 409 $activeOption = $key; 410 break; 411 } 412 } 413 414 $searchForm->addTagOpen('div')->addClass('search-tool js-search-tool'); 415 // render current 416 $currentWrapper = $searchForm->addTagOpen('div')->addClass('search-tool__current js-current'); 417 if ($INPUT->has('before') || $INPUT->has('after')) { 418 $currentWrapper->addClass('search-tool__current--changed'); 419 } 420 $searchForm->addHTML($options[$activeOption]['label']); 421 $searchForm->addTagClose('div'); 422 423 // render options list 424 $searchForm->addTagOpen('ul')->addClass('search-tool__options-list js-optionsList'); 425 426 foreach ($options as $key => $option) { 427 $listItem = $searchForm->addTagOpen('li')->addClass('search-tool__options-list-item'); 428 429 if ($key === $activeOption) { 430 $listItem->addClass('search-tool__options-list-item--active'); 431 $searchForm->addHTML($option['label']); 432 } else { 433 $this->searchState->addSearchLinkTime( 434 $searchForm, 435 $option['label'], 436 $option['after'], 437 $option['before'] 438 ); 439 } 440 $searchForm->addTagClose('li'); 441 } 442 $searchForm->addTagClose('ul'); 443 444 $searchForm->addTagClose('div'); 445 } 446 447 448 /** 449 * Build the intro text for the search page 450 * 451 * @param string $query the search query 452 * 453 * @return string 454 */ 455 protected function getSearchIntroHTML($query) 456 { 457 global $ID, $lang; 458 459 $intro = p_locale_xhtml('searchpage'); 460 // allow use of placeholder in search intro 461 $pagecreateinfo = (auth_quickaclcheck($ID) >= AUTH_CREATE) ? $lang['searchcreatepage'] : ''; 462 $intro = str_replace( 463 array('@QUERY@', '@SEARCH@', '@CREATEPAGEINFO@'), 464 array(hsc(rawurlencode($query)), hsc($query), $pagecreateinfo), 465 $intro 466 ); 467 return $intro; 468 } 469 470 /** 471 * Build HTML for a list of pages with matching pagenames 472 * 473 * @param array $data search results 474 * 475 * @return string 476 */ 477 protected function getPageLookupHTML($data) 478 { 479 if (empty($data)) { 480 return ''; 481 } 482 483 global $lang; 484 485 $html = '<div class="search_quickresult">'; 486 $html .= '<h3>' . $lang['quickhits'] . ':</h3>'; 487 $html .= '<ul class="search_quickhits">'; 488 foreach ($data as $id => $title) { 489 $link = html_wikilink(':' . $id); 490 $eventData = [ 491 'listItemContent' => [$link], 492 'page' => $id, 493 ]; 494 trigger_event('SEARCH_RESULT_PAGELOOKUP', $eventData); 495 $html .= '<li>' . implode('', $eventData['listItemContent']) . '</li>'; 496 } 497 $html .= '</ul> '; 498 //clear float (see http://www.complexspiral.com/publications/containing-floats/) 499 $html .= '<div class="clearer"></div>'; 500 $html .= '</div>'; 501 502 return $html; 503 } 504 505 /** 506 * Build HTML for fulltext search results or "no results" message 507 * 508 * @param array $data the results of the fulltext search 509 * @param array $highlight the terms to be highlighted in the results 510 * 511 * @return string 512 */ 513 protected function getFulltextResultsHTML($data, $highlight) 514 { 515 global $lang; 516 517 if (empty($data)) { 518 return '<div class="nothing">' . $lang['nothingfound'] . '</div>'; 519 } 520 521 $html = ''; 522 $html .= '<dl class="search_results">'; 523 $num = 1; 524 525 foreach ($data as $id => $cnt) { 526 $resultLink = html_wikilink(':' . $id, null, $highlight); 527 528 $resultHeader = [$resultLink]; 529 530 531 $restrictQueryToNSLink = $this->restrictQueryToNSLink(getNS($id)); 532 if ($restrictQueryToNSLink) { 533 $resultHeader[] = $restrictQueryToNSLink; 534 } 535 536 $snippet = ''; 537 $lastMod = ''; 538 $mtime = filemtime(wikiFN($id)); 539 if ($cnt !== 0) { 540 $resultHeader[] = $cnt . ' ' . $lang['hits']; 541 if ($num < FT_SNIPPET_NUMBER) { // create snippets for the first number of matches only 542 $snippet = '<dd>' . ft_snippet($id, $highlight) . '</dd>'; 543 $lastMod = '<span class="search_results__lastmod">' . $lang['lastmod'] . ' '; 544 $lastMod .= '<time datetime="' . date_iso8601($mtime) . '">' . dformat($mtime) . '</time>'; 545 $lastMod .= '</span>'; 546 } 547 $num++; 548 } 549 550 $metaLine = '<div class="search_results__metaLine">'; 551 $metaLine .= $lastMod; 552 $metaLine .= '</div>'; 553 554 555 $eventData = [ 556 'resultHeader' => $resultHeader, 557 'resultBody' => [$metaLine, $snippet], 558 'page' => $id, 559 ]; 560 trigger_event('SEARCH_RESULT_FULLPAGE', $eventData); 561 $html .= '<div class="search_fullpage_result">'; 562 $html .= '<dt>' . implode(' ', $eventData['resultHeader']) . '</dt>'; 563 $html .= implode('', $eventData['resultBody']); 564 $html .= '</div>'; 565 } 566 $html .= '</dl>'; 567 568 return $html; 569 } 570 571 /** 572 * create a link to restrict the current query to a namespace 573 * 574 * @param bool|string $ns the namespace to which to restrict the query 575 * 576 * @return bool|string 577 */ 578 protected function restrictQueryToNSLink($ns) 579 { 580 if (!$ns) { 581 return false; 582 } 583 if (!$this->isNamespaceAssistanceAvailable($this->parsedQuery)) { 584 return false; 585 } 586 if (!empty($this->parsedQuery['ns']) && $this->parsedQuery['ns'][0] === $ns) { 587 return false; 588 } 589 $name = '@' . $ns; 590 $tmpForm = new Form(); 591 $this->searchState->addSeachLinkNS($tmpForm, $name, $ns); 592 return $tmpForm->toHTML(); 593 } 594} 595