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