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