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