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 20 * @param array $fullTextResults 21 * @param string $highlight 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('dta')) { 73 $searchForm->setHiddenField('dta', $INPUT->str('dta')); 74 } 75 if ($INPUT->has('dtb')) { 76 $searchForm->setHiddenField('dtb', $INPUT->str('dtb')); 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'); 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'); 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 global $lang; 191 $searchForm->addButton('toggleAssistant', $lang['search_toggle_tools']) 192 ->attr('type', 'button') 193 ->addClass('toggleAssistant'); 194 195 $searchForm->addTagOpen('div') 196 ->addClass('advancedOptions') 197 ->attr('style', 'display: none;'); 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'); 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'); 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'); 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'); 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 ? " ($count)" : ''); 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 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('dta') === $option['after']) { 429 $activeOption = $key; 430 break; 431 } 432 } 433 434 $searchForm->addTagOpen('div')->addClass('toggle'); 435 // render current 436 $currentWrapper = $searchForm->addTagOpen('div')->addClass('current'); 437 if ($INPUT->has('dtb') || $INPUT->has('dta')) { 438 $currentWrapper->addClass('changed'); 439 } 440 $searchForm->addHTML($options[$activeOption]['label']); 441 $searchForm->addTagClose('div'); 442 443 // render options list 444 $searchForm->addTagOpen('ul'); 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 protected function createPagenameFromQuery($parsedQuery) 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 .= '<h3>' . $lang['quickhits'] . ':</h3>'; 530 $html .= '<ul class="search_quickhits">'; 531 foreach ($data as $id => $title) { 532 $link = html_wikilink(':' . $id); 533 $eventData = [ 534 'listItemContent' => [$link], 535 'page' => $id, 536 ]; 537 trigger_event('SEARCH_RESULT_PAGELOOKUP', $eventData); 538 $html .= '<li>' . implode('', $eventData['listItemContent']) . '</li>'; 539 } 540 $html .= '</ul> '; 541 //clear float (see http://www.complexspiral.com/publications/containing-floats/) 542 $html .= '<div class="clearer"></div>'; 543 $html .= '</div>'; 544 545 return $html; 546 } 547 548 /** 549 * Build HTML for fulltext search results or "no results" message 550 * 551 * @param array $data the results of the fulltext search 552 * @param array $highlight the terms to be highlighted in the results 553 * 554 * @return string 555 */ 556 protected function getFulltextResultsHTML($data, $highlight) 557 { 558 global $lang; 559 560 if (empty($data)) { 561 return '<div class="nothing">' . $lang['nothingfound'] . '</div>'; 562 } 563 564 $html = '<div class="search_fulltextresult">'; 565 $html .= '<h3>' . $lang['search_fullresults'] . ':</h3>'; 566 567 $html .= '<dl class="search_results">'; 568 $num = 1; 569 570 foreach ($data as $id => $cnt) { 571 $resultLink = html_wikilink(':' . $id, null, $highlight); 572 573 $resultHeader = [$resultLink]; 574 575 576 $restrictQueryToNSLink = $this->restrictQueryToNSLink(getNS($id)); 577 if ($restrictQueryToNSLink) { 578 $resultHeader[] = $restrictQueryToNSLink; 579 } 580 581 $snippet = ''; 582 $lastMod = ''; 583 $mtime = filemtime(wikiFN($id)); 584 if ($cnt !== 0) { 585 $resultHeader[] = $cnt . ' ' . $lang['hits']; 586 if ($num < FT_SNIPPET_NUMBER) { // create snippets for the first number of matches only 587 $snippet = '<dd>' . ft_snippet($id, $highlight) . '</dd>'; 588 $lastMod = '<span class="search_results__lastmod">' . $lang['lastmod'] . ' '; 589 $lastMod .= '<time datetime="' . date_iso8601($mtime) . '">' . dformat($mtime) . '</time>'; 590 $lastMod .= '</span>'; 591 } 592 $num++; 593 } 594 595 $metaLine = '<div class="search_results__metaLine">'; 596 $metaLine .= $lastMod; 597 $metaLine .= '</div>'; 598 599 600 $eventData = [ 601 'resultHeader' => $resultHeader, 602 'resultBody' => [$metaLine, $snippet], 603 'page' => $id, 604 ]; 605 trigger_event('SEARCH_RESULT_FULLPAGE', $eventData); 606 $html .= '<div class="search_fullpage_result">'; 607 $html .= '<dt>' . implode(' ', $eventData['resultHeader']) . '</dt>'; 608 $html .= implode('', $eventData['resultBody']); 609 $html .= '</div>'; 610 } 611 $html .= '</dl>'; 612 613 $html .= '</div>'; 614 615 return $html; 616 } 617 618 /** 619 * create a link to restrict the current query to a namespace 620 * 621 * @param bool|string $ns the namespace to which to restrict the query 622 * 623 * @return bool|string 624 */ 625 protected function restrictQueryToNSLink($ns) 626 { 627 if (!$ns) { 628 return false; 629 } 630 if (!$this->isNamespaceAssistanceAvailable($this->parsedQuery)) { 631 return false; 632 } 633 if (!empty($this->parsedQuery['ns']) && $this->parsedQuery['ns'][0] === $ns) { 634 return false; 635 } 636 637 $name = '@' . $ns; 638 return $this->searchState->withNamespace($ns)->getSearchLink($name); 639 } 640} 641