* * 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, help, 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. * null|string */ protected $profile; function getProfile() { return $this->profile; } /// Search engine protected $searchEngine; /// For links protected $extraParams = array(); /// No idea, apparently used by some other classes protected $mPrefix; /** * @var int */ protected $limit, $offset; /** * @var array */ protected $namespaces; function getNamespaces() { return $this->namespaces; } /** * @var bool */ protected $searchRedirects; /** * @var string */ protected $didYouMeanHtml, $fulltext; const NAMESPACES_CURRENT = 'sense'; public function __construct() { parent::__construct( 'Search' ); } /** * Entry point * * @param $par String or null */ public function execute( $par ) { $this->setHeaders(); $this->outputHeader(); $out = $this->getOutput(); $out->allowClickjacking(); $out->addModuleStyles( 'mediawiki.special' ); // 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 ( $request->getVal( 'fulltext' ) || !is_null( $request->getVal( 'offset' ) ) || !is_null( $request->getVal( 'searchx' ) ) ) { $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, 'searchlimit' ); $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']; } } // Redirects defaults to true, but we don't know whether it was ticked of or just missing $default = $request->getBool( 'profile' ) ? 0 : 1; $this->searchRedirects = $request->getBool( 'redirs', $default ) ? 1 : 0; $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 $term String */ public function goResult( $term ) { $this->setupPage( $term ); # Try to go to page as entered. $t = Title::newFromText( $term ); # If the string cannot be used to create a title if( is_null( $t ) ) { $this->showResults( $term ); return; } # If there's an exact or very near match, jump right there. $t = SearchEngine::getNearMatch( $term ); if ( !wfRunHooks( 'SpecialSearchGo', array( &$t, &$term ) ) ) { # Hook requested termination return; } if( !is_null( $t ) ) { $this->getOutput()->redirect( $t->getFullURL() ); return; } # No match, generate an edit URL $t = Title::newFromText( $term ); if( !is_null( $t ) ) { global $wgGoToEdit; wfRunHooks( 'SpecialSearchNogomatch', array( &$t ) ); wfDebugLog( 'nogomatch', $t->getText(), false ); # If the feature is enabled, go straight to the edit page if( $wgGoToEdit ) { $this->getOutput()->redirect( $t->getFullURL( array( 'action' => 'edit' ) ) ); return; } } $this->showResults( $term ); } /** * @param $term String */ public function showResults( $term ) { global $wgDisableTextSearch, $wgSearchForwardUrl, $wgContLang, $wgScript; wfProfileIn( __METHOD__ ); $search = $this->getSearchEngine(); $search->setLimitOffset( $this->limit, $this->offset ); $search->setNamespaces( $this->namespaces ); $search->showRedirects = $this->searchRedirects; // BC $search->setFeatureData( 'list-redirects', $this->searchRedirects ); $search->prefix = $this->mPrefix; $term = $search->transformSearchTerm($term); wfRunHooks( 'SpecialSearchSetupEngine', array( $this, $this->profile, $search ) ); $this->setupPage( $term ); $out = $this->getOutput(); if ( $wgDisableTextSearch ) { if ( $wgSearchForwardUrl ) { $url = str_replace( '$1', urlencode( $term ), $wgSearchForwardUrl ); $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' ) ); } wfProfileOut( __METHOD__ ); return; } $t = Title::newFromText( $term ); // fetch search results $rewritten = $search->replacePrefixes($term); $titleMatches = $search->searchTitle( $rewritten ); if( !( $titleMatches instanceof SearchResultTooMany ) ) { $textMatches = $search->searchText( $rewritten ); } // did you mean... suggestions if( $textMatches && $textMatches->hasSuggestion() ) { $st = SpecialPage::getTitleFor( 'Search' ); # mirror Go/Search behaviour 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( $st, $suggestionSnippet, array(), $stParams ); $this->didYouMeanHtml = '
' . $this->msg( 'search-suggest' )->rawParams( $suggestLink )->text() . '
'; } // start rendering the page $out->addHtml( Xml::openElement( 'form', array( 'id' => ( $this->profile === 'advanced' ? 'powersearch' : 'search' ), 'method' => 'get', 'action' => $wgScript ) ) ); $out->addHtml( Xml::openElement( 'table', array( 'id' => 'mw-search-top-table', 'cellpadding' => 0, 'cellspacing' => 0 ) ) . Xml::openElement( 'tr' ) . Xml::openElement( 'td' ) . "\n" . $this->shortDialog( $term ) . Xml::closeElement('td') . Xml::closeElement('tr') . Xml::closeElement('table') ); // Sometimes the search engine knows there are too many hits if( $titleMatches instanceof SearchResultTooMany ) { $out->wrapWikiMsg( "==$1==\n", 'toomanymatches' ); wfProfileOut( __METHOD__ ); return; } $filePrefix = $wgContLang->getFormattedNsText(NS_FILE).':'; if( trim( $term ) === '' || $filePrefix === trim( $term ) ) { $out->addHTML( $this->formHeader( $term, 0, 0 ) ); $out->addHtml( $this->getProfileForm( $this->profile, $term ) ); $out->addHTML( '' ); // Empty query -- straight view of search form wfProfileOut( __METHOD__ ); return; } // Get number of results $titleMatchesNum = $titleMatches ? $titleMatches->numRows() : 0; $textMatchesNum = $textMatches ? $textMatches->numRows() : 0; // Total initial query matches (possible false positives) $num = $titleMatchesNum + $textMatchesNum; // Get total actual results (after second filtering, if any) $numTitleMatches = $titleMatches && !is_null( $titleMatches->getTotalHits() ) ? $titleMatches->getTotalHits() : $titleMatchesNum; $numTextMatches = $textMatches && !is_null( $textMatches->getTotalHits() ) ? $textMatches->getTotalHits() : $textMatchesNum; // get total number of results if backend can calculate it $totalRes = 0; if($titleMatches && !is_null( $titleMatches->getTotalHits() ) ) $totalRes += $titleMatches->getTotalHits(); if($textMatches && !is_null( $textMatches->getTotalHits() )) $totalRes += $textMatches->getTotalHits(); // show number of results and current offset $out->addHTML( $this->formHeader( $term, $num, $totalRes ) ); $out->addHtml( $this->getProfileForm( $this->profile, $term ) ); $out->addHtml( Xml::closeElement( 'form' ) ); $out->addHtml( "
" ); // prev/next links if( $num || $this->offset ) { // Show the create link ahead $this->showCreateLink( $t ); $prevnext = $this->getLanguage()->viewPrevNext( $this->getTitle(), $this->offset, $this->limit, $this->powerSearchOptions() + array( 'search' => $term ), max( $titleMatchesNum, $textMatchesNum ) < $this->limit ); //$out->addHTML( "

