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