Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
32.46% covered (danger)
32.46%
87 / 268
28.21% covered (danger)
28.21%
11 / 39
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
32.46% covered (danger)
32.46%
87 / 268
28.21% covered (danger)
28.21%
11 / 39
4333.99
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%
12 / 12
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
20.00% covered (danger)
20.00%
8 / 40
0.00% covered (danger)
0.00%
0 / 1
293.85
 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%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 onArticleDelete
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 onArticleDeleteComplete
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 onRevisionDelete
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 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 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 onLinksUpdateCompleted
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 onUploadComplete
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 onPrefixSearchExtractNamespace
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 prefixSearchExtractNamespaceWithConnection
57.14% covered (warning)
57.14%
8 / 14
0.00% covered (danger)
0.00%
0 / 1
5.26
 onSearchGetNearMatch
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
42
 onTitleMove
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onPageMoveComplete
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 prepareTitlesForLinksUpdate
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 pickFromArray
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 onResourceLoaderGetConfigVars
n/a
0 / 0
n/a
0 / 0
1
 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 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 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%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 autoCompleteOptionsForPreferences
100.00% covered (success)
100.00%
9 / 9
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
 onMediaWikiServices
25.00% covered (danger)
25.00%
4 / 16
0.00% covered (danger)
0.00%
0 / 1
1.42
 onArticleUndelete
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 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\Profile\SearchProfileServiceFactory;
9use CirrusSearch\Search\FancyTitleResultsType;
10use ConfigFactory;
11use DeferredUpdates;
12use Html;
13use ISearchResultSet;
14use LinksUpdate;
15use MediaWiki\Linker\LinkTarget;
16use MediaWiki\Logger\LoggerFactory;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Preferences\Hook\GetPreferencesHook;
19use MediaWiki\Revision\RevisionRecord;
20use MediaWiki\User\Hook\UserGetDefaultOptionsHook;
21use MediaWiki\User\UserIdentity;
22use OutputPage;
23use RequestContext;
24use SpecialSearch;
25use Title;
26use User;
27use WebRequest;
28use WikiPage;
29use Xml;
30
31/**
32 * All CirrusSearch's external hooks.
33 *
34 * This program is free software; you can redistribute it and/or modify
35 * it under the terms of the GNU General Public License as published by
36 * the Free Software Foundation; either version 2 of the License, or
37 * (at your option) any later version.
38 *
39 * This program is distributed in the hope that it will be useful,
40 * but WITHOUT ANY WARRANTY; without even the implied warranty of
41 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
42 * GNU General Public License for more details.
43 *
44 * You should have received a copy of the GNU General Public License along
45 * with this program; if not, write to the Free Software Foundation, Inc.,
46 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
47 * http://www.gnu.org/copyleft/gpl.html
48 */
49class Hooks implements UserGetDefaultOptionsHook, GetPreferencesHook {
50    /**
51     * @var string[] Destination of titles being moved (the ->getPrefixedDBkey() form).
52     */
53    private static $movingTitles = [];
54
55    /** @var ConfigFactory */
56    private $configFactory;
57
58    /**
59     * @param ConfigFactory $configFactory
60     */
61    public function __construct( ConfigFactory $configFactory ) {
62        $this->configFactory = $configFactory;
63    }
64
65    /**
66     * Hooked to call initialize after the user is set up.
67     *
68     * @param Title $title
69     * @param \Article $unused
70     * @param OutputPage $outputPage
71     * @param User $user
72     * @param \WebRequest $request
73     * @param \MediaWiki $mediaWiki
74     */
75    public static function onBeforeInitialize( $title, $unused, $outputPage, $user, $request, $mediaWiki ) {
76        self::initializeForRequest( $request );
77    }
78
79    /**
80     * Hooked to call initialize after the user is set up.
81     * @param ApiMain $apiMain The ApiMain instance being used
82     */
83    public static function onApiBeforeMain( $apiMain ) {
84        self::initializeForRequest( $apiMain->getRequest() );
85    }
86
87    /**
88     * Initializes the portions of Cirrus that require the $request to be fully initialized
89     *
90     * @param WebRequest $request
91     */
92    public static function initializeForRequest( WebRequest $request ) {
93        global $wgCirrusSearchPhraseRescoreWindowSize,
94            $wgCirrusSearchFunctionRescoreWindowSize,
95            $wgCirrusSearchFragmentSize,
96            $wgCirrusSearchAllFields,
97            $wgCirrusSearchPhraseRescoreBoost,
98            $wgCirrusSearchPhraseSlop,
99            $wgCirrusSearchLogElasticRequests,
100            $wgCirrusSearchLogElasticRequestsSecret,
101            $wgCirrusSearchEnableAltLanguage,
102            $wgCirrusSearchUseCompletionSuggester;
103
104        self::overrideMoreLikeThisOptionsFromMessage();
105
106        self::overrideNumeric( $wgCirrusSearchPhraseRescoreWindowSize,
107            $request, 'cirrusPhraseWindow', 10000 );
108        self::overrideNumeric( $wgCirrusSearchPhraseRescoreBoost,
109            $request, 'cirrusPhraseBoost' );
110        self::overrideNumeric( $wgCirrusSearchPhraseSlop[ 'boost' ],
111            $request, 'cirrusPhraseSlop', 10 );
112        self::overrideNumeric( $wgCirrusSearchFunctionRescoreWindowSize,
113            $request, 'cirrusFunctionWindow', 10000 );
114        self::overrideNumeric( $wgCirrusSearchFragmentSize,
115            $request, 'cirrusFragmentSize', 1000 );
116        self::overrideYesNo( $wgCirrusSearchAllFields[ 'use' ],
117            $request, 'cirrusUseAllFields' );
118        if ( $wgCirrusSearchUseCompletionSuggester === 'yes' || $wgCirrusSearchUseCompletionSuggester === true ) {
119            // Only allow disabling the completion suggester, enabling it from request params might cause failures
120            // as the index might not be present.
121            self::overrideYesNo( $wgCirrusSearchUseCompletionSuggester,
122                $request, 'cirrusUseCompletionSuggester' );
123        }
124        self::overrideMoreLikeThisOptions( $request );
125        self::overrideSecret( $wgCirrusSearchLogElasticRequests,
126            $wgCirrusSearchLogElasticRequestsSecret, $request, 'cirrusLogElasticRequests', false );
127        self::overrideYesNo( $wgCirrusSearchEnableAltLanguage,
128            $request, 'cirrusAltLanguage' );
129    }
130
131    /**
132     * Set $dest to the numeric value from $request->getVal( $name ) if it is <= $limit
133     * or => $limit if upperLimit is false.
134     *
135     * @param mixed &$dest
136     * @param WebRequest $request
137     * @param string $name
138     * @param int|null $limit
139     * @param bool $upperLimit
140     */
141    private static function overrideNumeric(
142        &$dest,
143        WebRequest $request,
144        $name,
145        $limit = null,
146        $upperLimit = true
147    ) {
148        Util::overrideNumeric( $dest, $request, $name, $limit, $upperLimit );
149    }
150
151    /**
152     * @param mixed &$dest
153     * @param WebRequest $request
154     * @param string $name
155     */
156    private static function overrideMinimumShouldMatch( &$dest, WebRequest $request, $name ) {
157        $val = $request->getVal( $name );
158        if ( $val !== null && self::isMinimumShouldMatch( $val ) ) {
159            $dest = $val;
160        }
161    }
162
163    /**
164     * Set $dest to $value when $request->getVal( $name ) contains $secret
165     *
166     * @param mixed &$dest
167     * @param string $secret
168     * @param WebRequest $request
169     * @param string $name
170     * @param mixed $value
171     */
172    private static function overrideSecret( &$dest, $secret, WebRequest $request, $name, $value = true ) {
173        if ( $secret && $secret === $request->getVal( $name ) ) {
174            $dest = $value;
175        }
176    }
177
178    /**
179     * Set $dest to the true/false from $request->getVal( $name ) if yes/no.
180     *
181     * @param mixed &$dest
182     * @param WebRequest $request
183     * @param string $name
184     */
185    private static function overrideYesNo( &$dest, WebRequest $request, $name ) {
186        Util::overrideYesNo( $dest, $request, $name );
187    }
188
189    /**
190     * Extract more like this settings from the i18n message cirrussearch-morelikethis-settings
191     */
192    private static function overrideMoreLikeThisOptionsFromMessage() {
193        global $wgCirrusSearchMoreLikeThisConfig,
194            $wgCirrusSearchMoreLikeThisAllowedFields,
195            $wgCirrusSearchMoreLikeThisMaxQueryTermsLimit,
196            $wgCirrusSearchMoreLikeThisFields;
197
198        $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
199        $lines = $cache->getWithSetCallback(
200            $cache->makeKey( 'cirrussearch-morelikethis-settings' ),
201            600,
202            static function () {
203                $source = wfMessage( 'cirrussearch-morelikethis-settings' )->inContentLanguage();
204                if ( $source && $source->isDisabled() ) {
205                    return [];
206                }
207                return Util::parseSettingsInMessage( $source->plain() );
208            }
209        );
210
211        foreach ( $lines as $line ) {
212            if ( strpos( $line, ':' ) === false ) {
213                continue;
214            }
215            list( $k, $v ) = explode( ':', $line, 2 );
216            switch ( $k ) {
217            case 'min_doc_freq':
218            case 'max_doc_freq':
219            case 'max_query_terms':
220            case 'min_term_freq':
221            case 'min_word_length':
222            case 'max_word_length':
223                if ( is_numeric( $v ) && $v >= 0 ) {
224                    $wgCirrusSearchMoreLikeThisConfig[$k] = intval( $v );
225                } elseif ( $v === 'null' ) {
226                    unset( $wgCirrusSearchMoreLikeThisConfig[$k] );
227                }
228                break;
229            case 'percent_terms_to_match':
230                // @deprecated Use minimum_should_match now
231                $k = 'minimum_should_match';
232                if ( is_numeric( $v ) && $v > 0 && $v <= 1 ) {
233                    $v = ( (int)( (float)$v * 100 ) ) . '%';
234                } else {
235                    break;
236                }
237                // intentional fall-through
238            case 'minimum_should_match':
239                if ( self::isMinimumShouldMatch( $v ) ) {
240                    $wgCirrusSearchMoreLikeThisConfig[$k] = $v;
241                } elseif ( $v === 'null' ) {
242                    unset( $wgCirrusSearchMoreLikeThisConfig[$k] );
243                }
244                break;
245            case 'fields':
246                $wgCirrusSearchMoreLikeThisFields = array_intersect(
247                    array_map( 'trim', explode( ',', $v ) ),
248                    $wgCirrusSearchMoreLikeThisAllowedFields );
249                break;
250            }
251            // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
252            if ( $wgCirrusSearchMoreLikeThisConfig['max_query_terms'] > $wgCirrusSearchMoreLikeThisMaxQueryTermsLimit ) {
253                $wgCirrusSearchMoreLikeThisConfig['max_query_terms'] = $wgCirrusSearchMoreLikeThisMaxQueryTermsLimit;
254            }
255        }
256    }
257
258    /**
259     * @param string $v The value to check
260     * @return bool True if $v is an integer percentage in the domain -100 <= $v <= 100, $v != 0
261     * @todo minimum_should_match also supports combinations (3<90%) and multiple combinations
262     */
263    private static function isMinimumShouldMatch( string $v ) {
264        // specific integer count > 0
265        if ( ctype_digit( $v ) && $v != 0 ) {
266            return true;
267        }
268        // percentage 0 < x <= 100
269        if ( substr( $v, -1 ) !== '%' ) {
270            return false;
271        }
272        $v = substr( $v, 0, -1 );
273        if ( substr( $v, 0, 1 ) === '-' ) {
274            $v = substr( $v, 1 );
275        }
276        return ctype_digit( $v ) && $v > 0 && $v <= 100;
277    }
278
279    /**
280     * Override more like this settings from request URI parameters
281     *
282     * @param WebRequest $request
283     */
284    private static function overrideMoreLikeThisOptions( WebRequest $request ) {
285        global $wgCirrusSearchMoreLikeThisConfig,
286            $wgCirrusSearchMoreLikeThisAllowedFields,
287            $wgCirrusSearchMoreLikeThisMaxQueryTermsLimit,
288            $wgCirrusSearchMoreLikeThisFields;
289
290        self::overrideNumeric( $wgCirrusSearchMoreLikeThisConfig['min_doc_freq'],
291            $request, 'cirrusMltMinDocFreq' );
292        self::overrideNumeric( $wgCirrusSearchMoreLikeThisConfig['max_doc_freq'],
293            $request, 'cirrusMltMaxDocFreq' );
294        // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
295        self::overrideNumeric( $wgCirrusSearchMoreLikeThisConfig['max_query_terms'],
296            $request, 'cirrusMltMaxQueryTerms', $wgCirrusSearchMoreLikeThisMaxQueryTermsLimit );
297        self::overrideNumeric( $wgCirrusSearchMoreLikeThisConfig['min_term_freq'],
298            $request, 'cirrusMltMinTermFreq' );
299        self::overrideMinimumShouldMatch( $wgCirrusSearchMoreLikeThisConfig['minimum_should_match'],
300            $request, 'cirrusMltMinimumShouldMatch' );
301        self::overrideNumeric( $wgCirrusSearchMoreLikeThisConfig['min_word_length'],
302            $request, 'cirrusMltMinWordLength' );
303        self::overrideNumeric( $wgCirrusSearchMoreLikeThisConfig['max_word_length'],
304            $request, 'cirrusMltMaxWordLength' );
305        $fields = $request->getVal( 'cirrusMltFields' );
306        if ( isset( $fields ) ) {
307            $wgCirrusSearchMoreLikeThisFields = array_intersect(
308                array_map( 'trim', explode( ',', $fields ) ),
309                $wgCirrusSearchMoreLikeThisAllowedFields );
310        }
311    }
312
313    /**
314     * Hook to call before an article is deleted
315     * @param WikiPage $page The page we're deleting
316     */
317    public static function onArticleDelete( $page ) {
318        // We use this to pick up redirects so we can update their targets.
319        // Can't re-use ArticleDeleteComplete because the page info's
320        // already gone
321        // If we abort or fail deletion it's no big deal because this will
322        // end up being a no-op when it executes.
323        $target = $page->getRedirectTarget();
324        if ( $target ) {
325            // DeferredUpdate so we don't end up racing our own page deletion
326            DeferredUpdates::addCallableUpdate( static function () use ( $target ) {
327                MediaWikiServices::getInstance()->getJobQueueGroup()->push(
328                    new Job\LinksUpdate( $target, [
329                        'addedLinks' => [],
330                        'removedLinks' => [],
331                    ] )
332                );
333            } );
334        }
335    }
336
337    /**
338     * Hook to call after an article is deleted
339     * @param WikiPage $page The page we're deleting
340     * @param User $user The user deleting the page
341     * @param string $reason Reason the page is being deleted
342     * @param int $pageId Page id being deleted
343     */
344    public static function onArticleDeleteComplete( $page, $user, $reason, $pageId ) {
345        // Note that we must use the article id provided or it'll be lost in the ether.  The job can't
346        // load it from the title because the page row has already been deleted.
347        MediaWikiServices::getInstance()->getJobQueueGroup()->push(
348            new Job\DeletePages( $page->getTitle(), [
349                'docId' => self::getConfig()->makeId( $pageId )
350            ] )
351        );
352    }
353
354    /**
355     * Called when a revision is deleted. In theory, we shouldn't need to to this since
356     * you can't delete the current text of a page (so we should've already updated when
357     * the page was updated last). But we're paranoid, because deleted revisions absolutely
358     * should not be in the index.
359     *
360     * @param Title $title The page title we've had a revision deleted on
361     */
362    public static function onRevisionDelete( $title ) {
363        MediaWikiServices::getInstance()->getJobQueueGroup()->push(
364            new Job\LinksUpdate( $title, [
365                'addedLinks' => [],
366                'removedLinks' => [],
367                'prioritize' => true
368            ] )
369        );
370    }
371
372    /**
373     * Hook called to include Elasticsearch version info on Special:Version
374     * @param array &$software Array of wikitext and version numbers
375     */
376    public static function onSoftwareInfo( &$software ) {
377        $version = new Version( self::getConnection() );
378        $status = $version->get();
379        if ( $status->isOK() ) {
380            // We've already logged if this isn't ok and there is no need to warn the user on this page.
381            $software[ '[https://www.elastic.co/elasticsearch Elasticsearch]' ] = $status->getValue();
382        }
383    }
384
385    /**
386     * @param SpecialSearch $specialSearch
387     * @param OutputPage $out
388     * @param string $term
389     */
390    public static function onSpecialSearchResultsAppend( $specialSearch, $out, $term ) {
391        global $wgCirrusSearchFeedbackLink;
392
393        if ( $wgCirrusSearchFeedbackLink ) {
394            self::addSearchFeedbackLink( $wgCirrusSearchFeedbackLink, $specialSearch, $out );
395        }
396
397        // Embed metrics if this was a Cirrus page
398        $engine = $specialSearch->getSearchEngine();
399        if ( $engine instanceof CirrusSearch ) {
400            $out->addJsConfigVars( $engine->getLastSearchMetrics() );
401        }
402    }
403
404    /**
405     * @param string $link
406     * @param SpecialSearch $specialSearch
407     * @param OutputPage $out
408     */
409    private static function addSearchFeedbackLink( $link, SpecialSearch $specialSearch, OutputPage $out ) {
410        $anchor = Xml::element(
411            'a',
412            [ 'href' => $link ],
413            $specialSearch->msg( 'cirrussearch-give-feedback' )->text()
414        );
415        $block = Html::rawElement( 'div', [], $anchor );
416        $out->addHTML( $block );
417    }
418
419    /**
420     * Hooked to update the search index when pages change directly or when templates that
421     * they include change.
422     * @param LinksUpdate $linksUpdate source of all links update information
423     */
424    public static function onLinksUpdateCompleted( $linksUpdate ) {
425        global $wgCirrusSearchLinkedArticlesToUpdate,
426            $wgCirrusSearchUnlinkedArticlesToUpdate,
427            $wgCirrusSearchUpdateDelay;
428
429        // Titles that are created by a move don't need their own job.
430        if ( in_array( $linksUpdate->getTitle()->getPrefixedDBkey(), self::$movingTitles ) ) {
431            return;
432        }
433
434        $params = [
435            'addedLinks' => self::prepareTitlesForLinksUpdate(
436                $linksUpdate->getAddedLinks(), $wgCirrusSearchLinkedArticlesToUpdate ),
437            // We exclude links that contains invalid UTF-8 sequences, reason is that page created
438            // before T13143 was fixed might sill have bad links the pagelinks table
439            // and thus will cause LinksUpdate to believe that these links are removed.
440            'removedLinks' => self::prepareTitlesForLinksUpdate(
441                $linksUpdate->getRemovedLinks(), $wgCirrusSearchUnlinkedArticlesToUpdate, true ),
442        ];
443        // non recursive LinksUpdate can go to the non prioritized queue
444        if ( $linksUpdate->isRecursive() ) {
445            $params[ 'prioritize' ] = true;
446            $delay = $wgCirrusSearchUpdateDelay['prioritized'];
447        } else {
448            $delay = $wgCirrusSearchUpdateDelay['default'];
449        }
450        $params += Job\LinksUpdate::buildJobDelayOptions( Job\LinksUpdate::class, $delay );
451        $job = new Job\LinksUpdate( $linksUpdate->getTitle(), $params );
452
453        MediaWikiServices::getInstance()->getJobQueueGroup()->lazyPush( $job );
454    }
455
456    /**
457     * Hook into UploadComplete, overwritten files do not seem to trigger LinksUpdateComplete.
458     * Since files do contain indexed metadata we need to refresh the search index when a file
459     * is overwritten on an existing title.
460     * @param \UploadBase $uploadBase
461     */
462    public static function onUploadComplete( \UploadBase $uploadBase ) {
463        if ( $uploadBase->getTitle()->exists() ) {
464            MediaWikiServices::getInstance()->getJobQueueGroup()->push(
465                new Job\LinksUpdate( $uploadBase->getTitle(), [
466                    'addedLinks' => [],
467                    'removedLinks' => [],
468                    'prioritize' => true
469                ] )
470            );
471        }
472    }
473
474    /**
475     * Extract namespaces from query string.
476     * @param array &$namespaces
477     * @param string &$search
478     * @return bool
479     */
480    public static function onPrefixSearchExtractNamespace( &$namespaces, &$search ) {
481        global $wgSearchType;
482        if ( $wgSearchType !== 'CirrusSearch' ) {
483            return true;
484        }
485        return self::prefixSearchExtractNamespaceWithConnection( self::getConnection(), $namespaces, $search );
486    }
487
488    /**
489     * @param Connection $connection
490     * @param array &$namespaces
491     * @param string &$search
492     * @return false
493     */
494    public static function prefixSearchExtractNamespaceWithConnection(
495        Connection $connection,
496        &$namespaces,
497        &$search
498    ) {
499        $method = $connection->getConfig()->get( 'CirrusSearchNamespaceResolutionMethod' );
500        if ( $method === 'elastic' ) {
501            $searcher =
502                new Searcher( $connection, 0, 1, $connection->getConfig(), $namespaces );
503            $searcher->updateNamespacesFromQuery( $search );
504            $namespaces = $searcher->getSearchContext()->getNamespaces();
505        } else {
506            $colon = strpos( $search, ':' );
507            if ( $colon === false ) {
508                return false;
509            }
510            $namespaceName = substr( $search, 0, $colon );
511            $ns = Util::identifyNamespace( $namespaceName, $method );
512            if ( $ns !== false ) {
513                $namespaces = [ $ns ];
514                $search = substr( $search, $colon + 1 );
515            }
516        }
517
518        return false;
519    }
520
521    /**
522     * Let Elasticsearch take a crack at getting near matches once mediawiki has tried all kinds of variants.
523     * @param string $term the original search term and all language variants
524     * @param null|Title &$titleResult resulting match.  A Title if we found something, unchanged otherwise.
525     * @return bool return false if we find something, true otherwise so mediawiki can try its default behavior
526     */
527    public static function onSearchGetNearMatch( $term, &$titleResult ) {
528        global $wgSearchType;
529        if ( $wgSearchType !== 'CirrusSearch' ) {
530            return true;
531        }
532
533        $title = Title::newFromText( $term );
534        if ( $title === null ) {
535            return false;
536        }
537
538        $user = RequestContext::getMain()->getUser();
539        // Ask for the first 50 results we see.  If there are more than that too bad.
540        $searcher = new Searcher(
541            self::getConnection(), 0, 50, self::getConfig(), [ $title->getNamespace() ], $user );
542        if ( $title->getNamespace() === NS_MAIN ) {
543            $searcher->updateNamespacesFromQuery( $term );
544        } else {
545            $term = $title->getText();
546        }
547        $searcher->setResultsType( new FancyTitleResultsType( 'near_match' ) );
548        $status = $searcher->nearMatchTitleSearch( $term );
549        // There is no way to send errors or warnings back to the caller here so we have to make do with
550        // only sending results back if there are results and relying on the logging done at the status
551        // construction site to log errors.
552        if ( !$status->isOK() ) {
553            return true;
554        }
555
556        $contLang = MediaWikiServices::getInstance()->getContentLanguage();
557        $picker = new NearMatchPicker( $contLang, $term, $status->getValue() );
558        $best = $picker->pickBest();
559        if ( $best ) {
560            $titleResult = $best;
561            return false;
562        }
563        // Didn't find a result so let Mediawiki have a crack at it.
564        return true;
565    }
566
567    /**
568     * Before we've moved a title from $title to $newTitle.
569     * @param Title $title old title
570     * @param Title $newTitle
571     * @param User $user User who made the move
572     */
573    public static function onTitleMove( Title $title, Title $newTitle, $user ) {
574        self::$movingTitles[] = $title->getPrefixedDBkey();
575    }
576
577    /**
578     * When we've moved a Title from A to B.
579     * @param LinkTarget $title The old title
580     * @param LinkTarget $newTitle
581     * @param UserIdentity $user User who made the move
582     * @param int $oldId The page id of the old page.
583     * @param int $redirId
584     * @param string $reason
585     * @param RevisionRecord $revisionRecord
586     */
587    public static function onPageMoveComplete(
588        LinkTarget $title,
589        LinkTarget $newTitle,
590        UserIdentity $user,
591        int $oldId,
592        int $redirId,
593        string $reason,
594        RevisionRecord $revisionRecord
595    ) {
596        // When a page is moved the update and delete hooks are good enough to catch
597        // almost everything.  The only thing they miss is if a page moves from one
598        // index to another.  That only happens if it switches namespace.
599        if ( $title->getNamespace() === $newTitle->getNamespace() ) {
600            return;
601        }
602
603        $conn = self::getConnection();
604        $oldIndexSuffix = $conn->getIndexSuffixForNamespace( $title->getNamespace() );
605        $newIndexSuffix = $conn->getIndexSuffixForNamespace( $newTitle->getNamespace() );
606        if ( $oldIndexSuffix !== $newIndexSuffix ) {
607            $title = Title::newFromLinkTarget( $title );
608            $job = new Job\DeletePages( $title, [
609                'indexSuffix' => $oldIndexSuffix,
610                'docId' => self::getConfig()->makeId( $oldId )
611            ] );
612            // Push the job after DB commit but cancel on rollback
613            wfGetDB( DB_PRIMARY )->onTransactionCommitOrIdle( static function () use ( $job ) {
614                MediaWikiServices::getInstance()->getJobQueueGroup()->lazyPush( $job );
615            }, __METHOD__ );
616        }
617    }
618
619    /**
620     * Take a list of titles either linked or unlinked and prepare them for Job\LinksUpdate.
621     * This includes limiting them to $max titles.
622     * @param Title[] $titles titles to prepare
623     * @param int $max maximum number of titles to return
624     * @param bool $excludeBadUTF exclude links that contains invalid UTF sequences
625     * @return array
626     */
627    public static function prepareTitlesForLinksUpdate( $titles, $max, $excludeBadUTF = false ) {
628        $titles = self::pickFromArray( $titles, $max );
629        $dBKeys = [];
630        foreach ( $titles as $title ) {
631            $key = $title->getPrefixedDBkey();
632            if ( $excludeBadUTF ) {
633                $fixedKey = mb_convert_encoding( $key, 'UTF-8', 'UTF-8' );
634                if ( $fixedKey !== $key ) {
635                    LoggerFactory::getInstance( 'CirrusSearch' )
636                        ->warning( "Ignoring title {title} with invalid UTF-8 sequences.",
637                            [ 'title' => $fixedKey ] );
638                    continue;
639                }
640            }
641            $dBKeys[] = $title->getPrefixedDBkey();
642        }
643        return $dBKeys;
644    }
645
646    /**
647     * Pick $num random entries from $array.
648     * @param array $array Array to pick from
649     * @param int $num Number of entries to pick
650     * @return array of entries from $array
651     */
652    private static function pickFromArray( $array, $num ) {
653        if ( $num > count( $array ) ) {
654            return $array;
655        }
656        if ( $num < 1 ) {
657            return [];
658        }
659        $chosen = array_rand( $array, $num );
660        // If $num === 1 then array_rand will return a key rather than an array of keys.
661        if ( !is_array( $chosen ) ) {
662            return [ $array[ $chosen ] ];
663        }
664        $result = [];
665        foreach ( $chosen as $key ) {
666            $result[] = $array[ $key ];
667        }
668        return $result;
669    }
670
671    /**
672     * ResourceLoaderGetConfigVars hook handler
673     * This should be used for variables which vary with the html
674     * and for variables this should work cross skin
675     * @see https://www.mediawiki.org/wiki/Manual:Hooks/ResourceLoaderGetConfigVars
676     *
677     * @param array &$vars
678     */
679    public static function onResourceLoaderGetConfigVars( &$vars ) {
680        global $wgCirrusSearchFeedbackLink;
681
682        $vars += [
683            'wgCirrusSearchFeedbackLink' => $wgCirrusSearchFeedbackLink,
684        ];
685    }
686
687    /**
688     * @return SearchConfig
689     */
690    private static function getConfig() {
691        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
692        return MediaWikiServices::getInstance()
693            ->getConfigFactory()
694            ->makeConfig( 'CirrusSearch' );
695    }
696
697    /**
698     * @return Connection
699     */
700    private static function getConnection() {
701        return new Connection( self::getConfig() );
702    }
703
704    /**
705     * Add $wgCirrusSearchInterwikiProv to external results.
706     * @param Title $title
707     * @param mixed &$text
708     * @param mixed $result
709     * @param mixed $terms
710     * @param mixed $page
711     * @param array &$query
712     */
713    public static function onShowSearchHitTitle( Title $title, &$text, $result, $terms, $page, &$query = [] ) {
714        global $wgCirrusSearchInterwikiProv;
715        if ( $wgCirrusSearchInterwikiProv && $title->isExternal() ) {
716            $query["wprov"] = $wgCirrusSearchInterwikiProv;
717        }
718    }
719
720    /**
721     * @param ApiBase $module
722     */
723    public static function onAPIAfterExecute( $module ) {
724        if ( !ElasticsearchIntermediary::hasQueryLogs() ) {
725            return;
726        }
727        $response = $module->getContext()->getRequest()->response();
728        $response->header( 'X-Search-ID: ' . Util::getRequestSetToken() );
729        if ( $module instanceof ApiOpenSearch ) {
730            $types = ElasticsearchIntermediary::getQueryTypesUsed();
731            if ( $types ) {
732                $response->header( 'X-OpenSearch-Type: ' . implode( ',', $types ) );
733            }
734        }
735    }
736
737    /**
738     * @param string $term
739     * @param ISearchResultSet|null $titleMatches
740     * @param ISearchResultSet|null $textMatches
741     */
742    public static function onSpecialSearchResults( $term, $titleMatches, $textMatches ) {
743        global $wgOut,
744            $wgCirrusExploreSimilarResults;
745
746        $wgOut->addModules( 'ext.cirrus.serp' );
747
748        if ( $wgCirrusExploreSimilarResults ) {
749            $wgOut->addModules( 'ext.cirrus.explore-similar' );
750        }
751
752        $jsVars = [
753            'wgCirrusSearchRequestSetToken' => Util::getRequestSetToken(),
754        ];
755        // In theory UserTesting should always have been activated by now, but if
756        // somehow it wasn't we don't want to activate it now at the end of the request
757        // and report incorrect data.
758        if ( UserTestingStatus::hasInstance() ) {
759            $ut = UserTestingStatus::getInstance();
760            if ( $ut->isActive() ) {
761                $trigger = $ut->getTrigger();
762                $jsVars['wgCirrusSearchActiveUserTest'] = $trigger;
763                // bc for first deployment, some users will still have old js.
764                // Should be removed in following deployment.
765                $jsVars['wgCirrusSearchBackendUserTests'] = $trigger ? [ $trigger ] : [];
766            }
767        }
768        $wgOut->addJsConfigVars( $jsVars );
769
770        // This ignores interwiki results for now...not sure what do do with those
771        ElasticsearchIntermediary::setResultPages( [
772            $titleMatches,
773            $textMatches
774        ] );
775    }
776
777    /**
778     * @param array &$extraStats
779     * @return void
780     */
781    private static function addWordCount( array &$extraStats ): void {
782        $search = new CirrusSearch();
783
784        $status = $search->countContentWords();
785        if ( !$status->isOK() ) {
786            return;
787        }
788        $wordCount = $status->getValue();
789        if ( $wordCount !== null ) {
790            $extraStats['cirrussearch-article-words'] = $wordCount;
791        }
792    }
793
794    /** @inheritDoc */
795    public function onGetPreferences( $user, &$prefs ) {
796        $search = new CirrusSearch();
797        $profiles = $search->getProfiles( \SearchEngine::COMPLETION_PROFILE_TYPE, $user );
798        if ( empty( $profiles ) ) {
799            return;
800        }
801        $options = self::autoCompleteOptionsForPreferences( $profiles );
802        if ( !$options ) {
803            return;
804        }
805        $prefs['cirrussearch-pref-completion-profile'] = [
806            'type' => 'radio',
807            'section' => 'searchoptions/completion',
808            'options' => $options,
809            'label-message' => 'cirrussearch-pref-completion-profile-help',
810        ];
811    }
812
813    /**
814     * @param array[] $profiles
815     * @return string[]
816     */
817    private static function autoCompleteOptionsForPreferences( array $profiles ): array {
818        $available = array_column( $profiles, 'name' );
819        // Order in which we propose comp suggest profiles
820        $preferredOrder = [
821            'fuzzy',
822            'fuzzy-subphrases',
823            'strict',
824            'normal',
825            'normal-subphrases',
826            'classic'
827        ];
828        $messages = [];
829        foreach ( $preferredOrder as $name ) {
830            if ( in_array( $name, $available ) ) {
831                $display = wfMessage( "cirrussearch-completion-profile-$name-pref-name" )->escaped() .
832                    new \OOUI\LabelWidget( [
833                        'classes' => [ 'oo-ui-inline-help' ],
834                        'label' => wfMessage( "cirrussearch-completion-profile-$name-pref-desc" )->text()
835                    ] );
836                $messages[$display] = $name;
837            }
838        }
839        // At least 2 choices are required to provide the user a choice
840        return count( $messages ) >= 2 ? $messages : [];
841    }
842
843    /** @inheritDoc */
844    public function onUserGetDefaultOptions( &$defaultOptions ) {
845        $defaultOptions['cirrussearch-pref-completion-profile'] =
846            $this->configFactory->makeConfig( 'CirrusSearch' )->get( 'CirrusSearchCompletionSettings' );
847    }
848
849    /**
850     * Register CirrusSearch services
851     * @param MediaWikiServices $container
852     */
853    public static function onMediaWikiServices( MediaWikiServices $container ) {
854        $container->defineService(
855            InterwikiResolverFactory::SERVICE,
856            [ InterwikiResolverFactory::class, 'newFactory' ]
857        );
858        $container->defineService(
859            InterwikiResolver::SERVICE,
860            static function ( MediaWikiServices $serviceContainer ) {
861                $config = $serviceContainer->getConfigFactory()
862                        ->makeConfig( 'CirrusSearch' );
863                return $serviceContainer
864                    ->getService( InterwikiResolverFactory::SERVICE )
865                    ->getResolver( $config );
866            }
867        );
868        $container->defineService( SearchProfileServiceFactory::SERVICE_NAME,
869            static function ( MediaWikiServices $serviceContainer ) {
870                $config = $serviceContainer->getConfigFactory()
871                    ->makeConfig( 'CirrusSearch' );
872                return new SearchProfileServiceFactory(
873                    $serviceContainer->getService( InterwikiResolver::SERVICE ),
874                    /** @phan-suppress-next-line PhanTypeMismatchArgumentSuperType $config is actually a SearchConfig */
875                    $config,
876                    $serviceContainer->getLocalServerObjectCache(),
877                    new CirrusSearchHookRunner( $serviceContainer->getHookContainer() ),
878                    $serviceContainer->getUserOptionsLookup()
879                );
880            }
881        );
882    }
883
884    /**
885     * When article is undeleted - check the archive for other instances of the title,
886     * if not there - drop it from the archive.
887     * @param Title $title
888     * @param bool $create
889     * @param string $comment
890     * @param string $oldPageId
891     * @param array $restoredPages
892     */
893    public static function onArticleUndelete( Title $title, $create, $comment, $oldPageId, $restoredPages ) {
894        global $wgCirrusSearchIndexDeletes;
895        if ( !$wgCirrusSearchIndexDeletes ) {
896            // Not indexing, thus nothing to remove here.
897            return;
898        }
899        MediaWikiServices::getInstance()->getJobQueueGroup()->push(
900            new Job\DeleteArchive( $title, [ 'docIds' => $restoredPages ] )
901        );
902    }
903
904    public static function onSpecialStatsAddExtra( &$extraStats, $context ) {
905        self::addWordCount( $extraStats );
906    }
907
908    public static function onAPIQuerySiteInfoStatisticsInfo( &$extraStats ) {
909        self::addWordCount( $extraStats );
910    }
911}