[ Index ]

PHP Cross Reference of DokuWiki

title

Body

[close]

/inc/Ui/ -> Search.php (source)

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