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