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