Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
40.76% |
161 / 395 |
|
0.00% |
0 / 21 |
CRAP | |
0.00% |
0 / 1 |
SpecialSearch | |
40.86% |
161 / 394 |
|
0.00% |
0 / 21 |
2474.81 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
27.27% |
9 / 33 |
|
0.00% |
0 / 1 |
57.55 | |||
showGoogleSearch | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
2 | |||
load | |
75.00% |
33 / 44 |
|
0.00% |
0 / 1 |
14.25 | |||
goResult | |
76.92% |
10 / 13 |
|
0.00% |
0 / 1 |
8.79 | |||
redirectOnExactMatch | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
showResults | |
83.21% |
109 / 131 |
|
0.00% |
0 / 1 |
30.45 | |||
showCreateLink | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
156 | |||
setupPage | |
0.00% |
0 / 36 |
|
0.00% |
0 / 1 |
12 | |||
isPowerSearch | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
powerSearch | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
powerSearchOptions | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
saveNamespaces | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
42 | |||
getSearchProfiles | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
12 | |||
getSearchEngine | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getProfile | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getNamespaces | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setExtraParam | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getPrefix | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
prevNextLinks | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
56 | |||
getGroupName | |
0.00% |
0 / 1 |
|
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 | |
26 | namespace MediaWiki\Specials; |
27 | |
28 | use ISearchResultSet; |
29 | use MediaWiki\Config\ServiceOptions; |
30 | use MediaWiki\Content\IContentHandlerFactory; |
31 | use MediaWiki\Deferred\DeferredUpdates; |
32 | use MediaWiki\Html\Html; |
33 | use MediaWiki\Interwiki\InterwikiLookup; |
34 | use MediaWiki\Languages\LanguageConverterFactory; |
35 | use MediaWiki\MainConfigNames; |
36 | use MediaWiki\Message\Message; |
37 | use MediaWiki\Output\OutputPage; |
38 | use MediaWiki\Request\WebRequest; |
39 | use MediaWiki\Search\SearchResultThumbnailProvider; |
40 | use MediaWiki\Search\SearchWidgets\BasicSearchResultSetWidget; |
41 | use MediaWiki\Search\SearchWidgets\DidYouMeanWidget; |
42 | use MediaWiki\Search\SearchWidgets\FullSearchResultWidget; |
43 | use MediaWiki\Search\SearchWidgets\InterwikiSearchResultSetWidget; |
44 | use MediaWiki\Search\SearchWidgets\InterwikiSearchResultWidget; |
45 | use MediaWiki\Search\SearchWidgets\SearchFormWidget; |
46 | use MediaWiki\Search\TitleMatcher; |
47 | use MediaWiki\SpecialPage\SpecialPage; |
48 | use MediaWiki\Status\Status; |
49 | use MediaWiki\Title\NamespaceInfo; |
50 | use MediaWiki\Title\Title; |
51 | use MediaWiki\User\Options\UserOptionsManager; |
52 | use RepoGroup; |
53 | use SearchEngine; |
54 | use SearchEngineConfig; |
55 | use SearchEngineFactory; |
56 | use Wikimedia\Rdbms\ReadOnlyMode; |
57 | use Xml; |
58 | |
59 | /** |
60 | * implements Special:Search - Run text & title search and display the output |
61 | * @ingroup SpecialPage |
62 | */ |
63 | class 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 | */ |
938 | class_alias( SpecialSearch::class, 'SpecialSearch' ); |