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