[ Index ]

PHP Cross Reference of DokuWiki

title

Body

[close]

/inc/ -> fulltext.php (source)

   1  <?php
   2  
   3  /**
   4   * DokuWiki fulltextsearch functions using the index
   5   *
   6   * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
   7   * @author     Andreas Gohr <andi@splitbrain.org>
   8   */
   9  
  10  use dokuwiki\Utf8\Asian;
  11  use dokuwiki\Search\Indexer;
  12  use dokuwiki\Extension\Event;
  13  use dokuwiki\Utf8\Clean;
  14  use dokuwiki\Utf8\PhpString;
  15  use dokuwiki\Utf8\Sort;
  16  
  17  /**
  18   * create snippets for the first few results only
  19   */
  20  if (!defined('FT_SNIPPET_NUMBER')) define('FT_SNIPPET_NUMBER', 15);
  21  
  22  /**
  23   * The fulltext search
  24   *
  25   * Returns a list of matching documents for the given query
  26   *
  27   * refactored into ft_pageSearch(), _ft_pageSearch() and trigger_event()
  28   *
  29   * @param string     $query
  30   * @param array      $highlight
  31   * @param string     $sort
  32   * @param int|string $after  only show results with mtime after this date, accepts timestap or strtotime arguments
  33   * @param int|string $before only show results with mtime before this date, accepts timestap or strtotime arguments
  34   *
  35   * @return array
  36   */
  37  function ft_pageSearch($query, &$highlight, $sort = null, $after = null, $before = null)
  38  {
  39  
  40      if ($sort === null) {
  41          $sort = 'hits';
  42      }
  43      $data = [
  44          'query' => $query,
  45          'sort' => $sort,
  46          'after' => $after,
  47          'before' => $before
  48      ];
  49      $data['highlight'] =& $highlight;
  50  
  51      return Event::createAndTrigger('SEARCH_QUERY_FULLPAGE', $data, '_ft_pageSearch');
  52  }
  53  
  54  /**
  55   * Returns a list of matching documents for the given query
  56   *
  57   * @author Andreas Gohr <andi@splitbrain.org>
  58   * @author Kazutaka Miyasaka <kazmiya@gmail.com>
  59   *
  60   * @param array $data event data
  61   * @return array matching documents
  62   */
  63  function _ft_pageSearch(&$data)
  64  {
  65      $Indexer = idx_get_indexer();
  66  
  67      // parse the given query
  68      $q = ft_queryParser($Indexer, $data['query']);
  69      $data['highlight'] = $q['highlight'];
  70  
  71      if (empty($q['parsed_ary'])) return [];
  72  
  73      // lookup all words found in the query
  74      $lookup = $Indexer->lookup($q['words']);
  75  
  76      // get all pages in this dokuwiki site (!: includes nonexistent pages)
  77      $pages_all = [];
  78      foreach ($Indexer->getPages() as $id) {
  79          $pages_all[$id] = 0; // base: 0 hit
  80      }
  81  
  82      // process the query
  83      $stack = [];
  84      foreach ($q['parsed_ary'] as $token) {
  85          switch (substr($token, 0, 3)) {
  86              case 'W+:':
  87              case 'W-:':
  88              case 'W_:': // word
  89                  $word    = substr($token, 3);
  90                  if (isset($lookup[$word])) {
  91                      $stack[] = (array)$lookup[$word];
  92                  }
  93                  break;
  94              case 'P+:':
  95              case 'P-:': // phrase
  96                  $phrase = substr($token, 3);
  97                  // since phrases are always parsed as ((W1)(W2)...(P)),
  98                  // the end($stack) always points the pages that contain
  99                  // all words in this phrase
 100                  $pages  = end($stack);
 101                  $pages_matched = [];
 102                  foreach (array_keys($pages) as $id) {
 103                      $evdata = [
 104                          'id' => $id,
 105                          'phrase' => $phrase,
 106                          'text' => rawWiki($id)
 107                      ];
 108                      $evt = new Event('FULLTEXT_PHRASE_MATCH', $evdata);
 109                      if ($evt->advise_before() && $evt->result !== true) {
 110                          $text = PhpString::strtolower($evdata['text']);
 111                          if (strpos($text, $phrase) !== false) {
 112                              $evt->result = true;
 113                          }
 114                      }
 115                      $evt->advise_after();
 116                      if ($evt->result === true) {
 117                          $pages_matched[$id] = 0; // phrase: always 0 hit
 118                      }
 119                  }
 120                  $stack[] = $pages_matched;
 121                  break;
 122              case 'N+:':
 123              case 'N-:': // namespace
 124                  $ns = cleanID(substr($token, 3)) . ':';
 125                  $pages_matched = [];
 126                  foreach (array_keys($pages_all) as $id) {
 127                      if (strpos($id, $ns) === 0) {
 128                          $pages_matched[$id] = 0; // namespace: always 0 hit
 129                      }
 130                  }
 131                  $stack[] = $pages_matched;
 132                  break;
 133              case 'AND': // and operation
 134                  [$pages1, $pages2] = array_splice($stack, -2);
 135                  $stack[] = ft_resultCombine([$pages1, $pages2]);
 136                  break;
 137              case 'OR':  // or operation
 138                  [$pages1, $pages2] = array_splice($stack, -2);
 139                  $stack[] = ft_resultUnite([$pages1, $pages2]);
 140                  break;
 141              case 'NOT': // not operation (unary)
 142                  $pages   = array_pop($stack);
 143                  $stack[] = ft_resultComplement([$pages_all, $pages]);
 144                  break;
 145          }
 146      }
 147      $docs = array_pop($stack);
 148  
 149      if (empty($docs)) return [];
 150  
 151      // check: settings, acls, existence
 152      foreach (array_keys($docs) as $id) {
 153          if (isHiddenPage($id) || auth_quickaclcheck($id) < AUTH_READ || !page_exists($id, '', false)) {
 154              unset($docs[$id]);
 155          }
 156      }
 157  
 158      $docs = _ft_filterResultsByTime($docs, $data['after'], $data['before']);
 159  
 160      if ($data['sort'] === 'mtime') {
 161          uksort($docs, 'ft_pagemtimesorter');
 162      } else {
 163          // sort docs by count
 164          uksort($docs, 'ft_pagesorter');
 165          arsort($docs);
 166      }
 167  
 168      return $docs;
 169  }
 170  
 171  /**
 172   * Returns the backlinks for a given page
 173   *
 174   * Uses the metadata index.
 175   *
 176   * @param string $id           The id for which links shall be returned
 177   * @param bool   $ignore_perms Ignore the fact that pages are hidden or read-protected
 178   * @return array The pages that contain links to the given page
 179   */
 180  function ft_backlinks($id, $ignore_perms = false)
 181  {
 182      $result = idx_get_indexer()->lookupKey('relation_references', $id);
 183  
 184      if ($result === []) return $result;
 185  
 186      // check ACL permissions
 187      foreach (array_keys($result) as $idx) {
 188          if (
 189              (!$ignore_perms && (
 190                  isHiddenPage($result[$idx]) || auth_quickaclcheck($result[$idx]) < AUTH_READ
 191              )) || !page_exists($result[$idx], '', false)
 192          ) {
 193              unset($result[$idx]);
 194          }
 195      }
 196  
 197      Sort::sort($result);
 198      return $result;
 199  }
 200  
 201  /**
 202   * Returns the pages that use a given media file
 203   *
 204   * Uses the relation media metadata property and the metadata index.
 205   *
 206   * Note that before 2013-07-31 the second parameter was the maximum number of results and
 207   * permissions were ignored. That's why the parameter is now checked to be explicitely set
 208   * to true (with type bool) in order to be compatible with older uses of the function.
 209   *
 210   * @param string $id           The media id to look for
 211   * @param bool   $ignore_perms Ignore hidden pages and acls (optional, default: false)
 212   * @return array A list of pages that use the given media file
 213   */
 214  function ft_mediause($id, $ignore_perms = false)
 215  {
 216      $result = idx_get_indexer()->lookupKey('relation_media', $id);
 217  
 218      if ($result === []) return $result;
 219  
 220      // check ACL permissions
 221      foreach (array_keys($result) as $idx) {
 222          if (
 223              (!$ignore_perms && (
 224                      isHiddenPage($result[$idx]) || auth_quickaclcheck($result[$idx]) < AUTH_READ
 225                  )) || !page_exists($result[$idx], '', false)
 226          ) {
 227              unset($result[$idx]);
 228          }
 229      }
 230  
 231      Sort::sort($result);
 232      return $result;
 233  }
 234  
 235  
 236  /**
 237   * Quicksearch for pagenames
 238   *
 239   * By default it only matches the pagename and ignores the
 240   * namespace. This can be changed with the second parameter.
 241   * The third parameter allows to search in titles as well.
 242   *
 243   * The function always returns titles as well
 244   *
 245   * @triggers SEARCH_QUERY_PAGELOOKUP
 246   * @author   Andreas Gohr <andi@splitbrain.org>
 247   * @author   Adrian Lang <lang@cosmocode.de>
 248   *
 249   * @param string     $id       page id
 250   * @param bool       $in_ns    match against namespace as well?
 251   * @param bool       $in_title search in title?
 252   * @param int|string $after    only show results with mtime after this date, accepts timestap or strtotime arguments
 253   * @param int|string $before   only show results with mtime before this date, accepts timestap or strtotime arguments
 254   *
 255   * @return string[]
 256   */
 257  function ft_pageLookup($id, $in_ns = false, $in_title = false, $after = null, $before = null)
 258  {
 259      $data = [
 260          'id' => $id,
 261          'in_ns' => $in_ns,
 262          'in_title' => $in_title,
 263          'after' => $after,
 264          'before' => $before
 265      ];
 266      $data['has_titles'] = true; // for plugin backward compatibility check
 267      return Event::createAndTrigger('SEARCH_QUERY_PAGELOOKUP', $data, '_ft_pageLookup');
 268  }
 269  
 270  /**
 271   * Returns list of pages as array(pageid => First Heading)
 272   *
 273   * @param array &$data event data
 274   * @return string[]
 275   */
 276  function _ft_pageLookup(&$data)
 277  {
 278      // split out original parameters
 279      $id = $data['id'];
 280      $Indexer = idx_get_indexer();
 281      $parsedQuery = ft_queryParser($Indexer, $id);
 282      if (count($parsedQuery['ns']) > 0) {
 283          $ns = cleanID($parsedQuery['ns'][0]) . ':';
 284          $id = implode(' ', $parsedQuery['highlight']);
 285      }
 286      if (count($parsedQuery['notns']) > 0) {
 287          $notns = cleanID($parsedQuery['notns'][0]) . ':';
 288          $id = implode(' ', $parsedQuery['highlight']);
 289      }
 290  
 291      $in_ns    = $data['in_ns'];
 292      $in_title = $data['in_title'];
 293      $cleaned = cleanID($id);
 294  
 295      $Indexer = idx_get_indexer();
 296      $page_idx = $Indexer->getPages();
 297  
 298      $pages = [];
 299      if ($id !== '' && $cleaned !== '') {
 300          foreach ($page_idx as $p_id) {
 301              if ((strpos($in_ns ? $p_id : noNSorNS($p_id), $cleaned) !== false)) {
 302                  if (!isset($pages[$p_id]))
 303                      $pages[$p_id] = p_get_first_heading($p_id, METADATA_DONT_RENDER);
 304              }
 305          }
 306          if ($in_title) {
 307              foreach ($Indexer->lookupKey('title', $id, '_ft_pageLookupTitleCompare') as $p_id) {
 308                  if (!isset($pages[$p_id]))
 309                      $pages[$p_id] = p_get_first_heading($p_id, METADATA_DONT_RENDER);
 310              }
 311          }
 312      }
 313  
 314      if (isset($ns)) {
 315          foreach (array_keys($pages) as $p_id) {
 316              if (strpos($p_id, $ns) !== 0) {
 317                  unset($pages[$p_id]);
 318              }
 319          }
 320      }
 321      if (isset($notns)) {
 322          foreach (array_keys($pages) as $p_id) {
 323              if (strpos($p_id, $notns) === 0) {
 324                  unset($pages[$p_id]);
 325              }
 326          }
 327      }
 328  
 329      // discard hidden pages
 330      // discard nonexistent pages
 331      // check ACL permissions
 332      foreach (array_keys($pages) as $idx) {
 333          if (
 334              !isVisiblePage($idx) || !page_exists($idx) ||
 335              auth_quickaclcheck($idx) < AUTH_READ
 336          ) {
 337              unset($pages[$idx]);
 338          }
 339      }
 340  
 341      $pages = _ft_filterResultsByTime($pages, $data['after'], $data['before']);
 342  
 343      uksort($pages, 'ft_pagesorter');
 344      return $pages;
 345  }
 346  
 347  
 348  /**
 349   * @param array      $results search results in the form pageid => value
 350   * @param int|string $after   only returns results with mtime after this date, accepts timestap or strtotime arguments
 351   * @param int|string $before  only returns results with mtime after this date, accepts timestap or strtotime arguments
 352   *
 353   * @return array
 354   */
 355  function _ft_filterResultsByTime(array $results, $after, $before)
 356  {
 357      if ($after || $before) {
 358          $after = is_int($after) ? $after : strtotime($after);
 359          $before = is_int($before) ? $before : strtotime($before);
 360  
 361          foreach (array_keys($results) as $id) {
 362              $mTime = filemtime(wikiFN($id));
 363              if ($after && $after > $mTime) {
 364                  unset($results[$id]);
 365                  continue;
 366              }
 367              if ($before && $before < $mTime) {
 368                  unset($results[$id]);
 369              }
 370          }
 371      }
 372  
 373      return $results;
 374  }
 375  
 376  /**
 377   * Tiny helper function for comparing the searched title with the title
 378   * from the search index. This function is a wrapper around stripos with
 379   * adapted argument order and return value.
 380   *
 381   * @param string $search searched title
 382   * @param string $title  title from index
 383   * @return bool
 384   */
 385  function _ft_pageLookupTitleCompare($search, $title)
 386  {
 387      if (Clean::isASCII($search)) {
 388          $pos = stripos($title, $search);
 389      } else {
 390          $pos = PhpString::strpos(
 391              PhpString::strtolower($title),
 392              PhpString::strtolower($search)
 393          );
 394      }
 395  
 396      return $pos !== false;
 397  }
 398  
 399  /**
 400   * Sort pages based on their namespace level first, then on their string
 401   * values. This makes higher hierarchy pages rank higher than lower hierarchy
 402   * pages.
 403   *
 404   * @param string $a
 405   * @param string $b
 406   * @return int Returns < 0 if $a is less than $b; > 0 if $a is greater than $b, and 0 if they are equal.
 407   */
 408  function ft_pagesorter($a, $b)
 409  {
 410      $ac = count(explode(':', $a));
 411      $bc = count(explode(':', $b));
 412      if ($ac < $bc) {
 413          return -1;
 414      } elseif ($ac > $bc) {
 415          return 1;
 416      }
 417      return Sort::strcmp($a, $b);
 418  }
 419  
 420  /**
 421   * Sort pages by their mtime, from newest to oldest
 422   *
 423   * @param string $a
 424   * @param string $b
 425   *
 426   * @return int Returns < 0 if $a is newer than $b, > 0 if $b is newer than $a and 0 if they are of the same age
 427   */
 428  function ft_pagemtimesorter($a, $b)
 429  {
 430      $mtimeA = filemtime(wikiFN($a));
 431      $mtimeB = filemtime(wikiFN($b));
 432      return $mtimeB - $mtimeA;
 433  }
 434  
 435  /**
 436   * Creates a snippet extract
 437   *
 438   * @author Andreas Gohr <andi@splitbrain.org>
 439   * @triggers FULLTEXT_SNIPPET_CREATE
 440   *
 441   * @param string $id page id
 442   * @param array $highlight
 443   * @return mixed
 444   */
 445  function ft_snippet($id, $highlight)
 446  {
 447      $text = rawWiki($id);
 448      $text = str_replace("\xC2\xAD", '', $text);
 449       // remove soft-hyphens
 450      $evdata = [
 451          'id'        => $id,
 452          'text'      => &$text,
 453          'highlight' => &$highlight,
 454          'snippet'   => ''
 455      ];
 456  
 457      $evt = new Event('FULLTEXT_SNIPPET_CREATE', $evdata);
 458      if ($evt->advise_before()) {
 459          $match = [];
 460          $snippets = [];
 461          $utf8_offset = 0;
 462          $offset = 0;
 463          $end = 0;
 464          $len = PhpString::strlen($text);
 465  
 466          // build a regexp from the phrases to highlight
 467          $re1 = '(' .
 468              implode(
 469                  '|',
 470                  array_map(
 471                      'ft_snippet_re_preprocess',
 472                      array_map(
 473                          'preg_quote_cb',
 474                          array_filter((array) $highlight)
 475                      )
 476                  )
 477              ) .
 478              ')';
 479          $re2 = "$re1.{0,75}(?!\\1)$re1";
 480          $re3 = "$re1.{0,45}(?!\\1)$re1.{0,45}(?!\\1)(?!\\2)$re1";
 481  
 482          for ($cnt = 4; $cnt--;) {
 483              if (0) {
 484              } elseif (preg_match('/' . $re3 . '/iu', $text, $match, PREG_OFFSET_CAPTURE, $offset)) {
 485              } elseif (preg_match('/' . $re2 . '/iu', $text, $match, PREG_OFFSET_CAPTURE, $offset)) {
 486              } elseif (preg_match('/' . $re1 . '/iu', $text, $match, PREG_OFFSET_CAPTURE, $offset)) {
 487              } else {
 488                  break;
 489              }
 490  
 491              [$str, $idx] = $match[0];
 492  
 493              // convert $idx (a byte offset) into a utf8 character offset
 494              $utf8_idx = PhpString::strlen(substr($text, 0, $idx));
 495              $utf8_len = PhpString::strlen($str);
 496  
 497              // establish context, 100 bytes surrounding the match string
 498              // first look to see if we can go 100 either side,
 499              // then drop to 50 adding any excess if the other side can't go to 50,
 500              $pre = min($utf8_idx - $utf8_offset, 100);
 501              $post = min($len - $utf8_idx - $utf8_len, 100);
 502  
 503              if ($pre > 50 && $post > 50) {
 504                  $pre = 50;
 505                  $post = 50;
 506              } elseif ($pre > 50) {
 507                  $pre = min($pre, 100 - $post);
 508              } elseif ($post > 50) {
 509                  $post = min($post, 100 - $pre);
 510              } elseif ($offset == 0) {
 511                  // both are less than 50, means the context is the whole string
 512                  // make it so and break out of this loop - there is no need for the
 513                  // complex snippet calculations
 514                  $snippets = [$text];
 515                  break;
 516              }
 517  
 518              // establish context start and end points, try to append to previous
 519              // context if possible
 520              $start = $utf8_idx - $pre;
 521              $append = ($start < $end) ? $end : false;  // still the end of the previous context snippet
 522              $end = $utf8_idx + $utf8_len + $post;      // now set it to the end of this context
 523  
 524              if ($append) {
 525                  $snippets[count($snippets) - 1] .= PhpString::substr($text, $append, $end - $append);
 526              } else {
 527                  $snippets[] = PhpString::substr($text, $start, $end - $start);
 528              }
 529  
 530              // set $offset for next match attempt
 531              // continue matching after the current match
 532              // if the current match is not the longest possible match starting at the current offset
 533              // this prevents further matching of this snippet but for possible matches of length
 534              // smaller than match length + context (at least 50 characters) this match is part of the context
 535              $utf8_offset = $utf8_idx + $utf8_len;
 536              $offset = $idx + strlen(PhpString::substr($text, $utf8_idx, $utf8_len));
 537              $offset = Clean::correctIdx($text, $offset);
 538          }
 539  
 540          $m = "\1";
 541          $snippets = preg_replace('/' . $re1 . '/iu', $m . '$1' . $m, $snippets);
 542          $snippet = preg_replace(
 543              '/' . $m . '([^' . $m . ']*?)' . $m . '/iu',
 544              '<strong class="search_hit">$1</strong>',
 545              hsc(implode('... ', $snippets))
 546          );
 547  
 548          $evdata['snippet'] = $snippet;
 549      }
 550      $evt->advise_after();
 551      unset($evt);
 552  
 553      return $evdata['snippet'];
 554  }
 555  
 556  /**
 557   * Wraps a search term in regex boundary checks.
 558   *
 559   * @param string $term
 560   * @return string
 561   */
 562  function ft_snippet_re_preprocess($term)
 563  {
 564      // do not process asian terms where word boundaries are not explicit
 565      if (Asian::isAsianWords($term)) return $term;
 566  
 567      if (UTF8_PROPERTYSUPPORT) {
 568          // unicode word boundaries
 569          // see http://stackoverflow.com/a/2449017/172068
 570          $BL = '(?<!\pL)';
 571          $BR = '(?!\pL)';
 572      } else {
 573          // not as correct as above, but at least won't break
 574          $BL = '\b';
 575          $BR = '\b';
 576      }
 577  
 578      if (str_starts_with($term, '\\*')) {
 579          $term = substr($term, 2);
 580      } else {
 581          $term = $BL . $term;
 582      }
 583  
 584      if (str_ends_with($term, '\\*')) {
 585          $term = substr($term, 0, -2);
 586      } else {
 587          $term .= $BR;
 588      }
 589  
 590      if ($term == $BL || $term == $BR || $term == $BL . $BR) $term = '';
 591      return $term;
 592  }
 593  
 594  /**
 595   * Combine found documents and sum up their scores
 596   *
 597   * This function is used to combine searched words with a logical
 598   * AND. Only documents available in all arrays are returned.
 599   *
 600   * based upon PEAR's PHP_Compat function for array_intersect_key()
 601   *
 602   * @param array $args An array of page arrays
 603   * @return array
 604   */
 605  function ft_resultCombine($args)
 606  {
 607      $array_count = count($args);
 608      if ($array_count == 1) {
 609          return $args[0];
 610      }
 611  
 612      $result = [];
 613      if ($array_count > 1) {
 614          foreach ($args[0] as $key => $value) {
 615              $result[$key] = $value;
 616              for ($i = 1; $i !== $array_count; $i++) {
 617                  if (!isset($args[$i][$key])) {
 618                      unset($result[$key]);
 619                      break;
 620                  }
 621                  $result[$key] += $args[$i][$key];
 622              }
 623          }
 624      }
 625      return $result;
 626  }
 627  
 628  /**
 629   * Unites found documents and sum up their scores
 630   *
 631   * based upon ft_resultCombine() function
 632   *
 633   * @param array $args An array of page arrays
 634   * @return array
 635   *
 636   * @author Kazutaka Miyasaka <kazmiya@gmail.com>
 637   */
 638  function ft_resultUnite($args)
 639  {
 640      $array_count = count($args);
 641      if ($array_count === 1) {
 642          return $args[0];
 643      }
 644  
 645      $result = $args[0];
 646      for ($i = 1; $i !== $array_count; $i++) {
 647          foreach (array_keys($args[$i]) as $id) {
 648              $result[$id] += $args[$i][$id];
 649          }
 650      }
 651      return $result;
 652  }
 653  
 654  /**
 655   * Computes the difference of documents using page id for comparison
 656   *
 657   * nearly identical to PHP5's array_diff_key()
 658   *
 659   * @param array $args An array of page arrays
 660   * @return array
 661   *
 662   * @author Kazutaka Miyasaka <kazmiya@gmail.com>
 663   */
 664  function ft_resultComplement($args)
 665  {
 666      $array_count = count($args);
 667      if ($array_count === 1) {
 668          return $args[0];
 669      }
 670  
 671      $result = $args[0];
 672      foreach (array_keys($result) as $id) {
 673          for ($i = 1; $i !== $array_count; $i++) {
 674              if (isset($args[$i][$id])) unset($result[$id]);
 675          }
 676      }
 677      return $result;
 678  }
 679  
 680  /**
 681   * Parses a search query and builds an array of search formulas
 682   *
 683   * @author Andreas Gohr <andi@splitbrain.org>
 684   * @author Kazutaka Miyasaka <kazmiya@gmail.com>
 685   *
 686   * @param Indexer $Indexer
 687   * @param string                  $query search query
 688   * @return array of search formulas
 689   */
 690  function ft_queryParser($Indexer, $query)
 691  {
 692      /**
 693       * parse a search query and transform it into intermediate representation
 694       *
 695       * in a search query, you can use the following expressions:
 696       *
 697       *   words:
 698       *     include
 699       *     -exclude
 700       *   phrases:
 701       *     "phrase to be included"
 702       *     -"phrase you want to exclude"
 703       *   namespaces:
 704       *     @include:namespace (or ns:include:namespace)
 705       *     ^exclude:namespace (or -ns:exclude:namespace)
 706       *   groups:
 707       *     ()
 708       *     -()
 709       *   operators:
 710       *     and ('and' is the default operator: you can always omit this)
 711       *     or  (or pipe symbol '|', lower precedence than 'and')
 712       *
 713       * e.g. a query [ aa "bb cc" @dd:ee ] means "search pages which contain
 714       *      a word 'aa', a phrase 'bb cc' and are within a namespace 'dd:ee'".
 715       *      this query is equivalent to [ -(-aa or -"bb cc" or -ns:dd:ee) ]
 716       *      as long as you don't mind hit counts.
 717       *
 718       * intermediate representation consists of the following parts:
 719       *
 720       *   ( )           - group
 721       *   AND           - logical and
 722       *   OR            - logical or
 723       *   NOT           - logical not
 724       *   W+:, W-:, W_: - word      (underscore: no need to highlight)
 725       *   P+:, P-:      - phrase    (minus sign: logically in NOT group)
 726       *   N+:, N-:      - namespace
 727       */
 728      $parsed_query = '';
 729      $parens_level = 0;
 730      $terms = preg_split(
 731          '/(-?".*?")/u',
 732          PhpString::strtolower($query),
 733          -1,
 734          PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
 735      );
 736  
 737      foreach ($terms as $term) {
 738          $parsed = '';
 739          if (preg_match('/^(-?)"(.+)"$/u', $term, $matches)) {
 740              // phrase-include and phrase-exclude
 741              $not = $matches[1] ? 'NOT' : '';
 742              $parsed = $not . ft_termParser($Indexer, $matches[2], false, true);
 743          } else {
 744              // fix incomplete phrase
 745              $term = str_replace('"', ' ', $term);
 746  
 747              // fix parentheses
 748              $term = str_replace(')', ' ) ', $term);
 749              $term = str_replace('(', ' ( ', $term);
 750              $term = str_replace('- (', ' -(', $term);
 751  
 752              // treat pipe symbols as 'OR' operators
 753              $term = str_replace('|', ' or ', $term);
 754  
 755              // treat ideographic spaces (U+3000) as search term separators
 756              // FIXME: some more separators?
 757              $term = preg_replace('/[ \x{3000}]+/u', ' ', $term);
 758              $term = trim($term);
 759              if ($term === '') continue;
 760  
 761              $tokens = explode(' ', $term);
 762              foreach ($tokens as $token) {
 763                  if ($token === '(') {
 764                      // parenthesis-include-open
 765                      $parsed .= '(';
 766                      ++$parens_level;
 767                  } elseif ($token === '-(') {
 768                      // parenthesis-exclude-open
 769                      $parsed .= 'NOT(';
 770                      ++$parens_level;
 771                  } elseif ($token === ')') {
 772                      // parenthesis-any-close
 773                      if ($parens_level === 0) continue;
 774                      $parsed .= ')';
 775                      $parens_level--;
 776                  } elseif ($token === 'and') {
 777                      // logical-and (do nothing)
 778                  } elseif ($token === 'or') {
 779                      // logical-or
 780                      $parsed .= 'OR';
 781                  } elseif (preg_match('/^(?:\^|-ns:)(.+)$/u', $token, $matches)) {
 782                      // namespace-exclude
 783                      $parsed .= 'NOT(N+:' . $matches[1] . ')';
 784                  } elseif (preg_match('/^(?:@|ns:)(.+)$/u', $token, $matches)) {
 785                      // namespace-include
 786                      $parsed .= '(N+:' . $matches[1] . ')';
 787                  } elseif (preg_match('/^-(.+)$/', $token, $matches)) {
 788                      // word-exclude
 789                      $parsed .= 'NOT(' . ft_termParser($Indexer, $matches[1]) . ')';
 790                  } else {
 791                      // word-include
 792                      $parsed .= ft_termParser($Indexer, $token);
 793                  }
 794              }
 795          }
 796          $parsed_query .= $parsed;
 797      }
 798  
 799      // cleanup (very sensitive)
 800      $parsed_query .= str_repeat(')', $parens_level);
 801      do {
 802          $parsed_query_old = $parsed_query;
 803          $parsed_query = preg_replace('/(NOT)?\(\)/u', '', $parsed_query);
 804      } while ($parsed_query !== $parsed_query_old);
 805      $parsed_query = preg_replace('/(NOT|OR)+\)/u', ')', $parsed_query);
 806      $parsed_query = preg_replace('/(OR)+/u', 'OR', $parsed_query);
 807      $parsed_query = preg_replace('/\(OR/u', '(', $parsed_query);
 808      $parsed_query = preg_replace('/^OR|OR$/u', '', $parsed_query);
 809      $parsed_query = preg_replace('/\)(NOT)?\(/u', ')AND$1(', $parsed_query);
 810  
 811      // adjustment: make highlightings right
 812      $parens_level     = 0;
 813      $notgrp_levels    = [];
 814      $parsed_query_new = '';
 815      $tokens = preg_split('/(NOT\(|[()])/u', $parsed_query, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
 816      foreach ($tokens as $token) {
 817          if ($token === 'NOT(') {
 818              $notgrp_levels[] = ++$parens_level;
 819          } elseif ($token === '(') {
 820              ++$parens_level;
 821          } elseif ($token === ')') {
 822              if ($parens_level-- === end($notgrp_levels)) array_pop($notgrp_levels);
 823          } elseif (count($notgrp_levels) % 2 === 1) {
 824              // turn highlight-flag off if terms are logically in "NOT" group
 825              $token = preg_replace('/([WPN])\+\:/u', '$1-:', $token);
 826          }
 827          $parsed_query_new .= $token;
 828      }
 829      $parsed_query = $parsed_query_new;
 830  
 831      /**
 832       * convert infix notation string into postfix (Reverse Polish notation) array
 833       * by Shunting-yard algorithm
 834       *
 835       * see: http://en.wikipedia.org/wiki/Reverse_Polish_notation
 836       * see: http://en.wikipedia.org/wiki/Shunting-yard_algorithm
 837       */
 838      $parsed_ary     = [];
 839      $ope_stack      = [];
 840      $ope_precedence = [')' => 1, 'OR' => 2, 'AND' => 3, 'NOT' => 4, '(' => 5];
 841      $ope_regex      = '/([()]|OR|AND|NOT)/u';
 842  
 843      $tokens = preg_split($ope_regex, $parsed_query, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
 844      foreach ($tokens as $token) {
 845          if (preg_match($ope_regex, $token)) {
 846              // operator
 847              $last_ope = end($ope_stack);
 848              while ($last_ope !== false && $ope_precedence[$token] <= $ope_precedence[$last_ope] && $last_ope != '(') {
 849                  $parsed_ary[] = array_pop($ope_stack);
 850                  $last_ope = end($ope_stack);
 851              }
 852              if ($token == ')') {
 853                  array_pop($ope_stack); // this array_pop always deletes '('
 854              } else {
 855                  $ope_stack[] = $token;
 856              }
 857          } else {
 858              // operand
 859              $token_decoded = str_replace(['OP', 'CP'], ['(', ')'], $token);
 860              $parsed_ary[] = $token_decoded;
 861          }
 862      }
 863      $parsed_ary = array_values([...$parsed_ary, ...array_reverse($ope_stack)]);
 864  
 865      // cleanup: each double "NOT" in RPN array actually does nothing
 866      $parsed_ary_count = count($parsed_ary);
 867      for ($i = 1; $i < $parsed_ary_count; ++$i) {
 868          if ($parsed_ary[$i] === 'NOT' && $parsed_ary[$i - 1] === 'NOT') {
 869              unset($parsed_ary[$i], $parsed_ary[$i - 1]);
 870          }
 871      }
 872      $parsed_ary = array_values($parsed_ary);
 873  
 874      // build return value
 875      $q = [];
 876      $q['query']      = $query;
 877      $q['parsed_str'] = $parsed_query;
 878      $q['parsed_ary'] = $parsed_ary;
 879  
 880      foreach ($q['parsed_ary'] as $token) {
 881          if (strlen($token) < 3 || $token[2] !== ':') continue;
 882          $body = substr($token, 3);
 883  
 884          switch (substr($token, 0, 3)) {
 885              case 'N+:':
 886                       $q['ns'][]        = $body; // for backward compatibility
 887                  break;
 888              case 'N-:':
 889                       $q['notns'][]     = $body; // for backward compatibility
 890                  break;
 891              case 'W_:':
 892                       $q['words'][]     = $body;
 893                  break;
 894              case 'W-:':
 895                       $q['words'][]     = $body;
 896                       $q['not'][]       = $body; // for backward compatibility
 897                  break;
 898              case 'W+:':
 899                       $q['words'][]     = $body;
 900                       $q['highlight'][] = $body;
 901                       $q['and'][]       = $body; // for backward compatibility
 902                  break;
 903              case 'P-:':
 904                       $q['phrases'][]   = $body;
 905                  break;
 906              case 'P+:':
 907                       $q['phrases'][]   = $body;
 908                       $q['highlight'][] = $body;
 909                  break;
 910          }
 911      }
 912      foreach (['words', 'phrases', 'highlight', 'ns', 'notns', 'and', 'not'] as $key) {
 913          $q[$key] = empty($q[$key]) ? [] : array_values(array_unique($q[$key]));
 914      }
 915  
 916      return $q;
 917  }
 918  
 919  /**
 920   * Transforms given search term into intermediate representation
 921   *
 922   * This function is used in ft_queryParser() and not for general purpose use.
 923   *
 924   * @author Kazutaka Miyasaka <kazmiya@gmail.com>
 925   *
 926   * @param Indexer $Indexer
 927   * @param string                  $term
 928   * @param bool                    $consider_asian
 929   * @param bool                    $phrase_mode
 930   * @return string
 931   */
 932  function ft_termParser($Indexer, $term, $consider_asian = true, $phrase_mode = false)
 933  {
 934      $parsed = '';
 935      if ($consider_asian) {
 936          // successive asian characters need to be searched as a phrase
 937          $words = Asian::splitAsianWords($term);
 938          foreach ($words as $word) {
 939              $phrase_mode = $phrase_mode ? true : Asian::isAsianWords($word);
 940              $parsed .= ft_termParser($Indexer, $word, false, $phrase_mode);
 941          }
 942      } else {
 943          $term_noparen = str_replace(['(', ')'], ' ', $term);
 944          $words = $Indexer->tokenizer($term_noparen, true);
 945  
 946          // W_: no need to highlight
 947          if (empty($words)) {
 948              $parsed = '()'; // important: do not remove
 949          } elseif ($words[0] === $term) {
 950              $parsed = '(W+:' . $words[0] . ')';
 951          } elseif ($phrase_mode) {
 952              $term_encoded = str_replace(['(', ')'], ['OP', 'CP'], $term);
 953              $parsed = '((W_:' . implode(')(W_:', $words) . ')(P+:' . $term_encoded . '))';
 954          } else {
 955              $parsed = '((W+:' . implode(')(W+:', $words) . '))';
 956          }
 957      }
 958      return $parsed;
 959  }
 960  
 961  /**
 962   * Recreate a search query string based on parsed parts, doesn't support negated phrases and `OR` searches
 963   *
 964   * @param array $and
 965   * @param array $not
 966   * @param array $phrases
 967   * @param array $ns
 968   * @param array $notns
 969   *
 970   * @return string
 971   */
 972  function ft_queryUnparser_simple(array $and, array $not, array $phrases, array $ns, array $notns)
 973  {
 974      $query = implode(' ', $and);
 975      if ($not !== []) {
 976          $query .= ' -' . implode(' -', $not);
 977      }
 978  
 979      if ($phrases !== []) {
 980          $query .= ' "' . implode('" "', $phrases) . '"';
 981      }
 982  
 983      if ($ns !== []) {
 984          $query .= ' @' . implode(' @', $ns);
 985      }
 986  
 987      if ($notns !== []) {
 988          $query .= ' ^' . implode(' ^', $notns);
 989      }
 990  
 991      return $query;
 992  }
 993  
 994  //Setup VIM: ex: et ts=4 :