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->getSearchFormHTML($this->query); 47 48 $searchHTML .= $this->getSearchIntroHTML($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 $ID, $lang; 464 465 $intro = p_locale_xhtml('searchpage'); 466 // allow use of placeholder in search intro 467 $pagecreateinfo = (auth_quickaclcheck($ID) >= AUTH_CREATE) ? $lang['searchcreatepage'] : ''; 468 $intro = str_replace( 469 array('@QUERY@', '@SEARCH@', '@CREATEPAGEINFO@'), 470 array(hsc(rawurlencode($query)), hsc($query), $pagecreateinfo), 471 $intro 472 ); 473 return $intro; 474 } 475 476 /** 477 * Build HTML for a list of pages with matching pagenames 478 * 479 * @param array $data search results 480 * 481 * @return string 482 */ 483 protected function getPageLookupHTML($data) 484 { 485 if (empty($data)) { 486 return ''; 487 } 488 489 global $lang; 490 491 $html = '<div class="search_quickresult">'; 492 $html .= '<h3>' . $lang['quickhits'] . ':</h3>'; 493 $html .= '<ul class="search_quickhits">'; 494 foreach ($data as $id => $title) { 495 $link = html_wikilink(':' . $id); 496 $eventData = [ 497 'listItemContent' => [$link], 498 'page' => $id, 499 ]; 500 trigger_event('SEARCH_RESULT_PAGELOOKUP', $eventData); 501 $html .= '<li>' . implode('', $eventData['listItemContent']) . '</li>'; 502 } 503 $html .= '</ul> '; 504 //clear float (see http://www.complexspiral.com/publications/containing-floats/) 505 $html .= '<div class="clearer"></div>'; 506 $html .= '</div>'; 507 508 return $html; 509 } 510 511 /** 512 * Build HTML for fulltext search results or "no results" message 513 * 514 * @param array $data the results of the fulltext search 515 * @param array $highlight the terms to be highlighted in the results 516 * 517 * @return string 518 */ 519 protected function getFulltextResultsHTML($data, $highlight) 520 { 521 global $lang; 522 523 if (empty($data)) { 524 return '<div class="nothing">' . $lang['nothingfound'] . '</div>'; 525 } 526 527 $html = ''; 528 $html .= '<dl class="search_results">'; 529 $num = 1; 530 531 foreach ($data as $id => $cnt) { 532 $resultLink = html_wikilink(':' . $id, null, $highlight); 533 534 $resultHeader = [$resultLink]; 535 536 537 $restrictQueryToNSLink = $this->restrictQueryToNSLink(getNS($id)); 538 if ($restrictQueryToNSLink) { 539 $resultHeader[] = $restrictQueryToNSLink; 540 } 541 542 $snippet = ''; 543 $lastMod = ''; 544 $mtime = filemtime(wikiFN($id)); 545 if ($cnt !== 0) { 546 $resultHeader[] = $cnt . ' ' . $lang['hits']; 547 if ($num < FT_SNIPPET_NUMBER) { // create snippets for the first number of matches only 548 $snippet = '<dd>' . ft_snippet($id, $highlight) . '</dd>'; 549 $lastMod = '<span class="search_results__lastmod">' . $lang['lastmod'] . ' '; 550 $lastMod .= '<time datetime="' . date_iso8601($mtime) . '">' . dformat($mtime) . '</time>'; 551 $lastMod .= '</span>'; 552 } 553 $num++; 554 } 555 556 $metaLine = '<div class="search_results__metaLine">'; 557 $metaLine .= $lastMod; 558 $metaLine .= '</div>'; 559 560 561 $eventData = [ 562 'resultHeader' => $resultHeader, 563 'resultBody' => [$metaLine, $snippet], 564 'page' => $id, 565 ]; 566 trigger_event('SEARCH_RESULT_FULLPAGE', $eventData); 567 $html .= '<div class="search_fullpage_result">'; 568 $html .= '<dt>' . implode(' ', $eventData['resultHeader']) . '</dt>'; 569 $html .= implode('', $eventData['resultBody']); 570 $html .= '</div>'; 571 } 572 $html .= '</dl>'; 573 574 return $html; 575 } 576 577 /** 578 * create a link to restrict the current query to a namespace 579 * 580 * @param bool|string $ns the namespace to which to restrict the query 581 * 582 * @return bool|string 583 */ 584 protected function restrictQueryToNSLink($ns) 585 { 586 if (!$ns) { 587 return false; 588 } 589 if (!$this->isNamespaceAssistanceAvailable($this->parsedQuery)) { 590 return false; 591 } 592 if (!empty($this->parsedQuery['ns']) && $this->parsedQuery['ns'][0] === $ns) { 593 return false; 594 } 595 $name = '@' . $ns; 596 $tmpForm = new Form(); 597 $this->searchState->addSeachLinkNS($tmpForm, $name, $ns); 598 return $tmpForm->toHTML(); 599 } 600} 601