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