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