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