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