* * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * http://www.gnu.org/copyleft/gpl.html * * @file * @ingroup SpecialPage */ /** * implements Special:Search - Run text & title search and display the output * @ingroup SpecialPage */ class SpecialSearch extends SpecialPage { /** * Current search profile. Search profile is just a name that identifies * the active search tab on the search page (content, discussions...) * For users tt replaces the set of enabled namespaces from the query * string when applicable. Extensions can add new profiles with hooks * with custom search options just for that profile. * @var null|string */ protected $profile; /** @var SearchEngine Search engine */ protected $searchEngine; /** @var string Search engine type, if not default */ protected $searchEngineType; /** @var array For links */ protected $extraParams = array(); /** @var string No idea, apparently used by some other classes */ protected $mPrefix; /** * @var int */ protected $limit, $offset; /** * @var array */ protected $namespaces; /** * @var string */ protected $didYouMeanHtml, $fulltext; const NAMESPACES_CURRENT = 'sense'; public function __construct() { parent::__construct( 'Search' ); } /** * Entry point * * @param string $par */ public function execute( $par ) { $this->setHeaders(); $this->outputHeader(); $out = $this->getOutput(); $out->allowClickjacking(); $out->addModuleStyles( array( 'mediawiki.special', 'mediawiki.special.search', 'mediawiki.ui', 'mediawiki.ui.button', 'mediawiki.ui.input', ) ); // Strip underscores from title parameter; most of the time we'll want // text form here. But don't strip underscores from actual text params! $titleParam = str_replace( '_', ' ', $par ); $request = $this->getRequest(); // Fetch the search term $search = str_replace( "\n", " ", $request->getText( 'search', $titleParam ) ); $this->load(); if ( !is_null( $request->getVal( 'nsRemember' ) ) ) { $this->saveNamespaces(); // Remove the token from the URL to prevent the user from inadvertently // exposing it (e.g. by pasting it into a public wiki page) or undoing // later settings changes (e.g. by reloading the page). $query = $request->getValues(); unset( $query['title'], $query['nsRemember'] ); $out->redirect( $this->getPageTitle()->getFullURL( $query ) ); return; } $this->searchEngineType = $request->getVal( 'srbackend' ); if ( $request->getVal( 'fulltext' ) || !is_null( $request->getVal( 'offset' ) ) ) { $this->showResults( $search ); } else { $this->goResult( $search ); } } /** * Set up basic search parameters from the request and user settings. * * @see tests/phpunit/includes/specials/SpecialSearchTest.php */ public function load() { $request = $this->getRequest(); list( $this->limit, $this->offset ) = $request->getLimitOffset( 20, '' ); $this->mPrefix = $request->getVal( 'prefix', '' ); $user = $this->getUser(); # Extract manually requested namespaces $nslist = $this->powerSearch( $request ); if ( !count( $nslist ) ) { # Fallback to user preference $nslist = SearchEngine::userNamespaces( $user ); } $profile = null; if ( !count( $nslist ) ) { $profile = 'default'; } $profile = $request->getVal( 'profile', $profile ); $profiles = $this->getSearchProfiles(); if ( $profile === null ) { // BC with old request format $profile = 'advanced'; foreach ( $profiles as $key => $data ) { if ( $nslist === $data['namespaces'] && $key !== 'advanced' ) { $profile = $key; } } $this->namespaces = $nslist; } elseif ( $profile === 'advanced' ) { $this->namespaces = $nslist; } else { if ( isset( $profiles[$profile]['namespaces'] ) ) { $this->namespaces = $profiles[$profile]['namespaces']; } else { // Unknown profile requested $profile = 'default'; $this->namespaces = $profiles['default']['namespaces']; } } $this->didYouMeanHtml = ''; # html of did you mean... link $this->fulltext = $request->getVal( 'fulltext' ); $this->profile = $profile; } /** * If an exact title match can be found, jump straight ahead to it. * * @param string $term */ public function goResult( $term ) { $this->setupPage( $term ); # Try to go to page as entered. $title = Title::newFromText( $term ); # If the string cannot be used to create a title if ( is_null( $title ) ) { $this->showResults( $term ); return; } # If there's an exact or very near match, jump right there. $title = SearchEngine::getNearMatch( $term ); if ( !is_null( $title ) ) { $this->getOutput()->redirect( $title->getFullURL() ); return; } # No match, generate an edit URL $title = Title::newFromText( $term ); if ( !is_null( $title ) ) { wfRunHooks( 'SpecialSearchNogomatch', array( &$title ) ); } $this->showResults( $term ); } /** * @param string $term */ public function showResults( $term ) { global $wgContLang; $profile = new ProfileSection( __METHOD__ ); $search = $this->getSearchEngine(); $search->setLimitOffset( $this->limit, $this->offset ); $search->setNamespaces( $this->namespaces ); $search->prefix = $this->mPrefix; $term = $search->transformSearchTerm( $term ); wfRunHooks( 'SpecialSearchSetupEngine', array( $this, $this->profile, $search ) ); $this->setupPage( $term ); $out = $this->getOutput(); if ( $this->getConfig()->get( 'DisableTextSearch' ) ) { $searchFowardUrl = $this->getConfig()->get( 'SearchForwardUrl' ); if ( $searchFowardUrl ) { $url = str_replace( '$1', urlencode( $term ), $searchFowardUrl ); $out->redirect( $url ); } else { $out->addHTML( Xml::openElement( 'fieldset' ) . Xml::element( 'legend', null, $this->msg( 'search-external' )->text() ) . Xml::element( 'p', array( 'class' => 'mw-searchdisabled' ), $this->msg( 'searchdisabled' )->text() ) . $this->msg( 'googlesearch' )->rawParams( htmlspecialchars( $term ), 'UTF-8', $this->msg( 'searchbutton' )->escaped() )->text() . Xml::closeElement( 'fieldset' ) ); } return; } $title = Title::newFromText( $term ); $showSuggestion = $title === null || !$title->isKnown(); $search->setShowSuggestion( $showSuggestion ); // fetch search results $rewritten = $search->replacePrefixes( $term ); $titleMatches = $search->searchTitle( $rewritten ); $textMatches = $search->searchText( $rewritten ); $textStatus = null; if ( $textMatches instanceof Status ) { $textStatus = $textMatches; $textMatches = null; } // did you mean... suggestions if ( $showSuggestion && $textMatches && !$textStatus && $textMatches->hasSuggestion() ) { # mirror Go/Search behavior of original request .. $didYouMeanParams = array( 'search' => $textMatches->getSuggestionQuery() ); if ( $this->fulltext != null ) { $didYouMeanParams['fulltext'] = $this->fulltext; } $stParams = array_merge( $didYouMeanParams, $this->powerSearchOptions() ); $suggestionSnippet = $textMatches->getSuggestionSnippet(); if ( $suggestionSnippet == '' ) { $suggestionSnippet = null; } $suggestLink = Linker::linkKnown( $this->getPageTitle(), $suggestionSnippet, array(), $stParams ); $this->didYouMeanHtml = '
' . $this->msg( 'search-suggest' )->rawParams( $suggestLink )->text() . '
'; } if ( !wfRunHooks( 'SpecialSearchResultsPrepend', array( $this, $out, $term ) ) ) { # Hook requested termination return; } // start rendering the page $out->addHtml( Xml::openElement( 'form', array( 'id' => ( $this->profile === 'advanced' ? 'powersearch' : 'search' ), 'method' => 'get', 'action' => wfScript(), ) ) ); // Get number of results $titleMatchesNum = $textMatchesNum = $numTitleMatches = $numTextMatches = 0; if ( $titleMatches ) { $titleMatchesNum = $titleMatches->numRows(); $numTitleMatches = $titleMatches->getTotalHits(); } if ( $textMatches ) { $textMatchesNum = $textMatches->numRows(); $numTextMatches = $textMatches->getTotalHits(); } $num = $titleMatchesNum + $textMatchesNum; $totalRes = $numTitleMatches + $numTextMatches; $out->addHtml( # This is an awful awful ID name. It's not a table, but we # named it poorly from when this was a table so now we're # stuck with it Xml::openElement( 'div', array( 'id' => 'mw-search-top-table' ) ) . $this->shortDialog( $term, $num, $totalRes ) . Xml::closeElement( 'div' ) . $this->formHeader( $term ) . Xml::closeElement( 'form' ) ); $filePrefix = $wgContLang->getFormattedNsText( NS_FILE ) . ':'; if ( trim( $term ) === '' || $filePrefix === trim( $term ) ) { // Empty query -- straight view of search form return; } $out->addHtml( "
" ); // prev/next links $prevnext = null; if ( $num || $this->offset ) { // Show the create link ahead $this->showCreateLink( $title, $num, $titleMatches, $textMatches ); if ( $totalRes > $this->limit || $this->offset ) { if ( $this->searchEngineType !== null ) { $this->setExtraParam( 'srbackend', $this->searchEngineType ); } $prevnext = $this->getLanguage()->viewPrevNext( $this->getPageTitle(), $this->offset, $this->limit, $this->powerSearchOptions() + array( 'search' => $term ), $this->limit + $this->offset >= $totalRes ); } } wfRunHooks( 'SpecialSearchResults', array( $term, &$titleMatches, &$textMatches ) ); $out->parserOptions()->setEditSection( false ); if ( $titleMatches ) { if ( $numTitleMatches > 0 ) { $out->wrapWikiMsg( "==$1==\n", 'titlematches' ); $out->addHTML( $this->showMatches( $titleMatches ) ); } $titleMatches->free(); } if ( $textMatches && !$textStatus ) { // output appropriate heading if ( $numTextMatches > 0 && $numTitleMatches > 0 ) { // if no title matches the heading is redundant $out->wrapWikiMsg( "==$1==\n", 'textmatches' ); } // show interwiki results if any if ( $textMatches->hasInterwikiResults() ) { $out->addHTML( $this->showInterwiki( $textMatches->getInterwikiResults(), $term ) ); } // show results if ( $numTextMatches > 0 ) { $out->addHTML( $this->showMatches( $textMatches ) ); } $textMatches->free(); } if ( $num === 0 ) { if ( $textStatus ) { $out->addHTML( '
' . $textStatus->getMessage( 'search-error' ) . '
' ); } else { $out->wrapWikiMsg( "

