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 | * 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 | |
23 | namespace MediaWiki\Specials; |
24 | |
25 | use ISearchResultSet; |
26 | use MediaWiki\Config\ServiceOptions; |
27 | use MediaWiki\Content\IContentHandlerFactory; |
28 | use MediaWiki\Deferred\DeferredUpdates; |
29 | use MediaWiki\Html\Html; |
30 | use MediaWiki\Interwiki\InterwikiLookup; |
31 | use MediaWiki\Languages\LanguageConverterFactory; |
32 | use MediaWiki\MainConfigNames; |
33 | use MediaWiki\Message\Message; |
34 | use MediaWiki\Output\OutputPage; |
35 | use MediaWiki\Request\WebRequest; |
36 | use MediaWiki\Search\SearchResultThumbnailProvider; |
37 | use MediaWiki\Search\SearchWidgets\BasicSearchResultSetWidget; |
38 | use MediaWiki\Search\SearchWidgets\DidYouMeanWidget; |
39 | use MediaWiki\Search\SearchWidgets\FullSearchResultWidget; |
40 | use MediaWiki\Search\SearchWidgets\InterwikiSearchResultSetWidget; |
41 | use MediaWiki\Search\SearchWidgets\InterwikiSearchResultWidget; |
42 | use MediaWiki\Search\SearchWidgets\SearchFormWidget; |
43 | use MediaWiki\Search\TitleMatcher; |
44 | use MediaWiki\SpecialPage\SpecialPage; |
45 | use MediaWiki\Status\Status; |
46 | use MediaWiki\Title\NamespaceInfo; |
47 | use MediaWiki\Title\Title; |
48 | use MediaWiki\User\Options\UserOptionsManager; |
49 | use MediaWiki\Xml\Xml; |
50 | use RepoGroup; |
51 | use SearchEngine; |
52 | use SearchEngineConfig; |
53 | use SearchEngineFactory; |
54 | use Wikimedia\Rdbms\ReadOnlyMode; |
55 | |
56 | /** |
57 | * Run text & title search and display the output |
58 | * |
59 | * @ingroup SpecialPage |
60 | * @ingroup Search |
61 | */ |
62 | class 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->getQueryValues(); |
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->getQueryValues(); |
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->getMetadata()->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 | */ |
937 | class_alias( SpecialSearch::class, 'SpecialSearch' ); |