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