Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
41.18% covered (danger)
41.18%
105 / 255
25.81% covered (danger)
25.81%
8 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
41.18% covered (danger)
41.18%
105 / 255
25.81% covered (danger)
25.81%
8 / 31
1971.84
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onRegistration
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 onBeforeInitialize
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onApiBeforeMain
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 initializeForRequest
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
4
 overrideNumeric
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 overrideMinimumShouldMatch
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 overrideSecret
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 overrideYesNo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 overrideMoreLikeThisOptionsFromMessage
25.00% covered (danger)
25.00%
11 / 44
0.00% covered (danger)
0.00%
0 / 1
226.19
 isMinimumShouldMatch
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
9.58
 overrideMoreLikeThisOptions
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 onSoftwareInfo
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 onSpecialSearchResultsAppend
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 addSearchFeedbackLink
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 onPrefixSearchExtractNamespace
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 prefixSearchExtractNamespaceWithConnection
53.33% covered (warning)
53.33%
8 / 15
0.00% covered (danger)
0.00%
0 / 1
5.63
 onSearchGetNearMatch
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 handleSearchGetNearMatch
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
42
 onResourceLoaderGetConfigVars
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getConfig
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getConnection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onShowSearchHitTitle
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 onAPIAfterExecute
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 onSpecialSearchResults
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 addWordCount
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 onGetPreferences
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 autoCompleteOptionsForPreferences
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 onUserGetDefaultOptions
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 onSpecialStatsAddExtra
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onAPIQuerySiteInfoStatisticsInfo
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace CirrusSearch;
4
5use CirrusSearch\Search\FancyTitleResultsType;
6use HtmlArmor;
7use ISearchResultSet;
8use MediaWiki\Actions\ActionEntryPoint;
9use MediaWiki\Api\ApiBase;
10use MediaWiki\Api\ApiMain;
11use MediaWiki\Api\ApiOpenSearch;
12use MediaWiki\Api\Hook\APIAfterExecuteHook;
13use MediaWiki\Api\Hook\APIQuerySiteInfoStatisticsInfoHook;
14use MediaWiki\Config\Config;
15use MediaWiki\Config\ConfigFactory;
16use MediaWiki\Context\RequestContext;
17use MediaWiki\Hook\ApiBeforeMainHook;
18use MediaWiki\Hook\BeforeInitializeHook;
19use MediaWiki\Hook\SoftwareInfoHook;
20use MediaWiki\Hook\SpecialSearchResultsAppendHook;
21use MediaWiki\Hook\SpecialSearchResultsHook;
22use MediaWiki\Hook\SpecialStatsAddExtraHook;
23use MediaWiki\Html\Html;
24use MediaWiki\Logger\LoggerFactory;
25use MediaWiki\MediaWikiServices;
26use MediaWiki\Output\OutputPage;
27use MediaWiki\Preferences\Hook\GetPreferencesHook;
28use MediaWiki\Request\WebRequest;
29use MediaWiki\ResourceLoader\Hook\ResourceLoaderGetConfigVarsHook;
30use MediaWiki\Search\Hook\PrefixSearchExtractNamespaceHook;
31use MediaWiki\Search\Hook\SearchGetNearMatchHook;
32use MediaWiki\Search\Hook\ShowSearchHitTitleHook;
33use MediaWiki\Specials\SpecialSearch;
34use MediaWiki\Title\Title;
35use MediaWiki\User\Hook\UserGetDefaultOptionsHook;
36use MediaWiki\User\User;
37use SearchResult;
38
39/**
40 * All CirrusSearch's external hooks.
41 *
42 * @license GPL-2.0-or-later
43 */
44class Hooks implements
45    UserGetDefaultOptionsHook,
46    GetPreferencesHook,
47    APIAfterExecuteHook,
48    ApiBeforeMainHook,
49    APIQuerySiteInfoStatisticsInfoHook,
50    BeforeInitializeHook,
51    PrefixSearchExtractNamespaceHook,
52    ResourceLoaderGetConfigVarsHook,
53    SearchGetNearMatchHook,
54    ShowSearchHitTitleHook,
55    SoftwareInfoHook,
56    SpecialSearchResultsHook,
57    SpecialSearchResultsAppendHook,
58    SpecialStatsAddExtraHook
59{
60    /** @var ConfigFactory */
61    private $configFactory;
62
63    public function __construct( ConfigFactory $configFactory ) {
64        $this->configFactory = $configFactory;
65    }
66
67    /**
68     * Extension registration callback (https://www.mediawiki.org/wiki/Manual:Extension.json/Schema#callback), called
69     * early on in the MW setup.
70     */
71    public static function onRegistration(): void {
72        global $wgCirrusSearchClusters, $wgCirrusSearchWriteClusters;
73        if ( defined( 'MW_QUIBBLE_CI' ) && getenv( 'QUIBBLE_OPENSEARCH' ) !== 'true' ) {
74            // If running in quibble, disable writes unless OpenSearch is enabled.
75            // They will fail otherwise and potentially lead to timeouts (T389895).
76            $wgCirrusSearchClusters = [
77                'default' => [ 'localhost' ],
78            ];
79            $wgCirrusSearchWriteClusters = [];
80        }
81    }
82
83    /**
84     * Hooked to call initialize after the user is set up.
85     *
86     * @param Title $title
87     * @param null $unused
88     * @param OutputPage $outputPage
89     * @param User $user
90     * @param WebRequest $request
91     * @param ActionEntryPoint $mediaWiki
92     */
93    public function onBeforeInitialize( $title, $unused, $outputPage, $user, $request, $mediaWiki ) {
94        self::initializeForRequest( $request );
95    }
96
97    /**
98     * Hooked to call initialize after the user is set up.
99     * @param ApiMain &$apiMain The ApiMain instance being used
100     */
101    public function onApiBeforeMain( &$apiMain ) {
102        self::initializeForRequest( $apiMain->getRequest() );
103    }
104
105    /**
106     * Initializes the portions of Cirrus that require the $request to be fully initialized
107     */
108    public static function initializeForRequest( WebRequest $request ) {
109        global $wgCirrusSearchPhraseRescoreWindowSize,
110            $wgCirrusSearchFunctionRescoreWindowSize,
111            $wgCirrusSearchFragmentSize,
112            $wgCirrusSearchPhraseRescoreBoost,
113            $wgCirrusSearchPhraseSlop,
114            $wgCirrusSearchLogElasticRequests,
115            $wgCirrusSearchLogElasticRequestsSecret,
116            $wgCirrusSearchEnableAltLanguage,
117            $wgCirrusSearchUseCompletionSuggester,
118            $wgCirrusSearchCompletionSuggesterUseAltIndexId,
119            $wgCirrusSearchMustTrackTotalHits;
120
121        if ( $request->getCheck( 'cirrusApproxTotalHits' ) ) {
122            $wgCirrusSearchMustTrackTotalHits = [ 'default' => false ];
123        }
124        self::overrideMoreLikeThisOptionsFromMessage();
125
126        self::overrideNumeric( $wgCirrusSearchPhraseRescoreWindowSize,
127            $request, 'cirrusPhraseWindow', 10000 );
128        self::overrideNumeric( $wgCirrusSearchPhraseRescoreBoost,
129            $request, 'cirrusPhraseBoost' );
130        self::overrideNumeric( $wgCirrusSearchPhraseSlop[ 'boost' ],
131            $request, 'cirrusPhraseSlop', 10 );
132        self::overrideNumeric( $wgCirrusSearchFunctionRescoreWindowSize,
133            $request, 'cirrusFunctionWindow', 10000 );
134        self::overrideNumeric( $wgCirrusSearchFragmentSize,
135            $request, 'cirrusFragmentSize', 1000 );
136        if ( $wgCirrusSearchUseCompletionSuggester === 'yes' || $wgCirrusSearchUseCompletionSuggester === true ) {
137            // Only allow disabling the completion suggester, enabling it from request params might cause failures
138            // as the index might not be present.
139            self::overrideYesNo( $wgCirrusSearchUseCompletionSuggester,
140                $request, 'cirrusUseCompletionSuggester' );
141        }
142        self::overrideNumeric( $wgCirrusSearchCompletionSuggesterUseAltIndexId,
143            $request, 'cirrusCompletionAltIndexId' );
144        self::overrideMoreLikeThisOptions( $request );
145        self::overrideSecret( $wgCirrusSearchLogElasticRequests,
146            $wgCirrusSearchLogElasticRequestsSecret, $request, 'cirrusLogElasticRequests', false );
147        self::overrideYesNo( $wgCirrusSearchEnableAltLanguage,
148            $request, 'cirrusAltLanguage' );
149    }
150
151    /**
152     * Set $dest to the numeric value from $request->getVal( $name ) if it is <= $limit
153     * or => $limit if upperLimit is false.
154     *
155     * @param mixed &$dest
156     * @param WebRequest $request
157     * @param string $name
158     * @param int|null $limit
159     * @param bool $upperLimit
160     */
161    private static function overrideNumeric(
162        &$dest,
163        WebRequest $request,
164        $name,
165        $limit = null,
166        $upperLimit = true
167    ) {
168        Util::overrideNumeric( $dest, $request, $name, $limit, $upperLimit );
169    }
170
171    /**
172     * @param mixed &$dest
173     * @param WebRequest $request
174     * @param string $name
175     */
176    private static function overrideMinimumShouldMatch( &$dest, WebRequest $request, $name ) {
177        $val = $request->getVal( $name );
178        if ( $val !== null && self::isMinimumShouldMatch( $val ) ) {
179            $dest = $val;
180        }
181    }
182
183    /**
184     * Set $dest to $value when $request->getVal( $name ) contains $secret
185     *
186     * @param mixed &$dest
187     * @param string $secret
188     * @param WebRequest $request
189     * @param string $name
190     * @param mixed $value
191     */
192    private static function overrideSecret( &$dest, $secret, WebRequest $request, $name, $value = true ) {
193        if ( $secret && $secret === $request->getVal( $name ) ) {
194            $dest = $value;
195        }
196    }
197
198    /**
199     * Set $dest to the true/false from $request->getVal( $name ) if yes/no.
200     *
201     * @param mixed &$dest
202     * @param WebRequest $request
203     * @param string $name
204     */
205    private static function overrideYesNo( &$dest, WebRequest $request, $name ) {
206        Util::overrideYesNo( $dest, $request, $name );
207    }
208
209    /**
210     * Extract more like this settings from the i18n message cirrussearch-morelikethis-settings
211     */
212    private static function overrideMoreLikeThisOptionsFromMessage() {
213        global $wgCirrusSearchMoreLikeThisConfig,
214            $wgCirrusSearchMoreLikeThisAllowedFields,
215            $wgCirrusSearchMoreLikeThisMaxQueryTermsLimit,
216            $wgCirrusSearchMoreLikeThisFields;
217
218        $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
219        $lines = $cache->getWithSetCallback(
220            $cache->makeKey( 'cirrussearch-morelikethis-settings' ),
221            600,
222            static function () {
223                $source = wfMessage( 'cirrussearch-morelikethis-settings' )->inContentLanguage();
224                if ( $source->isDisabled() ) {
225                    return [];
226                }
227                return Util::parseSettingsInMessage( $source->plain() );
228            }
229        );
230
231        foreach ( $lines as $line ) {
232            if ( strpos( $line, ':' ) === false ) {
233                continue;
234            }
235            [ $k, $v ] = explode( ':', $line, 2 );
236            switch ( $k ) {
237                case 'min_doc_freq':
238                case 'max_doc_freq':
239                case 'max_query_terms':
240                case 'min_term_freq':
241                case 'min_word_length':
242                case 'max_word_length':
243                    if ( is_numeric( $v ) && $v >= 0 ) {
244                        $wgCirrusSearchMoreLikeThisConfig[$k] = intval( $v );
245                    } elseif ( $v === 'null' ) {
246                        unset( $wgCirrusSearchMoreLikeThisConfig[$k] );
247                    }
248                    break;
249                case 'percent_terms_to_match':
250                    // @deprecated Use minimum_should_match now
251                    $k = 'minimum_should_match';
252                    if ( is_numeric( $v ) && $v > 0 && $v <= 1 ) {
253                        $v = ( (int)( (float)$v * 100 ) ) . '%';
254                    } else {
255                        break;
256                    }
257                    // intentional fall-through
258                case 'minimum_should_match':
259                    if ( self::isMinimumShouldMatch( $v ) ) {
260                        $wgCirrusSearchMoreLikeThisConfig[$k] = $v;
261                    } elseif ( $v === 'null' ) {
262                        unset( $wgCirrusSearchMoreLikeThisConfig[$k] );
263                    }
264                    break;
265                case 'fields':
266                    $wgCirrusSearchMoreLikeThisFields = array_intersect(
267                        array_map( 'trim', explode( ',', $v ) ),
268                        $wgCirrusSearchMoreLikeThisAllowedFields );
269                    break;
270            }
271            // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
272            if ( $wgCirrusSearchMoreLikeThisConfig['max_query_terms'] > $wgCirrusSearchMoreLikeThisMaxQueryTermsLimit ) {
273                $wgCirrusSearchMoreLikeThisConfig['max_query_terms'] = $wgCirrusSearchMoreLikeThisMaxQueryTermsLimit;
274            }
275        }
276    }
277
278    /**
279     * @param string $v The value to check
280     * @return bool True if $v is an integer percentage in the domain -100 <= $v <= 100, $v != 0
281     * @todo minimum_should_match also supports combinations (3<90%) and multiple combinations
282     */
283    private static function isMinimumShouldMatch( string $v ) {
284        // specific integer count > 0
285        if ( ctype_digit( $v ) && $v != 0 ) {
286            return true;
287        }
288        // percentage 0 < x <= 100
289        if ( !str_ends_with( $v, '%' ) ) {
290            return false;
291        }
292        $v = substr( $v, 0, -1 );
293        if ( str_starts_with( $v, '-' ) ) {
294            $v = substr( $v, 1 );
295        }
296        return ctype_digit( $v ) && $v > 0 && $v <= 100;
297    }
298
299    /**
300     * Override more like this settings from request URI parameters
301     */
302    private static function overrideMoreLikeThisOptions( WebRequest $request ) {
303        global $wgCirrusSearchMoreLikeThisConfig,
304            $wgCirrusSearchMoreLikeThisAllowedFields,
305            $wgCirrusSearchMoreLikeThisMaxQueryTermsLimit,
306            $wgCirrusSearchMoreLikeThisFields;
307
308        self::overrideNumeric( $wgCirrusSearchMoreLikeThisConfig['min_doc_freq'],
309            $request, 'cirrusMltMinDocFreq' );
310        self::overrideNumeric( $wgCirrusSearchMoreLikeThisConfig['max_doc_freq'],
311            $request, 'cirrusMltMaxDocFreq' );
312        // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
313        self::overrideNumeric( $wgCirrusSearchMoreLikeThisConfig['max_query_terms'],
314            $request, 'cirrusMltMaxQueryTerms', $wgCirrusSearchMoreLikeThisMaxQueryTermsLimit );
315        self::overrideNumeric( $wgCirrusSearchMoreLikeThisConfig['min_term_freq'],
316            $request, 'cirrusMltMinTermFreq' );
317        self::overrideMinimumShouldMatch( $wgCirrusSearchMoreLikeThisConfig['minimum_should_match'],
318            $request, 'cirrusMltMinimumShouldMatch' );
319        self::overrideNumeric( $wgCirrusSearchMoreLikeThisConfig['min_word_length'],
320            $request, 'cirrusMltMinWordLength' );
321        self::overrideNumeric( $wgCirrusSearchMoreLikeThisConfig['max_word_length'],
322            $request, 'cirrusMltMaxWordLength' );
323        $fields = $request->getVal( 'cirrusMltFields' );
324        if ( $fields !== null ) {
325            $wgCirrusSearchMoreLikeThisFields = array_intersect(
326                array_map( 'trim', explode( ',', $fields ) ),
327                $wgCirrusSearchMoreLikeThisAllowedFields );
328        }
329    }
330
331    /**
332     * Hook called to include Elasticsearch version info on Special:Version
333     * @param array &$software Array of wikitext and version numbers
334     */
335    public function onSoftwareInfo( &$software ) {
336        $version = new Version( self::getConnection() );
337        $status = $version->get();
338        // We've already logged if this isn't ok and there is no need to warn the user on this page.
339        if ( $status->isOK() ) {
340            $distributions = [
341                'elasticsearch' => '[https://www.elastic.co/elasticsearch Elasticsearch]',
342                'opensearch' => '[https://opensearch.org OpenSearch]',
343            ];
344            $value = $status->getValue();
345            $desc = $distributions[$value['distribution']] ?? null;
346            if ( $desc === null ) {
347                LoggerFactory::getInstance( 'CirrusSearch' )->warning(
348                    'Unexpected software distribution [{name}] returned by Version check',
349                    [ 'name' => $value['distribution'] ]
350                );
351            } else {
352                $software[$desc] = $value['version'];
353            }
354        }
355    }
356
357    /**
358     * @param SpecialSearch $specialSearch
359     * @param OutputPage $out
360     * @param string $term
361     */
362    public function onSpecialSearchResultsAppend( $specialSearch, $out, $term ) {
363        $feedbackLink = $out->getConfig()->get( 'CirrusSearchFeedbackLink' );
364
365        if ( $feedbackLink ) {
366            self::addSearchFeedbackLink( $feedbackLink, $specialSearch, $out );
367        }
368
369        // Embed metrics if this was a Cirrus page
370        $engine = $specialSearch->getSearchEngine();
371        if ( $engine instanceof CirrusSearch ) {
372            $out->addJsConfigVars( $engine->getLastSearchMetrics() );
373        }
374    }
375
376    /**
377     * @param string $link
378     * @param SpecialSearch $specialSearch
379     * @param OutputPage $out
380     */
381    private static function addSearchFeedbackLink( $link, SpecialSearch $specialSearch, OutputPage $out ) {
382        $anchor = Html::element(
383            'a',
384            [ 'href' => $link ],
385            $specialSearch->msg( 'cirrussearch-give-feedback' )->text()
386        );
387        $block = Html::rawElement( 'div', [], $anchor );
388        $out->addHTML( $block );
389    }
390
391    /**
392     * Extract namespaces from query string.
393     * @param array &$namespaces
394     * @param string &$search
395     * @return bool
396     */
397    public function onPrefixSearchExtractNamespace( &$namespaces, &$search ) {
398        global $wgSearchType;
399        if ( $wgSearchType !== 'CirrusSearch' ) {
400            return true;
401        }
402        return self::prefixSearchExtractNamespaceWithConnection( self::getConnection(), $namespaces, $search );
403    }
404
405    /**
406     * @param Connection $connection
407     * @param array &$namespaces
408     * @param string &$search
409     * @return false
410     */
411    public static function prefixSearchExtractNamespaceWithConnection(
412        Connection $connection,
413        &$namespaces,
414        &$search
415    ) {
416        $method = $connection->getConfig()->get( 'CirrusSearchNamespaceResolutionMethod' );
417        if ( $method === 'elastic' ) {
418            $searcher =
419                new Searcher( $connection, 0, 1, $connection->getConfig(), $namespaces );
420            $searcher->updateNamespacesFromQuery( $search );
421            $namespaces = $searcher->getSearchContext()->getNamespaces();
422        } else {
423            $colon = strpos( $search, ':' );
424            if ( $colon === false ) {
425                return false;
426            }
427            $namespaceName = substr( $search, 0, $colon );
428            $ns = Util::identifyNamespace( $namespaceName, $method );
429            if ( $ns !== false ) {
430                $namespaces = [ $ns ];
431                $search = substr( $search, $colon + 1 );
432            }
433        }
434
435        return false;
436    }
437
438    /** @inheritDoc */
439    public function onSearchGetNearMatch( $term, &$titleResult ) {
440        return self::handleSearchGetNearMatch( $term, $titleResult );
441    }
442
443    /**
444     * Let Elasticsearch take a crack at getting near matches once mediawiki has tried all kinds of variants.
445     * @param string $term the original search term and all language variants
446     * @param null|Title &$titleResult resulting match.  A Title if we found something, unchanged otherwise.
447     * @return bool return false if we find something, true otherwise so mediawiki can try its default behavior
448     */
449    public static function handleSearchGetNearMatch( $term, &$titleResult ) {
450        global $wgSearchType;
451        if ( $wgSearchType !== 'CirrusSearch' ) {
452            return true;
453        }
454
455        $title = Title::newFromText( $term );
456        if ( $title === null ) {
457            return false;
458        }
459
460        $user = RequestContext::getMain()->getUser();
461        // Ask for the first 50 results we see.  If there are more than that too bad.
462        $searcher = new Searcher(
463            self::getConnection(), 0, 50, self::getConfig(), [ $title->getNamespace() ], $user );
464        if ( $title->getNamespace() === NS_MAIN ) {
465            $searcher->updateNamespacesFromQuery( $term );
466        } else {
467            $term = $title->getText();
468        }
469        $searcher->setResultsType( new FancyTitleResultsType( 'near_match' ) );
470        $status = $searcher->nearMatchTitleSearch( $term );
471        // There is no way to send errors or warnings back to the caller here so we have to make do with
472        // only sending results back if there are results and relying on the logging done at the status
473        // construction site to log errors.
474        if ( !$status->isOK() ) {
475            return true;
476        }
477
478        $contLang = MediaWikiServices::getInstance()->getContentLanguage();
479        $picker = new NearMatchPicker( $contLang, $term, $status->getValue() );
480        $best = $picker->pickBest();
481        if ( $best ) {
482            $titleResult = $best;
483            return false;
484        }
485        // Didn't find a result so let MediaWiki have a crack at it.
486        return true;
487    }
488
489    /**
490     * ResourceLoaderGetConfigVars hook handler
491     * This should be used for variables which vary with the html
492     * and for variables this should work cross skin
493     * @see https://www.mediawiki.org/wiki/Manual:Hooks/ResourceLoaderGetConfigVars
494     *
495     * @param array &$vars
496     * @param string $skin
497     * @param Config $config
498     */
499    public function onResourceLoaderGetConfigVars( array &$vars, $skin, Config $config ): void {
500        $vars += [
501            'wgCirrusSearchFeedbackLink' => $config->get( 'CirrusSearchFeedbackLink' ),
502        ];
503    }
504
505    /**
506     * @return SearchConfig
507     */
508    private static function getConfig() {
509        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
510        return MediaWikiServices::getInstance()
511            ->getConfigFactory()
512            ->makeConfig( 'CirrusSearch' );
513    }
514
515    /**
516     * @return Connection
517     */
518    private static function getConnection() {
519        return new Connection( self::getConfig() );
520    }
521
522    /**
523     * Add $wgCirrusSearchInterwikiProv to external results.
524     * @param Title &$title
525     * @param string|HtmlArmor|null &$text
526     * @param SearchResult $result
527     * @param array $terms
528     * @param SpecialSearch $page
529     * @param string[] &$query
530     * @param string[] &$attributes
531     */
532    public function onShowSearchHitTitle( &$title, &$text, $result, $terms, $page, &$query, &$attributes ) {
533        global $wgCirrusSearchInterwikiProv;
534        if ( $wgCirrusSearchInterwikiProv && $title->isExternal() ) {
535            $query["wprov"] = $wgCirrusSearchInterwikiProv;
536        }
537    }
538
539    /**
540     * @param ApiBase $module
541     */
542    public function onAPIAfterExecute( $module ) {
543        if ( !ElasticsearchIntermediary::hasQueryLogs() ) {
544            return;
545        }
546        $response = $module->getContext()->getRequest()->response();
547        $response->header( 'X-Search-ID: ' . Util::getRequestSetToken() );
548        if ( $module instanceof ApiOpenSearch ) {
549            $types = ElasticsearchIntermediary::getQueryTypesUsed();
550            if ( $types ) {
551                $response->header( 'X-OpenSearch-Type: ' . implode( ',', $types ) );
552            }
553        }
554    }
555
556    /**
557     * @param string $term
558     * @param ISearchResultSet|null &$titleMatches
559     * @param ISearchResultSet|null &$textMatches
560     */
561    public function onSpecialSearchResults( $term, &$titleMatches, &$textMatches ) {
562        $context = RequestContext::getMain();
563        $out = $context->getOutput();
564
565        $out->addModules( 'ext.cirrus.serp' );
566
567        $jsVars = [
568            'wgCirrusSearchRequestSetToken' => Util::getRequestSetToken(),
569        ];
570        // In theory UserTesting should always have been activated by now, but if
571        // somehow it wasn't we don't want to activate it now at the end of the request
572        // and report incorrect data.
573        if ( UserTestingStatus::hasInstance() ) {
574            $ut = UserTestingStatus::getInstance();
575            if ( $ut->isActive() ) {
576                $trigger = $ut->getTrigger();
577                $jsVars['wgCirrusSearchActiveUserTest'] = $trigger;
578                // bc for first deployment, some users will still have old js.
579                // Should be removed in following deployment.
580                $jsVars['wgCirrusSearchBackendUserTests'] = $trigger ? [ $trigger ] : [];
581            }
582        }
583        $out->addJsConfigVars( $jsVars );
584
585        // This ignores interwiki results for now...not sure what do do with those
586        ElasticsearchIntermediary::setResultPages( [
587            $titleMatches,
588            $textMatches
589        ] );
590    }
591
592    private static function addWordCount( array &$extraStats ): void {
593        $search = new CirrusSearch();
594
595        $status = $search->countContentWords();
596        if ( !$status->isOK() ) {
597            return;
598        }
599        $wordCount = $status->getValue();
600        if ( $wordCount !== null ) {
601            $extraStats['cirrussearch-article-words'] = $wordCount;
602        }
603    }
604
605    /** @inheritDoc */
606    public function onGetPreferences( $user, &$prefs ) {
607        $search = new CirrusSearch();
608        $profiles = $search->getProfiles( \SearchEngine::COMPLETION_PROFILE_TYPE, $user );
609        if ( !$profiles ) {
610            return;
611        }
612        $options = self::autoCompleteOptionsForPreferences( $profiles );
613        if ( !$options ) {
614            return;
615        }
616        $prefs['cirrussearch-pref-completion-profile'] = [
617            'type' => 'radio',
618            'section' => 'searchoptions/completion',
619            'options' => $options,
620            'label-message' => 'cirrussearch-pref-completion-profile-help',
621        ];
622    }
623
624    /**
625     * @param array[] $profiles
626     * @return string[]
627     */
628    private static function autoCompleteOptionsForPreferences( array $profiles ): array {
629        $available = array_column( $profiles, 'name' );
630        // Order in which we propose comp suggest profiles
631        $preferredOrder = [
632            'fuzzy',
633            'fuzzy-subphrases',
634            'strict',
635            'normal',
636            'normal-subphrases',
637            'classic'
638        ];
639        $messages = [];
640        foreach ( $preferredOrder as $name ) {
641            if ( in_array( $name, $available ) ) {
642                $display = wfMessage( "cirrussearch-completion-profile-$name-pref-name" )->escaped() .
643                    new \OOUI\LabelWidget( [
644                        'classes' => [ 'oo-ui-inline-help' ],
645                        'label' => wfMessage( "cirrussearch-completion-profile-$name-pref-desc" )->text()
646                    ] );
647                $messages[$display] = $name;
648            }
649        }
650        // At least 2 choices are required to provide the user a choice
651        return count( $messages ) >= 2 ? $messages : [];
652    }
653
654    /** @inheritDoc */
655    public function onUserGetDefaultOptions( &$defaultOptions ) {
656        $defaultOptions['cirrussearch-pref-completion-profile'] =
657            $this->configFactory->makeConfig( 'CirrusSearch' )->get( 'CirrusSearchCompletionSettings' );
658    }
659
660    /** @inheritDoc */
661    public function onSpecialStatsAddExtra( &$extraStats, $context ) {
662        self::addWordCount( $extraStats );
663    }
664
665    /** @inheritDoc */
666    public function onAPIQuerySiteInfoStatisticsInfo( &$extraStats ) {
667        self::addWordCount( $extraStats );
668    }
669}