Convert Special:Search input to OOUI
[lhc/web/wiklou.git] / includes / specials / SpecialSearch.php
1 <?php
2 /**
3 * Implements Special:Search
4 *
5 * Copyright © 2004 Brion Vibber <brion@pobox.com>
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 /**
27 * implements Special:Search - Run text & title search and display the output
28 * @ingroup SpecialPage
29 */
30 class SpecialSearch extends SpecialPage {
31 /**
32 * Current search profile. Search profile is just a name that identifies
33 * the active search tab on the search page (content, discussions...)
34 * For users tt replaces the set of enabled namespaces from the query
35 * string when applicable. Extensions can add new profiles with hooks
36 * with custom search options just for that profile.
37 * @var null|string
38 */
39 protected $profile;
40
41 /** @var SearchEngine Search engine */
42 protected $searchEngine;
43
44 /** @var string Search engine type, if not default */
45 protected $searchEngineType;
46
47 /** @var array For links */
48 protected $extraParams = [];
49
50 /**
51 * @var string The prefix url parameter. Set on the searcher and the
52 * is expected to treat it as prefix filter on titles.
53 */
54 protected $mPrefix;
55
56 /**
57 * @var int
58 */
59 protected $limit, $offset;
60
61 /**
62 * @var array
63 */
64 protected $namespaces;
65
66 /**
67 * @var string
68 */
69 protected $fulltext;
70
71 /**
72 * @var bool
73 */
74 protected $runSuggestion = true;
75
76 /**
77 * Names of the wikis, in format: Interwiki prefix -> caption
78 * @var array
79 */
80 protected $customCaptions;
81
82 const NAMESPACES_CURRENT = 'sense';
83
84 public function __construct() {
85 parent::__construct( 'Search' );
86 }
87
88 /**
89 * Entry point
90 *
91 * @param string $par
92 */
93 public function execute( $par ) {
94 $this->setHeaders();
95 $this->outputHeader();
96 $out = $this->getOutput();
97 $out->allowClickjacking();
98 $out->addModuleStyles( [
99 'mediawiki.special', 'mediawiki.special.search', 'mediawiki.ui', 'mediawiki.ui.button',
100 'mediawiki.ui.input', 'mediawiki.widgets.SearchInputWidget.styles',
101 ] );
102 $this->addHelpLink( 'Help:Searching' );
103
104 // Strip underscores from title parameter; most of the time we'll want
105 // text form here. But don't strip underscores from actual text params!
106 $titleParam = str_replace( '_', ' ', $par );
107
108 $request = $this->getRequest();
109
110 // Fetch the search term
111 $search = str_replace( "\n", " ", $request->getText( 'search', $titleParam ) );
112
113 $this->load();
114 if ( !is_null( $request->getVal( 'nsRemember' ) ) ) {
115 $this->saveNamespaces();
116 // Remove the token from the URL to prevent the user from inadvertently
117 // exposing it (e.g. by pasting it into a public wiki page) or undoing
118 // later settings changes (e.g. by reloading the page).
119 $query = $request->getValues();
120 unset( $query['title'], $query['nsRemember'] );
121 $out->redirect( $this->getPageTitle()->getFullURL( $query ) );
122 return;
123 }
124
125 $out->addJsConfigVars( [ 'searchTerm' => $search ] );
126 $this->searchEngineType = $request->getVal( 'srbackend' );
127
128 if ( $request->getVal( 'fulltext' )
129 || !is_null( $request->getVal( 'offset' ) )
130 ) {
131 $this->showResults( $search );
132 } else {
133 $this->goResult( $search );
134 }
135 }
136
137 /**
138 * Set up basic search parameters from the request and user settings.
139 *
140 * @see tests/phpunit/includes/specials/SpecialSearchTest.php
141 */
142 public function load() {
143 $request = $this->getRequest();
144 list( $this->limit, $this->offset ) = $request->getLimitOffset( 20, '' );
145 $this->mPrefix = $request->getVal( 'prefix', '' );
146
147 $user = $this->getUser();
148
149 # Extract manually requested namespaces
150 $nslist = $this->powerSearch( $request );
151 if ( !count( $nslist ) ) {
152 # Fallback to user preference
153 $nslist = SearchEngine::userNamespaces( $user );
154 }
155
156 $profile = null;
157 if ( !count( $nslist ) ) {
158 $profile = 'default';
159 }
160
161 $profile = $request->getVal( 'profile', $profile );
162 $profiles = $this->getSearchProfiles();
163 if ( $profile === null ) {
164 // BC with old request format
165 $profile = 'advanced';
166 foreach ( $profiles as $key => $data ) {
167 if ( $nslist === $data['namespaces'] && $key !== 'advanced' ) {
168 $profile = $key;
169 }
170 }
171 $this->namespaces = $nslist;
172 } elseif ( $profile === 'advanced' ) {
173 $this->namespaces = $nslist;
174 } else {
175 if ( isset( $profiles[$profile]['namespaces'] ) ) {
176 $this->namespaces = $profiles[$profile]['namespaces'];
177 } else {
178 // Unknown profile requested
179 $profile = 'default';
180 $this->namespaces = $profiles['default']['namespaces'];
181 }
182 }
183
184 $this->fulltext = $request->getVal( 'fulltext' );
185 $this->runSuggestion = (bool)$request->getVal( 'runsuggestion', true );
186 $this->profile = $profile;
187 }
188
189 /**
190 * If an exact title match can be found, jump straight ahead to it.
191 *
192 * @param string $term
193 */
194 public function goResult( $term ) {
195 $this->setupPage( $term );
196 # Try to go to page as entered.
197 $title = Title::newFromText( $term );
198 # If the string cannot be used to create a title
199 if ( is_null( $title ) ) {
200 $this->showResults( $term );
201
202 return;
203 }
204 # If there's an exact or very near match, jump right there.
205 $title = SearchEngine::getNearMatch( $term );
206
207 if ( !is_null( $title ) &&
208 Hooks::run( 'SpecialSearchGoResult', [ $term, $title, &$url ] )
209 ) {
210 if ( $url === null ) {
211 $url = $title->getFullURL();
212 }
213 $this->getOutput()->redirect( $url );
214
215 return;
216 }
217 # No match, generate an edit URL
218 $title = Title::newFromText( $term );
219 if ( !is_null( $title ) ) {
220 Hooks::run( 'SpecialSearchNogomatch', [ &$title ] );
221 }
222 $this->showResults( $term );
223 }
224
225 /**
226 * @param string $term
227 */
228 public function showResults( $term ) {
229 global $wgContLang;
230
231 $search = $this->getSearchEngine();
232 $search->setFeatureData( 'rewrite', $this->runSuggestion );
233 $search->setLimitOffset( $this->limit, $this->offset );
234 $search->setNamespaces( $this->namespaces );
235 $search->prefix = $this->mPrefix;
236 $term = $search->transformSearchTerm( $term );
237
238 Hooks::run( 'SpecialSearchSetupEngine', [ $this, $this->profile, $search ] );
239
240 $this->setupPage( $term );
241
242 $out = $this->getOutput();
243
244 if ( $this->getConfig()->get( 'DisableTextSearch' ) ) {
245 $searchFowardUrl = $this->getConfig()->get( 'SearchForwardUrl' );
246 if ( $searchFowardUrl ) {
247 $url = str_replace( '$1', urlencode( $term ), $searchFowardUrl );
248 $out->redirect( $url );
249 } else {
250 $out->addHTML(
251 Xml::openElement( 'fieldset' ) .
252 Xml::element( 'legend', null, $this->msg( 'search-external' )->text() ) .
253 Xml::element(
254 'p',
255 [ 'class' => 'mw-searchdisabled' ],
256 $this->msg( 'searchdisabled' )->text()
257 ) .
258 $this->msg( 'googlesearch' )->rawParams(
259 htmlspecialchars( $term ),
260 'UTF-8',
261 $this->msg( 'searchbutton' )->escaped()
262 )->text() .
263 Xml::closeElement( 'fieldset' )
264 );
265 }
266
267 return;
268 }
269
270 $title = Title::newFromText( $term );
271 $showSuggestion = $title === null || !$title->isKnown();
272 $search->setShowSuggestion( $showSuggestion );
273
274 // fetch search results
275 $rewritten = $search->replacePrefixes( $term );
276
277 $titleMatches = $search->searchTitle( $rewritten );
278 $textMatches = $search->searchText( $rewritten );
279
280 $textStatus = null;
281 if ( $textMatches instanceof Status ) {
282 $textStatus = $textMatches;
283 $textMatches = null;
284 }
285
286 // did you mean... suggestions
287 $didYouMeanHtml = '';
288 if ( $showSuggestion && $textMatches && !$textStatus ) {
289 if ( $textMatches->hasRewrittenQuery() ) {
290 $didYouMeanHtml = $this->getDidYouMeanRewrittenHtml( $term, $textMatches );
291 } elseif ( $textMatches->hasSuggestion() ) {
292 $didYouMeanHtml = $this->getDidYouMeanHtml( $textMatches );
293 }
294 }
295
296 if ( !Hooks::run( 'SpecialSearchResultsPrepend', [ $this, $out, $term ] ) ) {
297 # Hook requested termination
298 return;
299 }
300
301 // start rendering the page
302 $out->addHTML(
303 Xml::openElement(
304 'form',
305 [
306 'id' => ( $this->isPowerSearch() ? 'powersearch' : 'search' ),
307 'method' => 'get',
308 'action' => wfScript(),
309 ]
310 )
311 );
312
313 // Get number of results
314 $titleMatchesNum = $textMatchesNum = $numTitleMatches = $numTextMatches = 0;
315 if ( $titleMatches ) {
316 $titleMatchesNum = $titleMatches->numRows();
317 $numTitleMatches = $titleMatches->getTotalHits();
318 }
319 if ( $textMatches ) {
320 $textMatchesNum = $textMatches->numRows();
321 $numTextMatches = $textMatches->getTotalHits();
322 }
323 $num = $titleMatchesNum + $textMatchesNum;
324 $totalRes = $numTitleMatches + $numTextMatches;
325
326 $out->enableOOUI();
327 $out->addHTML(
328 # This is an awful awful ID name. It's not a table, but we
329 # named it poorly from when this was a table so now we're
330 # stuck with it
331 Xml::openElement( 'div', [ 'id' => 'mw-search-top-table' ] ) .
332 $this->shortDialog( $term, $num, $totalRes ) .
333 Xml::closeElement( 'div' ) .
334 $this->searchProfileTabs( $term ) .
335 $this->searchOptions( $term ) .
336 Xml::closeElement( 'form' ) .
337 $didYouMeanHtml
338 );
339
340 $filePrefix = $wgContLang->getFormattedNsText( NS_FILE ) . ':';
341 if ( trim( $term ) === '' || $filePrefix === trim( $term ) ) {
342 // Empty query -- straight view of search form
343 return;
344 }
345
346 $out->addHTML( "<div class='searchresults'>" );
347
348 // prev/next links
349 $prevnext = null;
350 if ( $num || $this->offset ) {
351 // Show the create link ahead
352 $this->showCreateLink( $title, $num, $titleMatches, $textMatches );
353 if ( $totalRes > $this->limit || $this->offset ) {
354 if ( $this->searchEngineType !== null ) {
355 $this->setExtraParam( 'srbackend', $this->searchEngineType );
356 }
357 $prevnext = $this->getLanguage()->viewPrevNext(
358 $this->getPageTitle(),
359 $this->offset,
360 $this->limit,
361 $this->powerSearchOptions() + [ 'search' => $term ],
362 $this->limit + $this->offset >= $totalRes
363 );
364 }
365 }
366 Hooks::run( 'SpecialSearchResults', [ $term, &$titleMatches, &$textMatches ] );
367
368 $out->parserOptions()->setEditSection( false );
369 if ( $titleMatches ) {
370 if ( $numTitleMatches > 0 ) {
371 $out->wrapWikiMsg( "==$1==\n", 'titlematches' );
372 $out->addHTML( $this->showMatches( $titleMatches ) );
373 }
374 $titleMatches->free();
375 }
376 if ( $textMatches && !$textStatus ) {
377 // output appropriate heading
378 if ( $numTextMatches > 0 && $numTitleMatches > 0 ) {
379 // if no title matches the heading is redundant
380 $out->wrapWikiMsg( "==$1==\n", 'textmatches' );
381 }
382
383 // show results
384 if ( $numTextMatches > 0 ) {
385 $out->addHTML( $this->showMatches( $textMatches ) );
386 }
387
388 // show secondary interwiki results if any
389 if ( $textMatches->hasInterwikiResults( SearchResultSet::SECONDARY_RESULTS ) ) {
390 $out->addHTML( $this->showInterwiki( $textMatches->getInterwikiResults(
391 SearchResultSet::SECONDARY_RESULTS ), $term ) );
392 }
393 }
394
395 $hasOtherResults = $textMatches &&
396 $textMatches->hasInterwikiResults( SearchResultSet::INLINE_RESULTS );
397
398 if ( $num === 0 ) {
399 if ( $textStatus ) {
400 $out->addHTML( '<div class="error">' .
401 $textStatus->getMessage( 'search-error' ) . '</div>' );
402 } else {
403 $this->showCreateLink( $title, $num, $titleMatches, $textMatches );
404 $out->wrapWikiMsg( "<p class=\"mw-search-nonefound\">\n$1</p>",
405 [ $hasOtherResults ? 'search-nonefound-thiswiki' : 'search-nonefound',
406 wfEscapeWikiText( $term )
407 ] );
408 }
409 }
410
411 if ( $hasOtherResults ) {
412 foreach ( $textMatches->getInterwikiResults( SearchResultSet::INLINE_RESULTS )
413 as $interwiki => $interwikiResult ) {
414 if ( $interwikiResult instanceof Status || $interwikiResult->numRows() == 0 ) {
415 // ignore bad interwikis for now
416 continue;
417 }
418 // TODO: wiki header
419 $out->addHTML( $this->showMatches( $interwikiResult, $interwiki ) );
420 }
421 }
422
423 if ( $textMatches ) {
424 $textMatches->free();
425 }
426
427 $out->addHTML( '<div class="visualClear"></div>' );
428
429 if ( $prevnext ) {
430 $out->addHTML( "<p class='mw-search-pager-bottom'>{$prevnext}</p>\n" );
431 }
432
433 $out->addHTML( "</div>" );
434
435 Hooks::run( 'SpecialSearchResultsAppend', [ $this, $out, $term ] );
436
437 }
438
439 /**
440 * Produce wiki header for interwiki results
441 * @param string $interwiki Interwiki name
442 * @param SearchResultSet $interwikiResult The result set
443 * @return string
444 */
445 protected function interwikiHeader( $interwiki, $interwikiResult ) {
446 // TODO: we need to figure out how to name wikis correctly
447 $wikiMsg = $this->msg( 'search-interwiki-results-' . $interwiki )->parse();
448 return "<p class=\"mw-search-interwiki-header\">\n$wikiMsg</p>";
449 }
450
451 /**
452 * Decide if the suggested query should be run, and it's results returned
453 * instead of the provided $textMatches
454 *
455 * @param SearchResultSet $textMatches The results of a users query
456 * @return bool
457 */
458 protected function shouldRunSuggestedQuery( SearchResultSet $textMatches ) {
459 if ( !$this->runSuggestion ||
460 !$textMatches->hasSuggestion() ||
461 $textMatches->numRows() > 0 ||
462 $textMatches->searchContainedSyntax()
463 ) {
464 return false;
465 }
466
467 return $this->getConfig()->get( 'SearchRunSuggestedQuery' );
468 }
469
470 /**
471 * Generates HTML shown to the user when we have a suggestion about a query
472 * that might give more results than their current query.
473 */
474 protected function getDidYouMeanHtml( SearchResultSet $textMatches ) {
475 # mirror Go/Search behavior of original request ..
476 $params = [ 'search' => $textMatches->getSuggestionQuery() ];
477 if ( $this->fulltext != null ) {
478 $params['fulltext'] = $this->fulltext;
479 }
480 $stParams = array_merge( $params, $this->powerSearchOptions() );
481
482 $suggest = Linker::linkKnown(
483 $this->getPageTitle(),
484 $textMatches->getSuggestionSnippet() ?: null,
485 [ 'id' => 'mw-search-DYM-suggestion' ],
486 $stParams
487 );
488
489 # HTML of did you mean... search suggestion link
490 return Html::rawElement(
491 'div',
492 [ 'class' => 'searchdidyoumean' ],
493 $this->msg( 'search-suggest' )->rawParams( $suggest )->parse()
494 );
495 }
496
497 /**
498 * Generates HTML shown to user when their query has been internally rewritten,
499 * and the results of the rewritten query are being returned.
500 *
501 * @param string $term The users search input
502 * @param SearchResultSet $textMatches The response to the users initial search request
503 * @return string HTML linking the user to their original $term query, and the one
504 * suggested by $textMatches.
505 */
506 protected function getDidYouMeanRewrittenHtml( $term, SearchResultSet $textMatches ) {
507 // Showing results for '$rewritten'
508 // Search instead for '$orig'
509
510 $params = [ 'search' => $textMatches->getQueryAfterRewrite() ];
511 if ( $this->fulltext != null ) {
512 $params['fulltext'] = $this->fulltext;
513 }
514 $stParams = array_merge( $params, $this->powerSearchOptions() );
515
516 $rewritten = Linker::linkKnown(
517 $this->getPageTitle(),
518 $textMatches->getQueryAfterRewriteSnippet() ?: null,
519 [ 'id' => 'mw-search-DYM-rewritten' ],
520 $stParams
521 );
522
523 $stParams['search'] = $term;
524 $stParams['runsuggestion'] = 0;
525 $original = Linker::linkKnown(
526 $this->getPageTitle(),
527 htmlspecialchars( $term ),
528 [ 'id' => 'mw-search-DYM-original' ],
529 $stParams
530 );
531
532 return Html::rawElement(
533 'div',
534 [ 'class' => 'searchdidyoumean' ],
535 $this->msg( 'search-rewritten' )->rawParams( $rewritten, $original )->escaped()
536 );
537 }
538
539 /**
540 * @param Title $title
541 * @param int $num The number of search results found
542 * @param null|SearchResultSet $titleMatches Results from title search
543 * @param null|SearchResultSet $textMatches Results from text search
544 */
545 protected function showCreateLink( $title, $num, $titleMatches, $textMatches ) {
546 // show direct page/create link if applicable
547
548 // Check DBkey !== '' in case of fragment link only.
549 if ( is_null( $title ) || $title->getDBkey() === ''
550 || ( $titleMatches !== null && $titleMatches->searchContainedSyntax() )
551 || ( $textMatches !== null && $textMatches->searchContainedSyntax() )
552 ) {
553 // invalid title
554 // preserve the paragraph for margins etc...
555 $this->getOutput()->addHTML( '<p></p>' );
556
557 return;
558 }
559
560 $messageName = 'searchmenu-new-nocreate';
561 $linkClass = 'mw-search-createlink';
562
563 if ( !$title->isExternal() ) {
564 if ( $title->isKnown() ) {
565 $messageName = 'searchmenu-exists';
566 $linkClass = 'mw-search-exists';
567 } elseif ( $title->quickUserCan( 'create', $this->getUser() ) ) {
568 $messageName = 'searchmenu-new';
569 }
570 }
571
572 $params = [
573 $messageName,
574 wfEscapeWikiText( $title->getPrefixedText() ),
575 Message::numParam( $num )
576 ];
577 Hooks::run( 'SpecialSearchCreateLink', [ $title, &$params ] );
578
579 // Extensions using the hook might still return an empty $messageName
580 if ( $messageName ) {
581 $this->getOutput()->wrapWikiMsg( "<p class=\"$linkClass\">\n$1</p>", $params );
582 } else {
583 // preserve the paragraph for margins etc...
584 $this->getOutput()->addHTML( '<p></p>' );
585 }
586 }
587
588 /**
589 * @param string $term
590 */
591 protected function setupPage( $term ) {
592 $out = $this->getOutput();
593 if ( strval( $term ) !== '' ) {
594 $out->setPageTitle( $this->msg( 'searchresults' ) );
595 $out->setHTMLTitle( $this->msg( 'pagetitle' )
596 ->rawParams( $this->msg( 'searchresults-title' )->rawParams( $term )->text() )
597 ->inContentLanguage()->text()
598 );
599 }
600 // add javascript specific to special:search
601 $out->addModules( 'mediawiki.special.search' );
602 }
603
604 /**
605 * Return true if current search is a power (advanced) search
606 *
607 * @return bool
608 */
609 protected function isPowerSearch() {
610 return $this->profile === 'advanced';
611 }
612
613 /**
614 * Extract "power search" namespace settings from the request object,
615 * returning a list of index numbers to search.
616 *
617 * @param WebRequest $request
618 * @return array
619 */
620 protected function powerSearch( &$request ) {
621 $arr = [];
622 foreach ( SearchEngine::searchableNamespaces() as $ns => $name ) {
623 if ( $request->getCheck( 'ns' . $ns ) ) {
624 $arr[] = $ns;
625 }
626 }
627
628 return $arr;
629 }
630
631 /**
632 * Reconstruct the 'power search' options for links
633 *
634 * @return array
635 */
636 protected function powerSearchOptions() {
637 $opt = [];
638 if ( !$this->isPowerSearch() ) {
639 $opt['profile'] = $this->profile;
640 } else {
641 foreach ( $this->namespaces as $n ) {
642 $opt['ns' . $n] = 1;
643 }
644 }
645
646 return $opt + $this->extraParams;
647 }
648
649 /**
650 * Save namespace preferences when we're supposed to
651 *
652 * @return bool Whether we wrote something
653 */
654 protected function saveNamespaces() {
655 $user = $this->getUser();
656 $request = $this->getRequest();
657
658 if ( $user->isLoggedIn() &&
659 $user->matchEditToken(
660 $request->getVal( 'nsRemember' ),
661 'searchnamespace',
662 $request
663 ) && !wfReadOnly()
664 ) {
665 // Reset namespace preferences: namespaces are not searched
666 // when they're not mentioned in the URL parameters.
667 foreach ( MWNamespace::getValidNamespaces() as $n ) {
668 $user->setOption( 'searchNs' . $n, false );
669 }
670 // The request parameters include all the namespaces to be searched.
671 // Even if they're the same as an existing profile, they're not eaten.
672 foreach ( $this->namespaces as $n ) {
673 $user->setOption( 'searchNs' . $n, true );
674 }
675
676 $user->saveSettings();
677 return true;
678 }
679
680 return false;
681 }
682
683 /**
684 * Show whole set of results
685 *
686 * @param SearchResultSet $matches
687 * @param string $interwiki Interwiki name
688 *
689 * @return string
690 */
691 protected function showMatches( &$matches, $interwiki = null ) {
692 global $wgContLang;
693
694 $terms = $wgContLang->convertForSearchResult( $matches->termMatches() );
695 $out = '';
696 $result = $matches->next();
697 $pos = $this->offset;
698
699 if ( $result && $interwiki ) {
700 $out .= $this->interwikiHeader( $interwiki, $result );
701 }
702
703 $out .= "<ul class='mw-search-results'>\n";
704 while ( $result ) {
705 $out .= $this->showHit( $result, $terms, ++$pos );
706 $result = $matches->next();
707 }
708 $out .= "</ul>\n";
709
710 // convert the whole thing to desired language variant
711 $out = $wgContLang->convert( $out );
712
713 return $out;
714 }
715
716 /**
717 * Format a single hit result
718 *
719 * @param SearchResult $result
720 * @param array $terms Terms to highlight
721 * @param int $position Position within the search results, including offset.
722 *
723 * @return string
724 */
725 protected function showHit( $result, $terms, $position ) {
726
727 if ( $result->isBrokenTitle() ) {
728 return '';
729 }
730
731 $title = $result->getTitle();
732
733 $titleSnippet = $result->getTitleSnippet();
734
735 if ( $titleSnippet == '' ) {
736 $titleSnippet = null;
737 }
738
739 $link_t = clone $title;
740 $query = [];
741
742 Hooks::run( 'ShowSearchHitTitle',
743 [ &$link_t, &$titleSnippet, $result, $terms, $this, &$query ] );
744
745 $link = Linker::linkKnown(
746 $link_t,
747 $titleSnippet,
748 [ 'data-serp-pos' => $position ], // HTML attributes
749 $query
750 );
751
752 // If page content is not readable, just return the title.
753 // This is not quite safe, but better than showing excerpts from non-readable pages
754 // Note that hiding the entry entirely would screw up paging.
755 if ( !$title->userCan( 'read', $this->getUser() ) ) {
756 return "<li>{$link}</li>\n";
757 }
758
759 // If the page doesn't *exist*... our search index is out of date.
760 // The least confusing at this point is to drop the result.
761 // You may get less results, but... oh well. :P
762 if ( $result->isMissingRevision() ) {
763 return '';
764 }
765
766 // format redirects / relevant sections
767 $redirectTitle = $result->getRedirectTitle();
768 $redirectText = $result->getRedirectSnippet();
769 $sectionTitle = $result->getSectionTitle();
770 $sectionText = $result->getSectionSnippet();
771 $categorySnippet = $result->getCategorySnippet();
772
773 $redirect = '';
774 if ( !is_null( $redirectTitle ) ) {
775 if ( $redirectText == '' ) {
776 $redirectText = null;
777 }
778
779 $redirect = "<span class='searchalttitle'>" .
780 $this->msg( 'search-redirect' )->rawParams(
781 Linker::linkKnown( $redirectTitle, $redirectText ) )->text() .
782 "</span>";
783 }
784
785 $section = '';
786 if ( !is_null( $sectionTitle ) ) {
787 if ( $sectionText == '' ) {
788 $sectionText = null;
789 }
790
791 $section = "<span class='searchalttitle'>" .
792 $this->msg( 'search-section' )->rawParams(
793 Linker::linkKnown( $sectionTitle, $sectionText ) )->text() .
794 "</span>";
795 }
796
797 $category = '';
798 if ( $categorySnippet ) {
799 $category = "<span class='searchalttitle'>" .
800 $this->msg( 'search-category' )->rawParams( $categorySnippet )->text() .
801 "</span>";
802 }
803
804 // format text extract
805 $extract = "<div class='searchresult'>" . $result->getTextSnippet( $terms ) . "</div>";
806
807 $lang = $this->getLanguage();
808
809 // format description
810 $byteSize = $result->getByteSize();
811 $wordCount = $result->getWordCount();
812 $timestamp = $result->getTimestamp();
813 $size = $this->msg( 'search-result-size', $lang->formatSize( $byteSize ) )
814 ->numParams( $wordCount )->escaped();
815
816 if ( $title->getNamespace() == NS_CATEGORY ) {
817 $cat = Category::newFromTitle( $title );
818 $size = $this->msg( 'search-result-category-size' )
819 ->numParams( $cat->getPageCount(), $cat->getSubcatCount(), $cat->getFileCount() )
820 ->escaped();
821 }
822
823 $date = $lang->userTimeAndDate( $timestamp, $this->getUser() );
824
825 $fileMatch = '';
826 // Include a thumbnail for media files...
827 if ( $title->getNamespace() == NS_FILE ) {
828 $img = $result->getFile();
829 $img = $img ?: wfFindFile( $title );
830 if ( $result->isFileMatch() ) {
831 $fileMatch = "<span class='searchalttitle'>" .
832 $this->msg( 'search-file-match' )->escaped() . "</span>";
833 }
834 if ( $img ) {
835 $thumb = $img->transform( [ 'width' => 120, 'height' => 120 ] );
836 if ( $thumb ) {
837 $desc = $this->msg( 'parentheses' )->rawParams( $img->getShortDesc() )->escaped();
838 // Float doesn't seem to interact well with the bullets.
839 // Table messes up vertical alignment of the bullets.
840 // Bullets are therefore disabled (didn't look great anyway).
841 return "<li>" .
842 '<table class="searchResultImage">' .
843 '<tr>' .
844 '<td style="width: 120px; text-align: center; vertical-align: top;">' .
845 $thumb->toHtml( [ 'desc-link' => true ] ) .
846 '</td>' .
847 '<td style="vertical-align: top;">' .
848 "{$link} {$redirect} {$category} {$section} {$fileMatch}" .
849 $extract .
850 "<div class='mw-search-result-data'>{$desc} - {$date}</div>" .
851 '</td>' .
852 '</tr>' .
853 '</table>' .
854 "</li>\n";
855 }
856 }
857 }
858
859 $html = null;
860
861 $score = '';
862 $related = '';
863 if ( Hooks::run( 'ShowSearchHit', [
864 $this, $result, $terms,
865 &$link, &$redirect, &$section, &$extract,
866 &$score, &$size, &$date, &$related,
867 &$html
868 ] ) ) {
869 $html = "<li><div class='mw-search-result-heading'>" .
870 "{$link} {$redirect} {$category} {$section} {$fileMatch}</div> {$extract}\n" .
871 "<div class='mw-search-result-data'>{$size} - {$date}</div>" .
872 "</li>\n";
873 }
874
875 return $html;
876 }
877
878 /**
879 * Extract custom captions from search-interwiki-custom message
880 */
881 protected function getCustomCaptions() {
882 if ( is_null( $this->customCaptions ) ) {
883 $this->customCaptions = [];
884 // format per line <iwprefix>:<caption>
885 $customLines = explode( "\n", $this->msg( 'search-interwiki-custom' )->text() );
886 foreach ( $customLines as $line ) {
887 $parts = explode( ":", $line, 2 );
888 if ( count( $parts ) == 2 ) { // validate line
889 $this->customCaptions[$parts[0]] = $parts[1];
890 }
891 }
892 }
893 }
894
895 /**
896 * Show results from other wikis
897 *
898 * @param SearchResultSet|array $matches
899 * @param string $query
900 *
901 * @return string
902 */
903 protected function showInterwiki( $matches, $query ) {
904 global $wgContLang;
905
906 $out = "<div id='mw-search-interwiki'><div id='mw-search-interwiki-caption'>" .
907 $this->msg( 'search-interwiki-caption' )->text() . "</div>\n";
908 $out .= "<ul class='mw-search-iwresults'>\n";
909
910 // work out custom project captions
911 $this->getCustomCaptions();
912
913 if ( !is_array( $matches ) ) {
914 $matches = [ $matches ];
915 }
916
917 foreach ( $matches as $set ) {
918 $prev = null;
919 $result = $set->next();
920 while ( $result ) {
921 $out .= $this->showInterwikiHit( $result, $prev, $query );
922 $prev = $result->getInterwikiPrefix();
923 $result = $set->next();
924 }
925 }
926
927 // @todo Should support paging in a non-confusing way (not sure how though, maybe via ajax)..
928 $out .= "</ul></div>\n";
929
930 // convert the whole thing to desired language variant
931 $out = $wgContLang->convert( $out );
932
933 return $out;
934 }
935
936 /**
937 * Show single interwiki link
938 *
939 * @param SearchResult $result
940 * @param string $lastInterwiki
941 * @param string $query
942 *
943 * @return string
944 */
945 protected function showInterwikiHit( $result, $lastInterwiki, $query ) {
946
947 if ( $result->isBrokenTitle() ) {
948 return '';
949 }
950
951 $title = $result->getTitle();
952
953 $titleSnippet = $result->getTitleSnippet();
954
955 if ( $titleSnippet == '' ) {
956 $titleSnippet = null;
957 }
958
959 $link = Linker::linkKnown(
960 $title,
961 $titleSnippet
962 );
963
964 // format redirect if any
965 $redirectTitle = $result->getRedirectTitle();
966 $redirectText = $result->getRedirectSnippet();
967 $redirect = '';
968 if ( !is_null( $redirectTitle ) ) {
969 if ( $redirectText == '' ) {
970 $redirectText = null;
971 }
972
973 $redirect = "<span class='searchalttitle'>" .
974 $this->msg( 'search-redirect' )->rawParams(
975 Linker::linkKnown( $redirectTitle, $redirectText ) )->text() .
976 "</span>";
977 }
978
979 $out = "";
980 // display project name
981 if ( is_null( $lastInterwiki ) || $lastInterwiki != $title->getInterwiki() ) {
982 if ( array_key_exists( $title->getInterwiki(), $this->customCaptions ) ) {
983 // captions from 'search-interwiki-custom'
984 $caption = $this->customCaptions[$title->getInterwiki()];
985 } else {
986 // default is to show the hostname of the other wiki which might suck
987 // if there are many wikis on one hostname
988 $parsed = wfParseUrl( $title->getFullURL() );
989 $caption = $this->msg( 'search-interwiki-default', $parsed['host'] )->text();
990 }
991 // "more results" link (special page stuff could be localized, but we might not know target lang)
992 $searchTitle = Title::newFromText( $title->getInterwiki() . ":Special:Search" );
993 $searchLink = Linker::linkKnown(
994 $searchTitle,
995 $this->msg( 'search-interwiki-more' )->text(),
996 [],
997 [
998 'search' => $query,
999 'fulltext' => 'Search'
1000 ]
1001 );
1002 $out .= "</ul><div class='mw-search-interwiki-project'><span class='mw-search-interwiki-more'>
1003 {$searchLink}</span>{$caption}</div>\n<ul>";
1004 }
1005
1006 $out .= "<li>{$link} {$redirect}</li>\n";
1007
1008 return $out;
1009 }
1010
1011 /**
1012 * Generates the power search box at [[Special:Search]]
1013 *
1014 * @param string $term Search term
1015 * @param array $opts
1016 * @return string HTML form
1017 */
1018 protected function powerSearchBox( $term, $opts ) {
1019 global $wgContLang;
1020
1021 // Groups namespaces into rows according to subject
1022 $rows = [];
1023 foreach ( SearchEngine::searchableNamespaces() as $namespace => $name ) {
1024 $subject = MWNamespace::getSubject( $namespace );
1025 if ( !array_key_exists( $subject, $rows ) ) {
1026 $rows[$subject] = "";
1027 }
1028
1029 $name = $wgContLang->getConverter()->convertNamespace( $namespace );
1030 if ( $name == '' ) {
1031 $name = $this->msg( 'blanknamespace' )->text();
1032 }
1033
1034 $rows[$subject] .=
1035 Xml::openElement( 'td' ) .
1036 Xml::checkLabel(
1037 $name,
1038 "ns{$namespace}",
1039 "mw-search-ns{$namespace}",
1040 in_array( $namespace, $this->namespaces )
1041 ) .
1042 Xml::closeElement( 'td' );
1043 }
1044
1045 $rows = array_values( $rows );
1046 $numRows = count( $rows );
1047
1048 // Lays out namespaces in multiple floating two-column tables so they'll
1049 // be arranged nicely while still accommodating different screen widths
1050 $namespaceTables = '';
1051 for ( $i = 0; $i < $numRows; $i += 4 ) {
1052 $namespaceTables .= Xml::openElement( 'table' );
1053
1054 for ( $j = $i; $j < $i + 4 && $j < $numRows; $j++ ) {
1055 $namespaceTables .= Xml::tags( 'tr', null, $rows[$j] );
1056 }
1057
1058 $namespaceTables .= Xml::closeElement( 'table' );
1059 }
1060
1061 $showSections = [ 'namespaceTables' => $namespaceTables ];
1062
1063 Hooks::run( 'SpecialSearchPowerBox', [ &$showSections, $term, $opts ] );
1064
1065 $hidden = '';
1066 foreach ( $opts as $key => $value ) {
1067 $hidden .= Html::hidden( $key, $value );
1068 }
1069
1070 # Stuff to feed saveNamespaces()
1071 $remember = '';
1072 $user = $this->getUser();
1073 if ( $user->isLoggedIn() ) {
1074 $remember .= Xml::checkLabel(
1075 $this->msg( 'powersearch-remember' )->text(),
1076 'nsRemember',
1077 'mw-search-powersearch-remember',
1078 false,
1079 // The token goes here rather than in a hidden field so it
1080 // is only sent when necessary (not every form submission).
1081 [ 'value' => $user->getEditToken(
1082 'searchnamespace',
1083 $this->getRequest()
1084 ) ]
1085 );
1086 }
1087
1088 // Return final output
1089 return Xml::openElement( 'fieldset', [ 'id' => 'mw-searchoptions' ] ) .
1090 Xml::element( 'legend', null, $this->msg( 'powersearch-legend' )->text() ) .
1091 Xml::tags( 'h4', null, $this->msg( 'powersearch-ns' )->parse() ) .
1092 Xml::element( 'div', [ 'id' => 'mw-search-togglebox' ], '', false ) .
1093 Xml::element( 'div', [ 'class' => 'divider' ], '', false ) .
1094 implode( Xml::element( 'div', [ 'class' => 'divider' ], '', false ), $showSections ) .
1095 $hidden .
1096 Xml::element( 'div', [ 'class' => 'divider' ], '', false ) .
1097 $remember .
1098 Xml::closeElement( 'fieldset' );
1099 }
1100
1101 /**
1102 * @return array
1103 */
1104 protected function getSearchProfiles() {
1105 // Builds list of Search Types (profiles)
1106 $nsAllSet = array_keys( SearchEngine::searchableNamespaces() );
1107
1108 $profiles = [
1109 'default' => [
1110 'message' => 'searchprofile-articles',
1111 'tooltip' => 'searchprofile-articles-tooltip',
1112 'namespaces' => SearchEngine::defaultNamespaces(),
1113 'namespace-messages' => SearchEngine::namespacesAsText(
1114 SearchEngine::defaultNamespaces()
1115 ),
1116 ],
1117 'images' => [
1118 'message' => 'searchprofile-images',
1119 'tooltip' => 'searchprofile-images-tooltip',
1120 'namespaces' => [ NS_FILE ],
1121 ],
1122 'all' => [
1123 'message' => 'searchprofile-everything',
1124 'tooltip' => 'searchprofile-everything-tooltip',
1125 'namespaces' => $nsAllSet,
1126 ],
1127 'advanced' => [
1128 'message' => 'searchprofile-advanced',
1129 'tooltip' => 'searchprofile-advanced-tooltip',
1130 'namespaces' => self::NAMESPACES_CURRENT,
1131 ]
1132 ];
1133
1134 Hooks::run( 'SpecialSearchProfiles', [ &$profiles ] );
1135
1136 foreach ( $profiles as &$data ) {
1137 if ( !is_array( $data['namespaces'] ) ) {
1138 continue;
1139 }
1140 sort( $data['namespaces'] );
1141 }
1142
1143 return $profiles;
1144 }
1145
1146 /**
1147 * @param string $term
1148 * @return string
1149 */
1150 protected function searchProfileTabs( $term ) {
1151 $out = Xml::openElement( 'div', [ 'class' => 'mw-search-profile-tabs' ] );
1152
1153 $bareterm = $term;
1154 if ( $this->startsWithImage( $term ) ) {
1155 // Deletes prefixes
1156 $bareterm = substr( $term, strpos( $term, ':' ) + 1 );
1157 }
1158
1159 $profiles = $this->getSearchProfiles();
1160 $lang = $this->getLanguage();
1161
1162 // Outputs XML for Search Types
1163 $out .= Xml::openElement( 'div', [ 'class' => 'search-types' ] );
1164 $out .= Xml::openElement( 'ul' );
1165 foreach ( $profiles as $id => $profile ) {
1166 if ( !isset( $profile['parameters'] ) ) {
1167 $profile['parameters'] = [];
1168 }
1169 $profile['parameters']['profile'] = $id;
1170
1171 $tooltipParam = isset( $profile['namespace-messages'] ) ?
1172 $lang->commaList( $profile['namespace-messages'] ) : null;
1173 $out .= Xml::tags(
1174 'li',
1175 [
1176 'class' => $this->profile === $id ? 'current' : 'normal'
1177 ],
1178 $this->makeSearchLink(
1179 $bareterm,
1180 [],
1181 $this->msg( $profile['message'] )->text(),
1182 $this->msg( $profile['tooltip'], $tooltipParam )->text(),
1183 $profile['parameters']
1184 )
1185 );
1186 }
1187 $out .= Xml::closeElement( 'ul' );
1188 $out .= Xml::closeElement( 'div' );
1189 $out .= Xml::element( 'div', [ 'style' => 'clear:both' ], '', false );
1190 $out .= Xml::closeElement( 'div' );
1191
1192 return $out;
1193 }
1194
1195 /**
1196 * @param string $term Search term
1197 * @return string
1198 */
1199 protected function searchOptions( $term ) {
1200 $out = '';
1201 $opts = [];
1202 $opts['profile'] = $this->profile;
1203
1204 if ( $this->isPowerSearch() ) {
1205 $out .= $this->powerSearchBox( $term, $opts );
1206 } else {
1207 $form = '';
1208 Hooks::run( 'SpecialSearchProfileForm', [ $this, &$form, $this->profile, $term, $opts ] );
1209 $out .= $form;
1210 }
1211
1212 return $out;
1213 }
1214
1215 /**
1216 * @param string $term
1217 * @param int $resultsShown
1218 * @param int $totalNum
1219 * @return string
1220 */
1221 protected function shortDialog( $term, $resultsShown, $totalNum ) {
1222 $searchWidget = new MediaWiki\Widget\SearchInputWidget( [
1223 'id' => 'searchText',
1224 'name' => 'search',
1225 'autofocus' => trim( $term ) === '',
1226 'value' => $term,
1227 ] );
1228
1229 $out =
1230 Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
1231 Html::hidden( 'profile', $this->profile ) .
1232 Html::hidden( 'fulltext', 'Search' ) .
1233 $searchWidget .
1234 new OOUI\ButtonInputWidget( [
1235 'type' => 'submit',
1236 'label' => $this->msg( 'searchbutton' )->text(),
1237 'flags' => [ 'progressive', 'primary' ],
1238 ] );
1239
1240 // Results-info
1241 if ( $totalNum > 0 && $this->offset < $totalNum ) {
1242 $top = $this->msg( 'search-showingresults' )
1243 ->numParams( $this->offset + 1, $this->offset + $resultsShown, $totalNum )
1244 ->numParams( $resultsShown )
1245 ->parse();
1246 $out .= Xml::tags( 'div', [ 'class' => 'results-info' ], $top ) .
1247 Xml::element( 'div', [ 'style' => 'clear:both' ], '', false );
1248 }
1249
1250 return $out;
1251 }
1252
1253 /**
1254 * Make a search link with some target namespaces
1255 *
1256 * @param string $term
1257 * @param array $namespaces Ignored
1258 * @param string $label Link's text
1259 * @param string $tooltip Link's tooltip
1260 * @param array $params Query string parameters
1261 * @return string HTML fragment
1262 */
1263 protected function makeSearchLink( $term, $namespaces, $label, $tooltip, $params = [] ) {
1264 $opt = $params;
1265 foreach ( $namespaces as $n ) {
1266 $opt['ns' . $n] = 1;
1267 }
1268
1269 $stParams = array_merge(
1270 [
1271 'search' => $term,
1272 'fulltext' => $this->msg( 'search' )->text()
1273 ],
1274 $opt
1275 );
1276
1277 return Xml::element(
1278 'a',
1279 [
1280 'href' => $this->getPageTitle()->getLocalURL( $stParams ),
1281 'title' => $tooltip
1282 ],
1283 $label
1284 );
1285 }
1286
1287 /**
1288 * Check if query starts with image: prefix
1289 *
1290 * @param string $term The string to check
1291 * @return bool
1292 */
1293 protected function startsWithImage( $term ) {
1294 global $wgContLang;
1295
1296 $parts = explode( ':', $term );
1297 if ( count( $parts ) > 1 ) {
1298 return $wgContLang->getNsIndex( $parts[0] ) == NS_FILE;
1299 }
1300
1301 return false;
1302 }
1303
1304 /**
1305 * Check if query starts with all: prefix
1306 *
1307 * @param string $term The string to check
1308 * @return bool
1309 */
1310 protected function startsWithAll( $term ) {
1311
1312 $allkeyword = $this->msg( 'searchall' )->inContentLanguage()->text();
1313
1314 $parts = explode( ':', $term );
1315 if ( count( $parts ) > 1 ) {
1316 return $parts[0] == $allkeyword;
1317 }
1318
1319 return false;
1320 }
1321
1322 /**
1323 * @since 1.18
1324 *
1325 * @return SearchEngine
1326 */
1327 public function getSearchEngine() {
1328 if ( $this->searchEngine === null ) {
1329 $this->searchEngine = $this->searchEngineType ?
1330 SearchEngine::create( $this->searchEngineType ) : SearchEngine::create();
1331 }
1332
1333 return $this->searchEngine;
1334 }
1335
1336 /**
1337 * Current search profile.
1338 * @return null|string
1339 */
1340 function getProfile() {
1341 return $this->profile;
1342 }
1343
1344 /**
1345 * Current namespaces.
1346 * @return array
1347 */
1348 function getNamespaces() {
1349 return $this->namespaces;
1350 }
1351
1352 /**
1353 * Users of hook SpecialSearchSetupEngine can use this to
1354 * add more params to links to not lose selection when
1355 * user navigates search results.
1356 * @since 1.18
1357 *
1358 * @param string $key
1359 * @param mixed $value
1360 */
1361 public function setExtraParam( $key, $value ) {
1362 $this->extraParams[$key] = $value;
1363 }
1364
1365 protected function getGroupName() {
1366 return 'pages';
1367 }
1368 }