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