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