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