Merge "mediawiki.widgets: Remove use of bind() for lexical 'this' binding"
[lhc/web/wiklou.git] / includes / search / SearchEngine.php
index 3c8d56e..b263fb3 100644 (file)
@@ -34,7 +34,7 @@ class SearchEngine {
        public $prefix = '';
 
        /** @var int[]|null */
-       public $namespaces = array( NS_MAIN );
+       public $namespaces = [ NS_MAIN ];
 
        /** @var int */
        protected $limit = 10;
@@ -43,18 +43,18 @@ class SearchEngine {
        protected $offset = 0;
 
        /** @var array|string */
-       protected $searchTerms = array();
+       protected $searchTerms = [];
 
        /** @var bool */
        protected $showSuggestion = true;
        private $sort = 'relevance';
 
        /** @var array Feature values */
-       protected $features = array();
+       protected $features = [];
 
        /**
         * Perform a full text search query and return a result set.
-        * If title searches are not supported or disabled, return null.
+        * If full text searches are not supported or disabled, return null.
         * STUB
         *
         * @param string $term Raw search term
@@ -138,7 +138,7 @@ class SearchEngine {
        public static function getNearMatch( $searchterm ) {
                $title = self::getNearMatchInternal( $searchterm );
 
-               Hooks::run( 'SearchGetNearMatchComplete', array( $searchterm, &$title ) );
+               Hooks::run( 'SearchGetNearMatchComplete', [ $searchterm, &$title ] );
                return $title;
        }
 
@@ -161,7 +161,7 @@ class SearchEngine {
        private static function getNearMatchInternal( $searchterm ) {
                global $wgContLang, $wgEnableSearchContributorsByIP;
 
-               $allSearchTerms = array( $searchterm );
+               $allSearchTerms = [ $searchterm ];
 
                if ( $wgContLang->hasVariants() ) {
                        $allSearchTerms = array_unique( array_merge(
@@ -171,7 +171,7 @@ class SearchEngine {
                }
 
                $titleResult = null;
-               if ( !Hooks::run( 'SearchGetNearMatchBefore', array( $allSearchTerms, &$titleResult ) ) ) {
+               if ( !Hooks::run( 'SearchGetNearMatchBefore', [ $allSearchTerms, &$titleResult ] ) ) {
                        return $titleResult;
                }
 
@@ -198,7 +198,7 @@ class SearchEngine {
                                return $title;
                        }
 
-                       if ( !Hooks::run( 'SearchAfterNoDirectMatch', array( $term, &$title ) ) ) {
+                       if ( !Hooks::run( 'SearchAfterNoDirectMatch', [ $term, &$title ] ) ) {
                                return $title;
                        }
 
@@ -228,7 +228,7 @@ class SearchEngine {
 
                        // Give hooks a chance at better match variants
                        $title = null;
-                       if ( !Hooks::run( 'SearchGetNearMatch', array( $term, &$title ) ) ) {
+                       if ( !Hooks::run( 'SearchGetNearMatch', [ $term, &$title ] ) ) {
                                return $title;
                        }
                }
@@ -265,7 +265,7 @@ class SearchEngine {
                }
 
                # Quoted term? Try without the quotes...
-               $matches = array();
+               $matches = [];
                if ( preg_match( '/^"([^"]+)"$/', $searchterm, $matches ) ) {
                        return SearchEngine::getNearMatch( $matches[1] );
                }
@@ -296,6 +296,15 @@ class SearchEngine {
         * @param int[]|null $namespaces
         */
        function setNamespaces( $namespaces ) {
+               if ( $namespaces ) {
+                       // Filter namespaces to only keep valid ones
+                       $validNs = $this->searchableNamespaces();
+                       $namespaces = array_filter( $namespaces, function( $ns ) use( $validNs ) {
+                               return $ns < 0 || isset( $validNs[$ns] );
+                       } );
+               } else {
+                       $namespaces = [];
+               }
                $this->namespaces = $namespaces;
        }
 
@@ -318,7 +327,7 @@ class SearchEngine {
         * @return array(string) the valid sort directions for setSort
         */
        public function getValidSorts() {
-               return array( 'relevance' );
+               return [ 'relevance' ];
        }
 
        /**
@@ -370,7 +379,7 @@ class SearchEngine {
                        $prefix = str_replace( ' ', '_', substr( $query, 0, strpos( $query, ':' ) ) );
                        $index = $wgContLang->getNsIndex( $prefix );
                        if ( $index !== false ) {
-                               $this->namespaces = array( $index );
+                               $this->namespaces = [ $index ];
                                $parsed = substr( $query, strlen( $prefix ) + 1 );
                        }
                }
@@ -387,14 +396,14 @@ class SearchEngine {
         */
        public static function searchableNamespaces() {
                global $wgContLang;
-               $arr = array();
+               $arr = [];
                foreach ( $wgContLang->getNamespaces() as $ns => $name ) {
                        if ( $ns >= NS_MAIN ) {
                                $arr[$ns] = $name;
                        }
                }
 
-               Hooks::run( 'SearchableNamespaces', array( &$arr ) );
+               Hooks::run( 'SearchableNamespaces', [ &$arr ] );
                return $arr;
        }
 
@@ -406,7 +415,7 @@ class SearchEngine {
         * @return array
         */
        public static function userNamespaces( $user ) {
-               $arr = array();
+               $arr = [];
                foreach ( SearchEngine::searchableNamespaces() as $ns => $name ) {
                        if ( $user->getOption( 'searchNs' . $ns ) ) {
                                $arr[] = $ns;
@@ -424,7 +433,7 @@ class SearchEngine {
        public static function userHighlightPrefs() {
                $contextlines = 2; // Hardcode this. Old defaults sucked. :)
                $contextchars = 75; // same as above.... :P
-               return array( $contextlines, $contextchars );
+               return [ $contextlines, $contextchars ];
        }
 
        /**
@@ -448,7 +457,7 @@ class SearchEngine {
        public static function namespacesAsText( $namespaces ) {
                global $wgContLang;
 
-               $formatted = array_map( array( $wgContLang, 'getFormattedNsText' ), $namespaces );
+               $formatted = array_map( [ $wgContLang, 'getFormattedNsText' ], $namespaces );
                foreach ( $formatted as $key => $ns ) {
                        if ( empty( $ns ) ) {
                                $formatted[$key] = wfMessage( 'blanknamespace' )->text();
@@ -492,7 +501,7 @@ class SearchEngine {
        public static function getSearchTypes() {
                global $wgSearchType, $wgSearchTypeAlternatives;
 
-               $alternatives = $wgSearchTypeAlternatives ?: array();
+               $alternatives = $wgSearchTypeAlternatives ?: [];
                array_unshift( $alternatives, $wgSearchType );
 
                return $alternatives;
@@ -570,6 +579,201 @@ class SearchEngine {
        public function textAlreadyUpdatedForIndex() {
                return false;
        }
+
+       /**
+        * Makes search simple string if it was namespaced.
+        * Sets namespaces of the search to namespaces extracted from string.
+        * @param string $search
+        * @return $string Simplified search string
+        */
+       protected function normalizeNamespaces( $search ) {
+               // Find a Title which is not an interwiki and is in NS_MAIN
+               $title = Title::newFromText( $search );
+               $ns = $this->namespaces;
+               if ( $title && !$title->isExternal() ) {
+                       $ns = [ $title->getNamespace() ];
+                       $search = $title->getText();
+                       if ( $ns[0] == NS_MAIN ) {
+                               $ns = $this->namespaces; // no explicit prefix, use default namespaces
+                               Hooks::run( 'PrefixSearchExtractNamespace', [ &$ns, &$search ] );
+                       }
+               } else {
+                       $title = Title::newFromText( $search . 'Dummy' );
+                       if ( $title && $title->getText() == 'Dummy'
+                                       && $title->getNamespace() != NS_MAIN
+                                       && !$title->isExternal() )
+                       {
+                               $ns = [ $title->getNamespace() ];
+                               $search = '';
+                       } else {
+                               Hooks::run( 'PrefixSearchExtractNamespace', [ &$ns, &$search ] );
+                       }
+               }
+
+               $ns = array_map( function( $space ) {
+                       return $space == NS_MEDIA ? NS_FILE : $space;
+               }, $ns );
+
+               $this->setNamespaces( $ns );
+               return $search;
+       }
+
+       /**
+        * Perform a completion search.
+        * Does not resolve namespaces and does not check variants.
+        * Search engine implementations may want to override this function.
+        * @param string $search
+        * @return SearchSuggestionSet
+        */
+       protected function completionSearchBackend( $search ) {
+               $results = [];
+
+               $search = trim( $search );
+
+               if ( !in_array( NS_SPECIAL, $this->namespaces ) && // We do not run hook on Special: search
+                        !Hooks::run( 'PrefixSearchBackend',
+                               [ $this->namespaces, $search, $this->limit, &$results, $this->offset ]
+               ) ) {
+                       // False means hook worked.
+                       // FIXME: Yes, the API is weird. That's why it is going to be deprecated.
+
+                       return SearchSuggestionSet::fromStrings( $results );
+               } else {
+                       // Hook did not do the job, use default simple search
+                       $results = $this->simplePrefixSearch( $search );
+                       return SearchSuggestionSet::fromTitles( $results );
+               }
+       }
+
+       /**
+        * Perform a completion search.
+        * @param string $search
+        * @return SearchSuggestionSet
+        */
+       public function completionSearch( $search ) {
+               if ( trim( $search ) === '' ) {
+                       return SearchSuggestionSet::emptySuggestionSet(); // Return empty result
+               }
+               $search = $this->normalizeNamespaces( $search );
+               return $this->processCompletionResults( $search, $this->completionSearchBackend( $search ) );
+       }
+
+       /**
+        * Perform a completion search with variants.
+        * @param string $search
+        * @return SearchSuggestionSet
+        */
+       public function completionSearchWithVariants( $search ) {
+               if ( trim( $search ) === '' ) {
+                       return SearchSuggestionSet::emptySuggestionSet(); // Return empty result
+               }
+               $search = $this->normalizeNamespaces( $search );
+
+               $results = $this->completionSearchBackend( $search );
+               $fallbackLimit = $this->limit - $results->getSize();
+               if ( $fallbackLimit > 0 ) {
+                       global $wgContLang;
+
+                       $fallbackSearches = $wgContLang->autoConvertToAllVariants( $search );
+                       $fallbackSearches = array_diff( array_unique( $fallbackSearches ), [ $search ] );
+
+                       foreach ( $fallbackSearches as $fbs ) {
+                               $this->setLimitOffset( $fallbackLimit );
+                               $fallbackSearchResult = $this->completionSearch( $fbs );
+                               $results->appendAll( $fallbackSearchResult );
+                               $fallbackLimit -= count( $fallbackSearchResult );
+                               if ( $fallbackLimit <= 0 ) {
+                                       break;
+                               }
+                       }
+               }
+               return $this->processCompletionResults( $search, $results );
+       }
+
+       /**
+        * Extract titles from completion results
+        * @param SearchSuggestionSet $completionResults
+        * @return Title[]
+        */
+       public function extractTitles( SearchSuggestionSet $completionResults ) {
+               return $completionResults->map( function( SearchSuggestion $sugg ) {
+                       return $sugg->getSuggestedTitle();
+               } );
+       }
+
+       /**
+        * Process completion search results.
+        * Resolves the titles and rescores.
+        * @param SearchSuggestionSet $suggestions
+        * @return SearchSuggestionSet
+        */
+       protected function processCompletionResults( $search, SearchSuggestionSet $suggestions ) {
+               $search = trim( $search );
+               // preload the titles with LinkBatch
+               $titles = $suggestions->map( function( SearchSuggestion $sugg ) {
+                       return $sugg->getSuggestedTitle();
+               } );
+               $lb = new LinkBatch( $titles );
+               $lb->setCaller( __METHOD__ );
+               $lb->execute();
+
+               $results = $suggestions->map( function( SearchSuggestion $sugg ) {
+                       return $sugg->getSuggestedTitle()->getPrefixedText();
+               } );
+
+               // Rescore results with an exact title match
+               // NOTE: in some cases like cross-namespace redirects
+               // (frequently used as shortcuts e.g. WP:WP on huwiki) some
+               // backends like Cirrus will return no results. We should still
+               // try an exact title match to workaround this limitation
+               $rescorer = new SearchExactMatchRescorer();
+               $rescoredResults = $rescorer->rescore( $search, $this->namespaces, $results, $this->limit );
+
+               if ( count( $rescoredResults ) > 0 ) {
+                       $found = array_search( $rescoredResults[0], $results );
+                       if ( $found === false ) {
+                               // If the first result is not in the previous array it
+                               // means that we found a new exact match
+                               $exactMatch = SearchSuggestion::fromTitle( 0, Title::newFromText( $rescoredResults[0] ) );
+                               $suggestions->prepend( $exactMatch );
+                               $suggestions->shrink( $this->limit );
+                       } else {
+                               // if the first result is not the same we need to rescore
+                               if ( $found > 0 ) {
+                                       $suggestions->rescore( $found );
+                               }
+                       }
+               }
+
+               return $suggestions;
+       }
+
+       /**
+        * Simple prefix search for subpages.
+        * @param string $search
+        * @return Title[]
+        */
+       public function defaultPrefixSearch( $search ) {
+               if ( trim( $search ) === '' ) {
+                       return [];
+               }
+
+               $search = $this->normalizeNamespaces( $search );
+               return $this->simplePrefixSearch( $search );
+       }
+
+       /**
+        * Call out to simple search backend.
+        * Defaults to TitlePrefixSearch.
+        * @param string $search
+        * @return Title[]
+        */
+       protected function simplePrefixSearch( $search ) {
+               // Use default database prefix search
+               $backend = new TitlePrefixSearch;
+               return $backend->defaultSearchBackend( $this->namespaces, $search, $this->limit, $this->offset );
+       }
+
 }
 
 /**