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