\n$1

", array( 'search-nonefound', wfEscapeWikiText( $term ) ) ); $this->showCreateLink( $title, $num, $titleMatches, $textMatches ); } } $out->addHtml( "
" ); if ( $prevnext ) { $out->addHTML( "

{$prevnext}

\n" ); } } /** * @param Title $title * @param int $num The number of search results found * @param null|SearchResultSet $titleMatches Results from title search * @param null|SearchResultSet $textMatches Results from text search */ protected function showCreateLink( $title, $num, $titleMatches, $textMatches ) { // show direct page/create link if applicable // Check DBkey !== '' in case of fragment link only. if ( is_null( $title ) || $title->getDBkey() === '' || ( $titleMatches !== null && $titleMatches->searchContainedSyntax() ) || ( $textMatches !== null && $textMatches->searchContainedSyntax() ) ) { // invalid title // preserve the paragraph for margins etc... $this->getOutput()->addHtml( '

' ); return; } $linkClass = 'mw-search-createlink'; if ( $title->isKnown() ) { $messageName = 'searchmenu-exists'; $linkClass = 'mw-search-exists'; } elseif ( $title->quickUserCan( 'create', $this->getUser() ) ) { $messageName = 'searchmenu-new'; } else { $messageName = 'searchmenu-new-nocreate'; } $params = array( $messageName, wfEscapeWikiText( $title->getPrefixedText() ), Message::numParam( $num ) ); wfRunHooks( 'SpecialSearchCreateLink', array( $title, &$params ) ); // Extensions using the hook might still return an empty $messageName if ( $messageName ) { $this->getOutput()->wrapWikiMsg( "

