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