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