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