{$prevnext}

\n" ); wfRunHooks( 'SpecialSearchResults', array( $term, &$titleMatches, &$textMatches ) ); } else { wfRunHooks( 'SpecialSearchNoResults', array( $term ) ); } $out->parserOptions()->setEditSection( false ); if( $titleMatches ) { if( $numTitleMatches > 0 ) { $out->wrapWikiMsg( "==$1==\n", 'titlematches' ); $out->addHTML( $this->showMatches( $titleMatches ) ); } $titleMatches->free(); } if( $textMatches ) { // output appropriate heading if( $numTextMatches > 0 && $numTitleMatches > 0 ) { // if no title matches the heading is redundant $out->wrapWikiMsg( "==$1==\n", 'textmatches' ); } elseif( $totalRes == 0 ) { # Don't show the 'no text matches' if we received title matches # $out->wrapWikiMsg( "==$1==\n", 'notextmatches' ); } // 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 ) { $out->wrapWikiMsg( "

\n$1

", array( 'search-nonefound', wfEscapeWikiText( $term ) ) ); $this->showCreateLink( $t ); } $out->addHtml( "
" ); if( $num || $this->offset ) { $out->addHTML( "

{$prevnext}

\n" ); } wfProfileOut( __METHOD__ ); } /** * @param $t Title */ protected function showCreateLink( $t ) { // show direct page/create link if applicable // Check DBkey !== '' in case of fragment link only. if( is_null( $t ) || $t->getDBkey() === '' ) { // invalid title // preserve the paragraph for margins etc... $this->getOutput()->addHtml( '

