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