1<?php 2 3namespace dokuwiki\Ui; 4 5use dokuwiki\Extension\Event; 6use dokuwiki\Form\Form; 7use dokuwiki\Utf8\PhpString; 8use dokuwiki\Utf8\Sort; 9 10class Search extends Ui 11{ 12 protected $query; 13 protected $parsedQuery; 14 protected $searchState; 15 protected $pageLookupResults = []; 16 protected $fullTextResults = []; 17 protected $highlight = []; 18 19 /** 20 * Search constructor. 21 * 22 * @param array $pageLookupResults pagename lookup results in the form [pagename => pagetitle] 23 * @param array $fullTextResults fulltext search results in the form [pagename => #hits] 24 * @param array $highlight array of strings to be highlighted 25 */ 26 public function __construct(array $pageLookupResults, array $fullTextResults, $highlight) 27 { 28 global $QUERY; 29 $Indexer = idx_get_indexer(); 30 31 $this->query = $QUERY; 32 $this->parsedQuery = ft_queryParser($Indexer, $QUERY); 33 $this->searchState = new SearchState($this->parsedQuery); 34 35 $this->pageLookupResults = $pageLookupResults; 36 $this->fullTextResults = $fullTextResults; 37 $this->highlight = $highlight; 38 } 39 40 /** 41 * display the search result 42 * 43 * @return void 44 */ 45 public function show() 46 { 47 $searchHTML = $this->getSearchIntroHTML($this->query); 48 49 $searchHTML .= $this->getSearchFormHTML($this->query); 50 51 $searchHTML .= $this->getPageLookupHTML($this->pageLookupResults); 52 53 $searchHTML .= $this->getFulltextResultsHTML($this->fullTextResults, $this->highlight); 54 55 echo $searchHTML; 56 } 57 58 /** 59 * Get a form which can be used to adjust/refine the search 60 * 61 * @param string $query 62 * 63 * @return string 64 */ 65 protected function getSearchFormHTML($query) 66 { 67 global $lang, $ID, $INPUT; 68 69 $searchForm = (new Form(['method' => 'get'], true))->addClass('search-results-form'); 70 $searchForm->setHiddenField('do', 'search'); 71 $searchForm->setHiddenField('id', $ID); 72 $searchForm->setHiddenField('sf', '1'); 73 if ($INPUT->has('min')) { 74 $searchForm->setHiddenField('min', $INPUT->str('min')); 75 } 76 if ($INPUT->has('max')) { 77 $searchForm->setHiddenField('max', $INPUT->str('max')); 78 } 79 if ($INPUT->has('srt')) { 80 $searchForm->setHiddenField('srt', $INPUT->str('srt')); 81 } 82 $searchForm->addFieldsetOpen()->addClass('search-form'); 83 $searchForm->addTextInput('q')->val($query)->useInput(false); 84 $searchForm->addButton('', $lang['btn_search'])->attr('type', 'submit'); 85 86 $this->addSearchAssistanceElements($searchForm); 87 88 $searchForm->addFieldsetClose(); 89 90 return $searchForm->toHTML('Search'); 91 } 92 93 /** 94 * Add elements to adjust how the results are sorted 95 * 96 * @param Form $searchForm 97 */ 98 protected function addSortTool(Form $searchForm) 99 { 100 global $INPUT, $lang; 101 102 $options = [ 103 'hits' => [ 104 'label' => $lang['search_sort_by_hits'], 105 'sort' => '', 106 ], 107 'mtime' => [ 108 'label' => $lang['search_sort_by_mtime'], 109 'sort' => 'mtime', 110 ], 111 ]; 112 $activeOption = 'hits'; 113 114 if ($INPUT->str('srt') === 'mtime') { 115 $activeOption = 'mtime'; 116 } 117 118 $searchForm->addTagOpen('div')->addClass('toggle')->attr('aria-haspopup', 'true'); 119 // render current 120 $currentWrapper = $searchForm->addTagOpen('div')->addClass('current'); 121 if ($activeOption !== 'hits') { 122 $currentWrapper->addClass('changed'); 123 } 124 $searchForm->addHTML($options[$activeOption]['label']); 125 $searchForm->addTagClose('div'); 126 127 // render options list 128 $searchForm->addTagOpen('ul')->attr('aria-expanded', 'false'); 129 130 foreach ($options as $key => $option) { 131 $listItem = $searchForm->addTagOpen('li'); 132 133 if ($key === $activeOption) { 134 $listItem->addClass('active'); 135 $searchForm->addHTML($option['label']); 136 } else { 137 $link = $this->searchState->withSorting($option['sort'])->getSearchLink($option['label']); 138 $searchForm->addHTML($link); 139 } 140 $searchForm->addTagClose('li'); 141 } 142 $searchForm->addTagClose('ul'); 143 144 $searchForm->addTagClose('div'); 145 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('@' . $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 = $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 /** 345 * Parse the full text results for their top namespaces below the given base namespace 346 * 347 * @param string $baseNS the namespace within which was searched, empty string for root namespace 348 * 349 * @return array an associative array with namespace => #number of found pages, sorted descending 350 */ 351 protected function getAdditionalNamespacesFromResults($baseNS) 352 { 353 $namespaces = []; 354 $baseNSLength = strlen($baseNS); 355 foreach ($this->fullTextResults as $page => $numberOfHits) { 356 $namespace = getNS($page); 357 if (!$namespace) { 358 continue; 359 } 360 if ($namespace === $baseNS) { 361 continue; 362 } 363 $firstColon = strpos((string)$namespace, ':', $baseNSLength + 1) ?: strlen($namespace); 364 $subtopNS = substr($namespace, 0, $firstColon); 365 if (empty($namespaces[$subtopNS])) { 366 $namespaces[$subtopNS] = 0; 367 } 368 ++$namespaces[$subtopNS]; 369 } 370 Sort::ksort($namespaces); 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('min') === $option['after']) { 409 $activeOption = $key; 410 break; 411 } 412 } 413 414 $searchForm->addTagOpen('div')->addClass('toggle')->attr('aria-haspopup', 'true'); 415 // render current 416 $currentWrapper = $searchForm->addTagOpen('div')->addClass('current'); 417 if ($INPUT->has('max') || $INPUT->has('min')) { 418 $currentWrapper->addClass('changed'); 419 } 420 $searchForm->addHTML($options[$activeOption]['label']); 421 $searchForm->addTagClose('div'); 422 423 // render options list 424 $searchForm->addTagOpen('ul')->attr('aria-expanded', 'false'); 425 426 foreach ($options as $key => $option) { 427 $listItem = $searchForm->addTagOpen('li'); 428 429 if ($key === $activeOption) { 430 $listItem->addClass('active'); 431 $searchForm->addHTML($option['label']); 432 } else { 433 $link = $this->searchState 434 ->withTimeLimitations($option['after'], $option['before']) 435 ->getSearchLink($option['label']); 436 $searchForm->addHTML($link); 437 } 438 $searchForm->addTagClose('li'); 439 } 440 $searchForm->addTagClose('ul'); 441 442 $searchForm->addTagClose('div'); 443 } 444 445 446 /** 447 * Build the intro text for the search page 448 * 449 * @param string $query the search query 450 * 451 * @return string 452 */ 453 protected function getSearchIntroHTML($query) 454 { 455 global $lang; 456 457 $intro = p_locale_xhtml('searchpage'); 458 459 $queryPagename = $this->createPagenameFromQuery($this->parsedQuery); 460 $createQueryPageLink = html_wikilink($queryPagename . '?do=edit', $queryPagename); 461 462 $pagecreateinfo = ''; 463 if (auth_quickaclcheck($queryPagename) >= AUTH_CREATE) { 464 $pagecreateinfo = sprintf($lang['searchcreatepage'], $createQueryPageLink); 465 } 466 return str_replace( 467 ['@QUERY@', '@SEARCH@', '@CREATEPAGEINFO@'], 468 [hsc(rawurlencode($query)), hsc($query), $pagecreateinfo], 469 $intro 470 ); 471 } 472 473 /** 474 * Create a pagename based the parsed search query 475 * 476 * @param array $parsedQuery 477 * 478 * @return string pagename constructed from the parsed query 479 */ 480 public function createPagenameFromQuery($parsedQuery) 481 { 482 $cleanedQuery = cleanID($parsedQuery['query']); // already strtolowered 483 if ($cleanedQuery === PhpString::strtolower($parsedQuery['query'])) { 484 return ':' . $cleanedQuery; 485 } 486 $pagename = ''; 487 if (!empty($parsedQuery['ns'])) { 488 $pagename .= ':' . cleanID($parsedQuery['ns'][0]); 489 } 490 $pagename .= ':' . cleanID(implode(' ', $parsedQuery['highlight'])); 491 return $pagename; 492 } 493 494 /** 495 * Build HTML for a list of pages with matching pagenames 496 * 497 * @param array $data search results 498 * 499 * @return string 500 */ 501 protected function getPageLookupHTML($data) 502 { 503 if (empty($data)) { 504 return ''; 505 } 506 507 global $lang; 508 509 $html = '<div class="search_quickresult">'; 510 $html .= '<h2>' . $lang['quickhits'] . ':</h2>'; 511 $html .= '<ul class="search_quickhits">'; 512 foreach (array_keys($data) as $id) { 513 $name = null; 514 if (!useHeading('navigation') && $ns = getNS($id)) { 515 $name = shorten(noNS($id), ' (' . $ns . ')', 30); 516 } 517 $link = html_wikilink(':' . $id, $name); 518 $eventData = [ 519 'listItemContent' => [$link], 520 'page' => $id, 521 ]; 522 Event::createAndTrigger('SEARCH_RESULT_PAGELOOKUP', $eventData); 523 $html .= '<li>' . implode('', $eventData['listItemContent']) . '</li>'; 524 } 525 $html .= '</ul> '; 526 //clear float (see http://www.complexspiral.com/publications/containing-floats/) 527 $html .= '<div class="clearer"></div>'; 528 $html .= '</div>'; 529 530 return $html; 531 } 532 533 /** 534 * Build HTML for fulltext search results or "no results" message 535 * 536 * @param array $data the results of the fulltext search 537 * @param array $highlight the terms to be highlighted in the results 538 * 539 * @return string 540 */ 541 protected function getFulltextResultsHTML($data, $highlight) 542 { 543 global $lang; 544 545 if (empty($data)) { 546 return '<div class="nothing">' . $lang['nothingfound'] . '</div>'; 547 } 548 549 $html = '<div class="search_fulltextresult">'; 550 $html .= '<h2>' . $lang['search_fullresults'] . ':</h2>'; 551 552 $html .= '<dl class="search_results">'; 553 $num = 0; 554 $position = 0; 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 <= FT_SNIPPET_NUMBER) { // create snippets for the first number of matches only 580 $resultBody['snippet'] = ft_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