' ); return; } if( $t->isKnown() ) { $messageName = 'searchmenu-exists'; } elseif( $t->userCan( 'create' ) ) { $messageName = 'searchmenu-new'; } else { $messageName = 'searchmenu-new-nocreate'; } $params = array( $messageName, wfEscapeWikiText( $t->getPrefixedText() ) ); wfRunHooks( 'SpecialSearchCreateLink', array( $t, &$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 $term string */ 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', $this->msg( 'searchresults-title', $term )->plain() ) ); } // 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 $request WebRequest * @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(); $opt['redirs'] = $this->searchRedirects ? 1 : 0; if( $this->profile !== 'advanced' ) { $opt['profile'] = $this->profile; } else { foreach( $this->namespaces as $n ) { $opt['ns' . $n] = 1; } } return $opt + $this->extraParams; } /** * Show whole set of results * * @param $matches SearchResultSet * * @return string */ protected function showMatches( &$matches ) { global $wgContLang; wfProfileIn( __METHOD__ ); $terms = $wgContLang->convertForSearchResult( $matches->termMatches() ); $out = ""; $infoLine = $matches->getInfo(); if( !is_null($infoLine) ) { $out .= "\n\n"; } $out .= "\n"; // convert the whole thing to desired language variant $out = $wgContLang->convert( $out ); wfProfileOut( __METHOD__ ); return $out; } /** * Format a single hit result * * @param $result SearchResult * @param $terms Array: terms to highlight * * @return string */ protected function showHit( $result, $terms ) { wfProfileIn( __METHOD__ ); if( $result->isBrokenTitle() ) { wfProfileOut( __METHOD__ ); return "\n"; } $t = $result->getTitle(); $titleSnippet = $result->getTitleSnippet($terms); if( $titleSnippet == '' ) $titleSnippet = null; $link_t = clone $t; 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( !$t->userCan( 'read' ) ) { wfProfileOut( __METHOD__ ); 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() ) { wfProfileOut( __METHOD__ ); return "\n"; } // 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 score if( is_null( $result->getScore() ) ) { // Search engine doesn't report scoring info $score = ''; } else { $percent = sprintf( '%2.1f', $result->getScore() * 100 ); $score = $this->msg( 'search-result-score' )->numParams( $percent )->text() . ' - '; } // format description $byteSize = $result->getByteSize(); $wordCount = $result->getWordCount(); $timestamp = $result->getTimestamp(); $size = $this->msg( 'search-result-size', $lang->formatSize( $byteSize ) ) ->numParams( $wordCount )->escaped(); if( $t->getNamespace() == NS_CATEGORY ) { $cat = Category::newFromTitle( $t ); $size = $this->msg( 'search-result-category-size' ) ->numParams( $cat->getPageCount(), $cat->getSubcatCount(), $cat->getFileCount() ) ->escaped(); } $date = $lang->userTimeAndDate( $timestamp, $this->getUser() ); // link to related articles if supported $related = ''; if( $result->hasRelated() ) { $st = SpecialPage::getTitleFor( 'Search' ); $stParams = array_merge( $this->powerSearchOptions(), array( 'search' => $this->msg( 'searchrelated' )->inContentLanguage()->text() . ':' . $t->getPrefixedText(), 'fulltext' => $this->msg( 'search' )->text() ) ); $related = ' -- ' . Linker::linkKnown( $st, $this->msg( 'search-relatedarticle' )->text(), array(), $stParams ); } // Include a thumbnail for media files... if( $t->getNamespace() == NS_FILE ) { $img = wfFindFile( $t ); if( $img ) { $thumb = $img->transform( array( 'width' => 120, 'height' => 120 ) ); if( $thumb ) { $desc = $this->msg( 'parentheses' )->rawParams( $img->getShortDesc() )->escaped(); wfProfileOut( __METHOD__ ); // 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 . $extract . "
    {$score}{$desc} - {$date}{$related}
    " . '
    ' . "
  • \n"; } } } wfProfileOut( __METHOD__ ); return "
  • {$link} {$redirect} {$section}
    {$extract}\n" . "
    {$score}{$size} - {$date}{$related}
    " . "
  • \n"; } /** * Show results from other wikis * * @param $matches SearchResultSet * @param $query String * * @return string */ protected function showInterwiki( &$matches, $query ) { global $wgContLang; wfProfileIn( __METHOD__ ); $terms = $wgContLang->convertForSearchResult( $matches->termMatches() ); $out = "
    ". $this->msg( 'search-interwiki-caption' )->text() . "
    \n"; $out .= "
    \n"; // convert the whole thing to desired language variant $out = $wgContLang->convert( $out ); wfProfileOut( __METHOD__ ); return $out; } /** * Show single interwiki link * * @param $result SearchResult * @param $lastInterwiki String * @param $terms Array * @param $query String * @param $customCaptions Array: iw prefix -> caption * * @return string */ protected function showInterwikiHit( $result, $lastInterwiki, $terms, $query, $customCaptions) { wfProfileIn( __METHOD__ ); if( $result->isBrokenTitle() ) { wfProfileOut( __METHOD__ ); return "\n"; } $t = $result->getTitle(); $titleSnippet = $result->getTitleSnippet($terms); if( $titleSnippet == '' ) $titleSnippet = null; $link = Linker::linkKnown( $t, $titleSnippet ); // format redirect if any $redirectTitle = $result->getRedirectTitle(); $redirectText = $result->getRedirectSnippet($terms); $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 != $t->getInterwiki()) { if( array_key_exists($t->getInterwiki(),$customCaptions) ) { // captions from 'search-interwiki-custom' $caption = $customCaptions[$t->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( $t->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($t->getInterwiki().":Special:Search"); $searchLink = Linker::linkKnown( $searchTitle, $this->msg( 'search-interwiki-more' )->text(), array(), array( 'search' => $query, 'fulltext' => 'Search' ) ); $out .= "
    {$searchLink}{$caption}
    \n