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