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