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