Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
40.76% covered (danger)
40.76%
161 / 395
0.00% covered (danger)
0.00%
0 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialSearch
40.86% covered (danger)
40.86%
161 / 394
0.00% covered (danger)
0.00%
0 / 21
2474.81
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 execute
27.27% covered (danger)
27.27%
9 / 33
0.00% covered (danger)
0.00%
0 / 1
57.55
 showGoogleSearch
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 load
75.00% covered (warning)
75.00%
33 / 44
0.00% covered (danger)
0.00%
0 / 1
14.25
 goResult
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
8.79
 redirectOnExactMatch
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 showResults
83.21% covered (warning)
83.21%
109 / 131
0.00% covered (danger)
0.00%
0 / 1
30.45
 showCreateLink
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
156
 setupPage
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
12
 isPowerSearch
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 powerSearch
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 powerSearchOptions
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 saveNamespaces
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 getSearchProfiles
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
12
 getSearchEngine
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getProfile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNamespaces
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setExtraParam
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPrefix
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 prevNextLinks
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
56
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Implements Special:Search
4 *
5 * Copyright © 2004 Brooke Vibber <bvibber@wikimedia.org>
6 *
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; either version 2 of the License, or
10 * (at your option) any later version.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU General Public License for more details.
16 *
17 * You should have received a copy of the GNU General Public License along
18 * with this program; if not, write to the Free Software Foundation, Inc.,
19 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 * http://www.gnu.org/copyleft/gpl.html
21 *
22 * @file
23 * @ingroup SpecialPage
24 */
25
26namespace MediaWiki\Specials;
27
28use ISearchResultSet;
29use MediaWiki\Config\ServiceOptions;
30use MediaWiki\Content\IContentHandlerFactory;
31use MediaWiki\Deferred\DeferredUpdates;
32use MediaWiki\Html\Html;
33use MediaWiki\Interwiki\InterwikiLookup;
34use MediaWiki\Languages\LanguageConverterFactory;
35use MediaWiki\MainConfigNames;
36use MediaWiki\Message\Message;
37use MediaWiki\Output\OutputPage;
38use MediaWiki\Request\WebRequest;
39use MediaWiki\Search\SearchResultThumbnailProvider;
40use MediaWiki\Search\SearchWidgets\BasicSearchResultSetWidget;
41use MediaWiki\Search\SearchWidgets\DidYouMeanWidget;
42use MediaWiki\Search\SearchWidgets\FullSearchResultWidget;
43use MediaWiki\Search\SearchWidgets\InterwikiSearchResultSetWidget;
44use MediaWiki\Search\SearchWidgets\InterwikiSearchResultWidget;
45use MediaWiki\Search\SearchWidgets\SearchFormWidget;
46use MediaWiki\Search\TitleMatcher;
47use MediaWiki\SpecialPage\SpecialPage;
48use MediaWiki\Status\Status;
49use MediaWiki\Title\NamespaceInfo;
50use MediaWiki\Title\Title;
51use MediaWiki\User\Options\UserOptionsManager;
52use RepoGroup;
53use SearchEngine;
54use SearchEngineConfig;
55use SearchEngineFactory;
56use Wikimedia\Rdbms\ReadOnlyMode;
57use Xml;
58
59/**
60 * implements Special:Search - Run text & title search and display the output
61 * @ingroup SpecialPage
62 */
63class SpecialSearch extends SpecialPage {
64    /**
65     * Current search profile. Search profile is just a name that identifies
66     * the active search tab on the search page (content, discussions...)
67     * For users tt replaces the set of enabled namespaces from the query
68     * string when applicable. Extensions can add new profiles with hooks
69     * with custom search options just for that profile.
70     * @var null|string
71     */
72    protected $profile;
73
74    /** @var SearchEngine Search engine */
75    protected $searchEngine;
76
77    /** @var string|null Search engine type, if not default */
78    protected $searchEngineType = null;
79
80    /** @var array For links */
81    protected $extraParams = [];
82
83    /**
84     * @var string The prefix url parameter. Set on the searcher and the
85     * is expected to treat it as prefix filter on titles.
86     */
87    protected $mPrefix;
88
89    protected int $limit;
90    protected int $offset;
91
92    /**
93     * @var array
94     */
95    protected $namespaces;
96
97    /**
98     * @var string
99     */
100    protected $fulltext;
101
102    /**
103     * @var string
104     */
105    protected $sort = SearchEngine::DEFAULT_SORT;
106
107    /**
108     * @var bool
109     */
110    protected $runSuggestion = true;
111
112    /**
113     * Search engine configurations.
114     * @var SearchEngineConfig
115     */
116    protected $searchConfig;
117
118    private SearchEngineFactory $searchEngineFactory;
119    private NamespaceInfo $nsInfo;
120    private IContentHandlerFactory $contentHandlerFactory;
121    private InterwikiLookup $interwikiLookup;
122    private ReadOnlyMode $readOnlyMode;
123    private UserOptionsManager $userOptionsManager;
124    private LanguageConverterFactory $languageConverterFactory;
125    private RepoGroup $repoGroup;
126    private SearchResultThumbnailProvider $thumbnailProvider;
127    private TitleMatcher $titleMatcher;
128
129    /**
130     * @var Status Holds any parameter validation errors that should
131     *  be displayed back to the user.
132     */
133    private $loadStatus;
134
135    private const NAMESPACES_CURRENT = 'sense';
136
137    /**
138     * @param SearchEngineConfig $searchConfig
139     * @param SearchEngineFactory $searchEngineFactory
140     * @param NamespaceInfo $nsInfo
141     * @param IContentHandlerFactory $contentHandlerFactory
142     * @param InterwikiLookup $interwikiLookup
143     * @param ReadOnlyMode $readOnlyMode
144     * @param UserOptionsManager $userOptionsManager
145     * @param LanguageConverterFactory $languageConverterFactory
146     * @param RepoGroup $repoGroup
147     * @param SearchResultThumbnailProvider $thumbnailProvider
148     * @param TitleMatcher $titleMatcher
149     */
150    public function __construct(
151        SearchEngineConfig $searchConfig,
152        SearchEngineFactory $searchEngineFactory,
153        NamespaceInfo $nsInfo,
154        IContentHandlerFactory $contentHandlerFactory,
155        InterwikiLookup $interwikiLookup,
156        ReadOnlyMode $readOnlyMode,
157        UserOptionsManager $userOptionsManager,
158        LanguageConverterFactory $languageConverterFactory,
159        RepoGroup $repoGroup,
160        SearchResultThumbnailProvider $thumbnailProvider,
161        TitleMatcher $titleMatcher
162    ) {
163        parent::__construct( 'Search' );
164        $this->searchConfig = $searchConfig;
165        $this->searchEngineFactory = $searchEngineFactory;
166        $this->nsInfo = $nsInfo;
167        $this->contentHandlerFactory = $contentHandlerFactory;
168        $this->interwikiLookup = $interwikiLookup;
169        $this->readOnlyMode = $readOnlyMode;
170        $this->userOptionsManager = $userOptionsManager;
171        $this->languageConverterFactory = $languageConverterFactory;
172        $this->repoGroup = $repoGroup;
173        $this->thumbnailProvider = $thumbnailProvider;
174        $this->titleMatcher = $titleMatcher;
175    }
176
177    /**
178     * Entry point
179     *
180     * @param string|null $par
181     */
182    public function execute( $par ) {
183        $request = $this->getRequest();
184        $out = $this->getOutput();
185
186        // Fetch the search term
187        $term = str_replace( "\n", " ", $request->getText( 'search' ) );
188
189        // Historically search terms have been accepted not only in the search query
190        // parameter, but also as part of the primary url. This can have PII implications
191        // in releasing page view data. As such issue a 301 redirect to the correct
192        // URL.
193        if ( $par !== null && $par !== '' && $term === '' ) {
194            $query = $request->getValues();
195            unset( $query['title'] );
196            // Strip underscores from title parameter; most of the time we'll want
197            // text form here. But don't strip underscores from actual text params!
198            $query['search'] = str_replace( '_', ' ', $par );
199            $out->redirect( $this->getPageTitle()->getFullURL( $query ), 301 );
200            return;
201        }
202
203        // Need to load selected namespaces before handling nsRemember
204        $this->load();
205        // TODO: This performs database actions on GET request, which is going to
206        // be a problem for our multi-datacenter work.
207        if ( $request->getCheck( 'nsRemember' ) ) {
208            $this->saveNamespaces();
209            // Remove the token from the URL to prevent the user from inadvertently
210            // exposing it (e.g. by pasting it into a public wiki page) or undoing
211            // later settings changes (e.g. by reloading the page).
212            $query = $request->getValues();
213            unset( $query['title'], $query['nsRemember'] );
214            $out->redirect( $this->getPageTitle()->getFullURL( $query ) );
215            return;
216        }
217
218        if ( !$request->getVal( 'fulltext' ) && !$request->getCheck( 'offset' ) ) {
219            $url = $this->goResult( $term );
220            if ( $url !== null ) {
221                // successful 'go'
222                $out->redirect( $url );
223                return;
224            }
225            // No match. If it could plausibly be a title
226            // run the No go match hook.
227            $title = Title::newFromText( $term );
228            if ( $title !== null ) {
229                $this->getHookRunner()->onSpecialSearchNogomatch( $title );
230            }
231        }
232
233        $this->setupPage( $term );
234
235        if ( $this->getConfig()->get( MainConfigNames::DisableTextSearch ) ) {
236            $searchForwardUrl = $this->getConfig()->get( MainConfigNames::SearchForwardUrl );
237            if ( $searchForwardUrl ) {
238                $url = str_replace( '$1', urlencode( $term ), $searchForwardUrl );
239                $out->redirect( $url );
240            } else {
241                $out->addHTML( $this->showGoogleSearch( $term ) );
242            }
243
244            return;
245        }
246
247        $this->showResults( $term );
248    }
249
250    /**
251     * Output a google search form if search is disabled
252     *
253     * @param string $term Search term
254     * @todo FIXME Maybe we should get rid of this raw html message at some future time
255     * @return string HTML
256     * @return-taint escaped
257     */
258    private function showGoogleSearch( $term ) {
259        return "<fieldset>" .
260                "<legend>" .
261                    $this->msg( 'search-external' )->escaped() .
262                "</legend>" .
263                "<p class='mw-searchdisabled'>" .
264                    $this->msg( 'searchdisabled' )->escaped() .
265                "</p>" .
266                // googlesearch is part of $wgRawHtmlMessages and safe to use as is here
267                $this->msg( 'googlesearch' )->rawParams(
268                    htmlspecialchars( $term ),
269                    'UTF-8',
270                    $this->msg( 'searchbutton' )->escaped()
271                )->text() .
272            "</fieldset>";
273    }
274
275    /**
276     * Set up basic search parameters from the request and user settings.
277     *
278     * @see tests/phpunit/includes/specials/SpecialSearchTest.php
279     */
280    public function load() {
281        $this->loadStatus = new Status();
282
283        $request = $this->getRequest();
284        $this->searchEngineType = $request->getVal( 'srbackend' );
285
286        [ $this->limit, $this->offset ] = $request->getLimitOffsetForUser(
287            $this->getUser(),
288            20,
289            'searchlimit'
290        );
291        $this->mPrefix = $request->getVal( 'prefix', '' );
292        if ( $this->mPrefix !== '' ) {
293            $this->setExtraParam( 'prefix', $this->mPrefix );
294        }
295
296        $sort = $request->getVal( 'sort', SearchEngine::DEFAULT_SORT );
297        $validSorts = $this->getSearchEngine()->getValidSorts();
298        if ( !in_array( $sort, $validSorts ) ) {
299            $this->loadStatus->warning( 'search-invalid-sort-order', $sort,
300                implode( ', ', $validSorts ) );
301        } elseif ( $sort !== $this->sort ) {
302            $this->sort = $sort;
303            $this->setExtraParam( 'sort', $this->sort );
304        }
305
306        $user = $this->getUser();
307
308        # Extract manually requested namespaces
309        $nslist = $this->powerSearch( $request );
310        if ( $nslist === [] ) {
311            # Fallback to user preference
312            $nslist = $this->searchConfig->userNamespaces( $user );
313        }
314
315        $profile = null;
316        if ( $nslist === [] ) {
317            $profile = 'default';
318        }
319
320        $profile = $request->getVal( 'profile', $profile );
321        $profiles = $this->getSearchProfiles();
322        if ( $profile === null ) {
323            // BC with old request format
324            $profile = 'advanced';
325            foreach ( $profiles as $key => $data ) {
326                if ( $nslist === $data['namespaces'] && $key !== 'advanced' ) {
327                    $profile = $key;
328                }
329            }
330            $this->namespaces = $nslist;
331        } elseif ( $profile === 'advanced' ) {
332            $this->namespaces = $nslist;
333        } elseif ( isset( $profiles[$profile]['namespaces'] ) ) {
334            $this->namespaces = $profiles[$profile]['namespaces'];
335        } else {
336            // Unknown profile requested
337            $this->loadStatus->warning( 'search-unknown-profile', $profile );
338            $profile = 'default';
339            $this->namespaces = $profiles['default']['namespaces'];
340        }
341
342        $this->fulltext = $request->getVal( 'fulltext' );
343        $this->runSuggestion = (bool)$request->getVal( 'runsuggestion', '1' );
344        $this->profile = $profile;
345    }
346
347    /**
348     * If an exact title match can be found, jump straight ahead to it.
349     *
350     * @param string $term
351     * @return string|null The url to redirect to, or null if no redirect.
352     */
353    public function goResult( $term ) {
354        # If the string cannot be used to create a title
355        if ( Title::newFromText( $term ) === null ) {
356            return null;
357        }
358        # If there's an exact or very near match, jump right there.
359        $title = $this->titleMatcher->getNearMatch( $term );
360        if ( $title === null ) {
361            return null;
362        }
363        $url = null;
364        if ( !$this->getHookRunner()->onSpecialSearchGoResult( $term, $title, $url ) ) {
365            return null;
366        }
367
368        if (
369            // If there is a preference set to NOT redirect on exact page match
370            // then return null (which prevents direction)
371            !$this->redirectOnExactMatch()
372            // BUT ...
373            // ... ignore no-redirect preference if the exact page match is an interwiki link
374            && !$title->isExternal()
375            // ... ignore no-redirect preference if the exact page match is NOT in the main
376            // namespace AND there's a namespace in the search string
377            && !( $title->getNamespace() !== NS_MAIN && strpos( $term, ':' ) > 0 )
378        ) {
379            return null;
380        }
381
382        return $url ?? $title->getFullUrlForRedirect();
383    }
384
385    private function redirectOnExactMatch() {
386        if ( !$this->getConfig()->get( MainConfigNames::SearchMatchRedirectPreference ) ) {
387            // If the preference for whether to redirect is disabled, use the default setting
388            return $this->userOptionsManager->getDefaultOption(
389                'search-match-redirect',
390                $this->getUser()
391            );
392        } else {
393            // Otherwise use the user's preference
394            return $this->userOptionsManager->getOption( $this->getUser(), 'search-match-redirect' );
395        }
396    }
397
398    /**
399     * @param string $term
400     */
401    public function showResults( $term ) {
402        if ( $this->searchEngineType !== null ) {
403            $this->setExtraParam( 'srbackend', $this->searchEngineType );
404        }
405
406        $out = $this->getOutput();
407        $widgetOptions = $this->getConfig()->get( MainConfigNames::SpecialSearchFormOptions );
408        $formWidget = new SearchFormWidget(
409            new ServiceOptions(
410                SearchFormWidget::CONSTRUCTOR_OPTIONS,
411                $this->getConfig()
412            ),
413            $this,
414            $this->searchConfig,
415            $this->getHookContainer(),
416            $this->languageConverterFactory->getLanguageConverter( $this->getLanguage() ),
417            $this->nsInfo,
418            $this->getSearchProfiles()
419        );
420        $filePrefix = $this->getContentLanguage()->getFormattedNsText( NS_FILE ) . ':';
421        if ( trim( $term ) === '' || $filePrefix === trim( $term ) ) {
422            // Empty query -- straight view of search form
423            if ( !$this->getHookRunner()->onSpecialSearchResultsPrepend( $this, $out, $term ) ) {
424                # Hook requested termination
425                return;
426            }
427            $out->enableOOUI();
428            // The form also contains the 'Showing results 0 - 20 of 1234' so we can
429            // only do the form render here for the empty $term case. Rendering
430            // the form when a search is provided is repeated below.
431            $out->addHTML( $formWidget->render(
432                $this->profile, $term, 0, 0, $this->offset, $this->isPowerSearch(), $widgetOptions
433            ) );
434            return;
435        }
436
437        $engine = $this->getSearchEngine();
438        $engine->setFeatureData( 'rewrite', $this->runSuggestion );
439        $engine->setLimitOffset( $this->limit, $this->offset );
440        $engine->setNamespaces( $this->namespaces );
441        $engine->setSort( $this->sort );
442        $engine->prefix = $this->mPrefix;
443
444        $this->getHookRunner()->onSpecialSearchSetupEngine( $this, $this->profile, $engine );
445        if ( !$this->getHookRunner()->onSpecialSearchResultsPrepend( $this, $out, $term ) ) {
446            # Hook requested termination
447            return;
448        }
449
450        $title = Title::newFromText( $term );
451        $languageConverter = $this->languageConverterFactory->getLanguageConverter( $this->getContentLanguage() );
452        if ( $languageConverter->hasVariants() ) {
453            // findVariantLink will replace the link arg as well but we want to keep our original
454            // search string, use a copy in the $variantTerm var so that $term remains intact.
455            $variantTerm = $term;
456            $languageConverter->findVariantLink( $variantTerm, $title );
457        }
458
459        $showSuggestion = $title === null || !$title->isKnown();
460        $engine->setShowSuggestion( $showSuggestion );
461
462        $rewritten = $engine->replacePrefixes( $term );
463        if ( $rewritten !== $term ) {
464            wfDeprecatedMsg( 'SearchEngine::replacePrefixes()  was overridden by ' .
465                get_class( $engine ) . ', this is deprecated since MediaWiki 1.32',
466                '1.32', false, false );
467        }
468
469        // fetch search results
470        $titleMatches = $engine->searchTitle( $rewritten );
471        $textMatches = $engine->searchText( $rewritten );
472
473        $textStatus = null;
474        if ( $textMatches instanceof Status ) {
475            $textStatus = $textMatches;
476            $textMatches = $textStatus->getValue();
477        }
478
479        // Get number of results
480        $titleMatchesNum = $textMatchesNum = $numTitleMatches = $numTextMatches = 0;
481        if ( $titleMatches ) {
482            $titleMatchesNum = $titleMatches->numRows();
483            $numTitleMatches = $titleMatches->getTotalHits();
484        }
485        if ( $textMatches ) {
486            $textMatchesNum = $textMatches->numRows();
487            $numTextMatches = $textMatches->getTotalHits();
488            if ( $textMatchesNum > 0 ) {
489                $engine->augmentSearchResults( $textMatches );
490            }
491        }
492        $num = $titleMatchesNum + $textMatchesNum;
493        $totalRes = $numTitleMatches + $numTextMatches;
494
495        // start rendering the page
496        $out->enableOOUI();
497        $out->addHTML( $formWidget->render(
498            $this->profile, $term, $num, $totalRes, $this->offset, $this->isPowerSearch(), $widgetOptions
499        ) );
500
501        // did you mean... suggestions
502        if ( $textMatches ) {
503            $dymWidget = new DidYouMeanWidget( $this );
504            $out->addHTML( $dymWidget->render( $term, $textMatches ) );
505        }
506
507        $hasSearchErrors = $textStatus && $textStatus->getErrors() !== [];
508        $hasInlineIwResults = $textMatches &&
509            $textMatches->hasInterwikiResults( ISearchResultSet::INLINE_RESULTS );
510        $hasSecondaryIwResults = $textMatches &&
511            $textMatches->hasInterwikiResults( ISearchResultSet::SECONDARY_RESULTS );
512
513        $classNames = [ 'searchresults' ];
514        if ( $hasSecondaryIwResults ) {
515            $classNames[] = 'mw-searchresults-has-iw';
516        }
517        if ( $this->offset > 0 ) {
518            $classNames[] = 'mw-searchresults-has-offset';
519        }
520        $out->addHTML( '<div class="' . implode( ' ', $classNames ) . '">' );
521
522        $out->addHTML( '<div class="mw-search-results-info">' );
523
524        if ( $hasSearchErrors || $this->loadStatus->getErrors() ) {
525            if ( $textStatus === null ) {
526                $textStatus = $this->loadStatus;
527            } else {
528                $textStatus->merge( $this->loadStatus );
529            }
530            [ $error, $warning ] = $textStatus->splitByErrorType();
531            if ( $error->getErrors() ) {
532                $out->addHTML( Html::errorBox(
533                    $error->getHTML( 'search-error' )
534                ) );
535            }
536            if ( $warning->getErrors() ) {
537                $out->addHTML( Html::warningBox(
538                    $warning->getHTML( 'search-warning' )
539                ) );
540            }
541        }
542
543        // If we have no results and have not already displayed an error message
544        if ( $num === 0 && !$hasSearchErrors ) {
545            $out->wrapWikiMsg( "<p class=\"mw-search-nonefound\">\n$1</p>", [
546                $hasInlineIwResults ? 'search-nonefound-thiswiki' : 'search-nonefound',
547                wfEscapeWikiText( $term ),
548                $term
549            ] );
550        }
551
552        // Show the create link ahead
553        $this->showCreateLink( $title, $num, $titleMatches, $textMatches );
554
555        $this->getHookRunner()->onSpecialSearchResults( $term, $titleMatches, $textMatches );
556
557        // Close <div class='mw-search-results-info'>
558        $out->addHTML( '</div>' );
559
560        // Although $num might be 0 there can still be secondary or inline
561        // results to display.
562        $linkRenderer = $this->getLinkRenderer();
563        $mainResultWidget = new FullSearchResultWidget(
564            $this,
565            $linkRenderer,
566            $this->getHookContainer(),
567            $this->repoGroup,
568            $this->thumbnailProvider,
569            $this->userOptionsManager
570        );
571
572        $sidebarResultWidget = new InterwikiSearchResultWidget( $this, $linkRenderer );
573        $sidebarResultsWidget = new InterwikiSearchResultSetWidget(
574            $this,
575            $sidebarResultWidget,
576            $linkRenderer,
577            $this->interwikiLookup,
578            $engine->getFeatureData( 'show-multimedia-search-results' )
579        );
580
581        $widget = new BasicSearchResultSetWidget( $this, $mainResultWidget, $sidebarResultsWidget );
582
583        $out->addHTML( '<div class="mw-search-visualclear"></div>' );
584        $this->prevNextLinks( $totalRes, $textMatches, $term, 'mw-search-pager-top', $out );
585
586        $out->addHTML( $widget->render(
587            $term, $this->offset, $titleMatches, $textMatches
588        ) );
589
590        $out->addHTML( '<div class="mw-search-visualclear"></div>' );
591        $this->prevNextLinks( $totalRes, $textMatches, $term, 'mw-search-pager-bottom', $out );
592
593        // Close <div class='searchresults'>
594        $out->addHTML( "</div>" );
595
596        $this->getHookRunner()->onSpecialSearchResultsAppend( $this, $out, $term );
597    }
598
599    /**
600     * @param Title|null $title
601     * @param int $num The number of search results found
602     * @param null|ISearchResultSet $titleMatches Results from title search
603     * @param null|ISearchResultSet $textMatches Results from text search
604     */
605    protected function showCreateLink( $title, $num, $titleMatches, $textMatches ) {
606        // show direct page/create link if applicable
607
608        // Check DBkey !== '' in case of fragment link only.
609        if ( $title === null || $title->getDBkey() === ''
610            || ( $titleMatches !== null && $titleMatches->searchContainedSyntax() )
611            || ( $textMatches !== null && $textMatches->searchContainedSyntax() )
612        ) {
613            // invalid title
614            // preserve the paragraph for margins etc...
615            $this->getOutput()->addHTML( '<p></p>' );
616
617            return;
618        }
619
620        $messageName = 'searchmenu-new-nocreate';
621        $linkClass = 'mw-search-createlink';
622
623        if ( !$title->isExternal() ) {
624            if ( $title->isKnown() ) {
625                $messageName = 'searchmenu-exists';
626                $linkClass = 'mw-search-exists';
627            } elseif (
628                $this->contentHandlerFactory->getContentHandler( $title->getContentModel() )
629                    ->supportsDirectEditing()
630                && $this->getAuthority()->probablyCan( 'edit', $title )
631            ) {
632                $messageName = 'searchmenu-new';
633            }
634        }
635
636        $params = [
637            $messageName,
638            wfEscapeWikiText( $title->getPrefixedText() ),
639            Message::numParam( $num )
640        ];
641        $this->getHookRunner()->onSpecialSearchCreateLink( $title, $params );
642
643        // Extensions using the hook might still return an empty $messageName
644        // @phan-suppress-next-line PhanRedundantCondition Set by hook
645        if ( $messageName ) {
646            $this->getOutput()->wrapWikiMsg( "<p class=\"$linkClass\">\n$1</p>", $params );
647        } else {
648            // preserve the paragraph for margins etc...
649            $this->getOutput()->addHTML( '<p></p>' );
650        }
651    }
652
653    /**
654     * Sets up everything for the HTML output page including styles, javascript,
655     * page title, etc.
656     *
657     * @param string $term
658     */
659    protected function setupPage( $term ) {
660        $out = $this->getOutput();
661
662        $this->setHeaders();
663        $this->outputHeader();
664        // TODO: Is this true? The namespace remember uses a user token
665        // on save.
666        $out->setPreventClickjacking( false );
667        $this->addHelpLink( 'Help:Searching' );
668
669        if ( strval( $term ) !== '' ) {
670            $out->setPageTitleMsg( $this->msg( 'searchresults' ) );
671            $out->setHTMLTitle( $this->msg( 'pagetitle' )
672                ->plaintextParams( $this->msg( 'searchresults-title' )->plaintextParams( $term )->text() )
673                ->inContentLanguage()->text()
674            );
675        }
676
677        if ( $this->mPrefix !== '' ) {
678            $subtitle = $this->msg( 'search-filter-title-prefix' )->plaintextParams( $this->mPrefix );
679            $params = $this->powerSearchOptions();
680            unset( $params['prefix'] );
681            $params += [
682                'search' => $term,
683                'fulltext' => 1,
684            ];
685
686            $subtitle .= ' (';
687            $subtitle .= Xml::element(
688                'a',
689                [
690                    'href' => $this->getPageTitle()->getLocalURL( $params ),
691                    'title' => $this->msg( 'search-filter-title-prefix-reset' )->text(),
692                ],
693                $this->msg( 'search-filter-title-prefix-reset' )->text()
694            );
695            $subtitle .= ')';
696            $out->setSubtitle( $subtitle );
697        }
698
699        $out->addJsConfigVars( [ 'searchTerm' => $term ] );
700        $out->addModules( 'mediawiki.special.search' );
701        $out->addModuleStyles( [
702            'mediawiki.special', 'mediawiki.special.search.styles',
703            'mediawiki.widgets.SearchInputWidget.styles',
704        ] );
705    }
706
707    /**
708     * Return true if current search is a power (advanced) search
709     *
710     * @return bool
711     */
712    protected function isPowerSearch() {
713        return $this->profile === 'advanced';
714    }
715
716    /**
717     * Extract "power search" namespace settings from the request object,
718     * returning a list of index numbers to search.
719     *
720     * @param WebRequest &$request
721     * @return array
722     */
723    protected function powerSearch( &$request ) {
724        $arr = [];
725        foreach ( $this->searchConfig->searchableNamespaces() as $ns => $name ) {
726            if ( $request->getCheck( 'ns' . $ns ) ) {
727                $arr[] = $ns;
728            }
729        }
730
731        return $arr;
732    }
733
734    /**
735     * Reconstruct the 'power search' options for links
736     * TODO: Instead of exposing this publicly, could we instead expose
737     *  a function for creating search links?
738     *
739     * @return array
740     */
741    public function powerSearchOptions() {
742        $opt = [];
743        if ( $this->isPowerSearch() ) {
744            foreach ( $this->namespaces as $n ) {
745                $opt['ns' . $n] = 1;
746            }
747        } else {
748            $opt['profile'] = $this->profile;
749        }
750
751        return $opt + $this->extraParams;
752    }
753
754    /**
755     * Save namespace preferences when we're supposed to
756     *
757     * @return bool Whether we wrote something
758     */
759    protected function saveNamespaces() {
760        $user = $this->getUser();
761        $request = $this->getRequest();
762
763        if ( $user->isRegistered() &&
764            $user->matchEditToken(
765                $request->getVal( 'nsRemember' ),
766                'searchnamespace',
767                $request
768            ) && !$this->readOnlyMode->isReadOnly()
769        ) {
770            // Reset namespace preferences: namespaces are not searched
771            // when they're not mentioned in the URL parameters.
772            foreach ( $this->nsInfo->getValidNamespaces() as $n ) {
773                $this->userOptionsManager->setOption( $user, 'searchNs' . $n, false );
774            }
775            // The request parameters include all the namespaces to be searched.
776            // Even if they're the same as an existing profile, they're not eaten.
777            foreach ( $this->namespaces as $n ) {
778                $this->userOptionsManager->setOption( $user, 'searchNs' . $n, true );
779            }
780
781            DeferredUpdates::addCallableUpdate( static function () use ( $user ) {
782                $user->saveSettings();
783            } );
784
785            return true;
786        }
787
788        return false;
789    }
790
791    /**
792     * @return array[]
793     * @phan-return array<string,array{message:string,tooltip:string,namespaces:int|string|(int|string)[],namespace-messages?:string[]}>
794     */
795    protected function getSearchProfiles() {
796        // Builds list of Search Types (profiles)
797        $nsAllSet = array_keys( $this->searchConfig->searchableNamespaces() );
798        $defaultNs = $this->searchConfig->defaultNamespaces();
799        $profiles = [
800            'default' => [
801                'message' => 'searchprofile-articles',
802                'tooltip' => 'searchprofile-articles-tooltip',
803                'namespaces' => $defaultNs,
804                'namespace-messages' => $this->searchConfig->namespacesAsText(
805                    $defaultNs
806                ),
807            ],
808            'images' => [
809                'message' => 'searchprofile-images',
810                'tooltip' => 'searchprofile-images-tooltip',
811                'namespaces' => [ NS_FILE ],
812            ],
813            'all' => [
814                'message' => 'searchprofile-everything',
815                'tooltip' => 'searchprofile-everything-tooltip',
816                'namespaces' => $nsAllSet,
817            ],
818            'advanced' => [
819                'message' => 'searchprofile-advanced',
820                'tooltip' => 'searchprofile-advanced-tooltip',
821                'namespaces' => self::NAMESPACES_CURRENT,
822            ]
823        ];
824
825        $this->getHookRunner()->onSpecialSearchProfiles( $profiles );
826
827        foreach ( $profiles as &$data ) {
828            if ( !is_array( $data['namespaces'] ) ) {
829                continue;
830            }
831            sort( $data['namespaces'] );
832        }
833
834        return $profiles;
835    }
836
837    /**
838     * @since 1.18
839     *
840     * @return SearchEngine
841     */
842    public function getSearchEngine() {
843        if ( $this->searchEngine === null ) {
844            $this->searchEngine = $this->searchEngineFactory->create( $this->searchEngineType );
845        }
846
847        return $this->searchEngine;
848    }
849
850    /**
851     * Current search profile.
852     * @return null|string
853     */
854    public function getProfile() {
855        return $this->profile;
856    }
857
858    /**
859     * Current namespaces.
860     * @return array
861     */
862    public function getNamespaces() {
863        return $this->namespaces;
864    }
865
866    /**
867     * Users of hook SpecialSearchSetupEngine can use this to
868     * add more params to links to not lose selection when
869     * user navigates search results.
870     * @since 1.18
871     *
872     * @param string $key
873     * @param mixed $value
874     */
875    public function setExtraParam( $key, $value ) {
876        $this->extraParams[$key] = $value;
877    }
878
879    /**
880     * The prefix value send to Special:Search using the 'prefix' URI param
881     * It means that the user is willing to search for pages whose titles start with
882     * this prefix value.
883     * (Used by the InputBox extension)
884     *
885     * @return string
886     */
887    public function getPrefix() {
888        return $this->mPrefix;
889    }
890
891    /**
892     * @param null|int $totalRes
893     * @param null|ISearchResultSet $textMatches
894     * @param string $term
895     * @param string $class
896     * @param OutputPage $out
897     */
898    private function prevNextLinks(
899        ?int $totalRes,
900        ?ISearchResultSet $textMatches,
901        string $term,
902        string $class,
903        OutputPage $out
904    ) {
905        if ( $totalRes > $this->limit || $this->offset ) {
906            // Allow matches to define the correct offset, as interleaved
907            // AB testing may require a different next page offset.
908            if ( $textMatches && $textMatches->getOffset() !== null ) {
909                $offset = $textMatches->getOffset();
910            } else {
911                $offset = $this->offset;
912            }
913
914            // use the rewritten search term for subsequent page searches
915            $newSearchTerm = $term;
916            if ( $textMatches && $textMatches->hasRewrittenQuery() ) {
917                $newSearchTerm = $textMatches->getQueryAfterRewrite();
918            }
919
920            $prevNext =
921                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable offset is not null
922                $this->buildPrevNextNavigation( $offset, $this->limit,
923                    $this->powerSearchOptions() + [ 'search' => $newSearchTerm ],
924                    $this->limit + $this->offset >= $totalRes );
925            $out->addHTML( "<div class='{$class}'>{$prevNext}</div>\n" );
926        }
927    }
928
929    protected function getGroupName() {
930        return 'pages';
931    }
932}
933
934/**
935 * Retain the old class name for backwards compatibility.
936 * @deprecated since 1.41
937 */
938class_alias( SpecialSearch::class, 'SpecialSearch' );