\n$1

", $params ); } else { // preserve the paragraph for margins etc... $this->getOutput()->addHtml( '

' ); } } /** * @param string $term */ protected function setupPage( $term ) { # Should advanced UI be used? $this->searchAdvanced = ( $this->profile === 'advanced' ); $out = $this->getOutput(); if ( strval( $term ) !== '' ) { $out->setPageTitle( $this->msg( 'searchresults' ) ); $out->setHTMLTitle( $this->msg( 'pagetitle' ) ->rawParams( $this->msg( 'searchresults-title' )->rawParams( $term )->text() ) ->inContentLanguage()->text() ); } // add javascript specific to special:search $out->addModules( 'mediawiki.special.search' ); } /** * Extract "power search" namespace settings from the request object, * returning a list of index numbers to search. * * @param WebRequest $request * @return array */ protected function powerSearch( &$request ) { $arr = array(); foreach ( SearchEngine::searchableNamespaces() as $ns => $name ) { if ( $request->getCheck( 'ns' . $ns ) ) { $arr[] = $ns; } } return $arr; } /** * Reconstruct the 'power search' options for links * * @return array */ protected function powerSearchOptions() { $opt = array(); if ( $this->profile !== 'advanced' ) { $opt['profile'] = $this->profile; } else { foreach ( $this->namespaces as $n ) { $opt['ns' . $n] = 1; } } return $opt + $this->extraParams; } /** * Save namespace preferences when we're supposed to * * @return bool Whether we wrote something */ protected function saveNamespaces() { $user = $this->getUser(); $request = $this->getRequest(); if ( $user->isLoggedIn() && $user->matchEditToken( $request->getVal( 'nsRemember' ), 'searchnamespace', $request ) ) { // Reset namespace preferences: namespaces are not searched // when they're not mentioned in the URL parameters. foreach ( MWNamespace::getValidNamespaces() as $n ) { $user->setOption( 'searchNs' . $n, false ); } // The request parameters include all the namespaces to be searched. // Even if they're the same as an existing profile, they're not eaten. foreach ( $this->namespaces as $n ) { $user->setOption( 'searchNs' . $n, true ); } $user->saveSettings(); return true; } return false; } /** * Show whole set of results * * @param SearchResultSet $matches * * @return string */ protected function showMatches( &$matches ) { global $wgContLang; $profile = new ProfileSection( __METHOD__ ); $terms = $wgContLang->convertForSearchResult( $matches->termMatches() ); $out = "\n"; // convert the whole thing to desired language variant $out = $wgContLang->convert( $out ); return $out; } /** * Format a single hit result * * @param SearchResult $result * @param array $terms Terms to highlight * * @return string */ protected function showHit( $result, $terms ) { $profile = new ProfileSection( __METHOD__ ); if ( $result->isBrokenTitle() ) { return ''; } $title = $result->getTitle(); $titleSnippet = $result->getTitleSnippet( $terms ); if ( $titleSnippet == '' ) { $titleSnippet = null; } $link_t = clone $title; wfRunHooks( 'ShowSearchHitTitle', array( &$link_t, &$titleSnippet, $result, $terms, $this ) ); $link = Linker::linkKnown( $link_t, $titleSnippet ); //If page content is not readable, just return the title. //This is not quite safe, but better than showing excerpts from non-readable pages //Note that hiding the entry entirely would screw up paging. if ( !$title->userCan( 'read', $this->getUser() ) ) { return "
  • {$link}
  • \n"; } // If the page doesn't *exist*... our search index is out of date. // The least confusing at this point is to drop the result. // You may get less results, but... oh well. :P if ( $result->isMissingRevision() ) { return ''; } // format redirects / relevant sections $redirectTitle = $result->getRedirectTitle(); $redirectText = $result->getRedirectSnippet( $terms ); $sectionTitle = $result->getSectionTitle(); $sectionText = $result->getSectionSnippet( $terms ); $redirect = ''; if ( !is_null( $redirectTitle ) ) { if ( $redirectText == '' ) { $redirectText = null; } $redirect = "" . $this->msg( 'search-redirect' )->rawParams( Linker::linkKnown( $redirectTitle, $redirectText ) )->text() . ""; } $section = ''; if ( !is_null( $sectionTitle ) ) { if ( $sectionText == '' ) { $sectionText = null; } $section = "" . $this->msg( 'search-section' )->rawParams( Linker::linkKnown( $sectionTitle, $sectionText ) )->text() . ""; } // format text extract $extract = "
    " . $result->getTextSnippet( $terms ) . "
    "; $lang = $this->getLanguage(); // format description $byteSize = $result->getByteSize(); $wordCount = $result->getWordCount(); $timestamp = $result->getTimestamp(); $size = $this->msg( 'search-result-size', $lang->formatSize( $byteSize ) ) ->numParams( $wordCount )->escaped(); if ( $title->getNamespace() == NS_CATEGORY ) { $cat = Category::newFromTitle( $title ); $size = $this->msg( 'search-result-category-size' ) ->numParams( $cat->getPageCount(), $cat->getSubcatCount(), $cat->getFileCount() ) ->escaped(); } $date = $lang->userTimeAndDate( $timestamp, $this->getUser() ); $fileMatch = ''; // Include a thumbnail for media files... if ( $title->getNamespace() == NS_FILE ) { $img = $result->getFile(); $img = $img ?: wfFindFile( $title ); if ( $result->isFileMatch() ) { $fileMatch = "" . $this->msg( 'search-file-match' )->escaped() . ""; } if ( $img ) { $thumb = $img->transform( array( 'width' => 120, 'height' => 120 ) ); if ( $thumb ) { $desc = $this->msg( 'parentheses' )->rawParams( $img->getShortDesc() )->escaped(); // Float doesn't seem to interact well with the bullets. // Table messes up vertical alignment of the bullets. // Bullets are therefore disabled (didn't look great anyway). return "
  • " . '' . '' . '' . '' . '' . '
    ' . $thumb->toHtml( array( 'desc-link' => true ) ) . '' . "{$link} {$redirect} {$section} {$fileMatch}" . $extract . "
    {$desc} - {$date}
    " . '
    ' . "
  • \n"; } } } $html = null; $score = ''; if ( wfRunHooks( 'ShowSearchHit', array( $this, $result, $terms, &$link, &$redirect, &$section, &$extract, &$score, &$size, &$date, &$related, &$html ) ) ) { $html = "
  • " . "{$link} {$redirect} {$section} {$fileMatch}
    {$extract}\n" . "
    {$size} - {$date}
    " . "
  • \n"; } return $html; } /** * Show results from other wikis * * @param SearchResultSet|array $matches * @param string $query * * @return string */ protected function showInterwiki( $matches, $query ) { global $wgContLang; $profile = new ProfileSection( __METHOD__ ); $out = "
    " . $this->msg( 'search-interwiki-caption' )->text() . "
    \n"; $out .= "
    \n"; // convert the whole thing to desired language variant $out = $wgContLang->convert( $out ); return $out; } /** * Show single interwiki link * * @param SearchResult $result * @param string $lastInterwiki * @param string $query * @param array $customCaptions Interwiki prefix -> caption * * @return string */ protected function showInterwikiHit( $result, $lastInterwiki, $query, $customCaptions ) { $profile = new ProfileSection( __METHOD__ ); if ( $result->isBrokenTitle() ) { return ''; } $title = $result->getTitle(); $titleSnippet = $result->getTitleSnippet(); if ( $titleSnippet == '' ) { $titleSnippet = null; } $link = Linker::linkKnown( $title, $titleSnippet ); // format redirect if any $redirectTitle = $result->getRedirectTitle(); $redirectText = $result->getRedirectSnippet(); $redirect = ''; if ( !is_null( $redirectTitle ) ) { if ( $redirectText == '' ) { $redirectText = null; } $redirect = "" . $this->msg( 'search-redirect' )->rawParams( Linker::linkKnown( $redirectTitle, $redirectText ) )->text() . ""; } $out = ""; // display project name if ( is_null( $lastInterwiki ) || $lastInterwiki != $title->getInterwiki() ) { if ( array_key_exists( $title->getInterwiki(), $customCaptions ) ) { // captions from 'search-interwiki-custom' $caption = $customCaptions[$title->getInterwiki()]; } else { // default is to show the hostname of the other wiki which might suck // if there are many wikis on one hostname $parsed = wfParseUrl( $title->getFullURL() ); $caption = $this->msg( 'search-interwiki-default', $parsed['host'] )->text(); } // "more results" link (special page stuff could be localized, but we might not know target lang) $searchTitle = Title::newFromText( $title->getInterwiki() . ":Special:Search" ); $searchLink = Linker::linkKnown( $searchTitle, $this->msg( 'search-interwiki-more' )->text(), array(), array( 'search' => $query, 'fulltext' => 'Search' ) ); $out .= "
    {$searchLink}{$caption}
    \n