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