includes/search/searchwidgets/FullSearchResultWidget.php
<?php
namespace MediaWiki\Search\SearchWidgets;
use File;
use HtmlArmor;
use MediaTransformOutput;
use MediaWiki\Category\Category;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\Html\Html;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\MainConfigNames;
use MediaWiki\Search\Entity\SearchResultThumbnail;
use MediaWiki\Search\SearchResultThumbnailProvider;
use MediaWiki\Specials\SpecialSearch;
use MediaWiki\Title\Title;
use MediaWiki\User\Options\UserOptionsManager;
use RepoGroup;
use SearchResult;
use ThumbnailImage;
/**
* Renders a 'full' multi-line search result with metadata.
*
* The Title
* some *highlighted* *text* about the search result
* 5 KiB (651 words) - 12:40, 6 Aug 2016
*/
class FullSearchResultWidget implements SearchResultWidget {
/** @var int */
public const THUMBNAIL_SIZE = 90;
/** @var SpecialSearch */
protected $specialPage;
/** @var LinkRenderer */
protected $linkRenderer;
/** @var HookRunner */
private $hookRunner;
/** @var RepoGroup */
private $repoGroup;
/** @var SearchResultThumbnailProvider */
private $thumbnailProvider;
/** @var string */
private $thumbnailPlaceholderHtml;
/** @var UserOptionsManager */
private $userOptionsManager;
public function __construct(
SpecialSearch $specialPage,
LinkRenderer $linkRenderer,
HookContainer $hookContainer,
RepoGroup $repoGroup,
SearchResultThumbnailProvider $thumbnailProvider,
UserOptionsManager $userOptionsManager
) {
$this->specialPage = $specialPage;
$this->linkRenderer = $linkRenderer;
$this->hookRunner = new HookRunner( $hookContainer );
$this->repoGroup = $repoGroup;
$this->thumbnailProvider = $thumbnailProvider;
$this->userOptionsManager = $userOptionsManager;
}
/**
* @param SearchResult $result The result to render
* @param int $position The result position, including offset
* @return string HTML
*/
public function render( SearchResult $result, $position ) {
// 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... on well. :P
if ( $result->isBrokenTitle() || $result->isMissingRevision() ) {
return '';
}
$link = $this->generateMainLinkHtml( $result, $position );
// If page content is not readable, just return ths 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 (really?).
if ( !$this->specialPage->getAuthority()->definitelyCan( 'read', $result->getTitle() ) ) {
return Html::rawElement( 'li', [], $link );
}
$redirect = $this->generateRedirectHtml( $result );
$section = $this->generateSectionHtml( $result );
$category = $this->generateCategoryHtml( $result );
$date = htmlspecialchars(
$this->specialPage->getLanguage()->userTimeAndDate(
$result->getTimestamp(),
$this->specialPage->getUser()
)
);
[ $file, $desc, $thumb ] = $this->generateFileHtml( $result );
$snippet = $result->getTextSnippet();
if ( $snippet ) {
$snippetWithEllipsis = $snippet . $this->specialPage->msg( 'ellipsis' );
$extract = Html::rawElement( 'div', [ 'class' => 'searchresult' ], $snippetWithEllipsis );
} else {
$extract = '';
}
if ( $result->getTitle() && $result->getTitle()->getNamespace() !== NS_FILE ) {
// If no file, then the description is about size
$desc = $this->generateSizeHtml( $result );
// Let hooks do their own final construction if desired.
// FIXME: Not sure why this is only for results without thumbnails,
// but keeping it as-is for now to prevent breaking hook consumers.
$html = null;
$score = '';
$related = '';
// TODO: remove this instanceof and always pass [], let implementors do the cast if
// they want to be SearchDatabase specific
$terms = $result instanceof \SqlSearchResult ? $result->getTermMatches() : [];
if ( !$this->hookRunner->onShowSearchHit( $this->specialPage, $result,
$terms, $link, $redirect, $section, $extract, $score,
// @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
$desc, $date, $related, $html )
) {
return $html;
}
}
// All the pieces have been collected. Now generate the final HTML
$joined = "{$link} {$redirect} {$category} {$section} {$file}";
$meta = $this->buildMeta( $desc, $date );
// Text portion of the search result
$html = Html::rawElement(
'div',
[ 'class' => 'mw-search-result-heading' ],
$joined
);
$html .= $extract . ' ' . $meta;
// If the result has a thumbnail, place it next to the text block
if ( $thumb ) {
$gridCells = Html::rawElement(
'div',
[ 'class' => 'searchResultImage-thumbnail' ],
$thumb
) . Html::rawElement(
'div',
[ 'class' => 'searchResultImage-text' ],
$html
);
$html = Html::rawElement(
'div',
[ 'class' => 'searchResultImage' ],
$gridCells
);
}
return Html::rawElement(
'li',
[ 'class' => [ 'mw-search-result', 'mw-search-result-ns-' . $result->getTitle()->getNamespace() ] ],
$html
);
}
/**
* Generates HTML for the primary call to action. It is
* typically the article title, but the search engine can
* return an exact snippet to use (typically the article
* title with highlighted words).
*
* @param SearchResult $result
* @param int $position
* @return string HTML
*/
protected function generateMainLinkHtml( SearchResult $result, $position ) {
$snippet = $result->getTitleSnippet();
if ( $snippet === '' ) {
$snippet = null;
} else {
$snippet = new HtmlArmor( $snippet );
}
// clone to prevent hook from changing the title stored inside $result
$title = clone $result->getTitle();
$query = [];
$attributes = [ 'data-serp-pos' => $position ];
$this->hookRunner->onShowSearchHitTitle( $title, $snippet, $result,
$result instanceof \SqlSearchResult ? $result->getTermMatches() : [],
// @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
$this->specialPage, $query, $attributes );
$link = $this->linkRenderer->makeLink(
$title,
$snippet,
$attributes,
$query
);
return $link;
}
/**
* Generates an alternate title link, such as (redirect from <a>Foo</a>).
*
* @param string $msgKey i18n message used to wrap title
* @param Title|null $title The title to link to, or null to generate
* the message without a link. In that case $text must be non-null.
* @param string|null $text The text snippet to display, or null
* to use the title
* @return string HTML
*/
protected function generateAltTitleHtml( $msgKey, ?Title $title, $text ) {
$inner = $title === null
? $text
: $this->linkRenderer->makeLink( $title, $text ? new HtmlArmor( $text ) : null );
return "<span class='searchalttitle'>" .
$this->specialPage->msg( $msgKey )->rawParams( $inner )->parse()
. "</span>";
}
/**
* @param SearchResult $result
* @return string HTML
*/
protected function generateRedirectHtml( SearchResult $result ) {
$title = $result->getRedirectTitle();
return $title === null
? ''
: $this->generateAltTitleHtml( 'search-redirect', $title, $result->getRedirectSnippet() );
}
/**
* @param SearchResult $result
* @return string HTML
*/
protected function generateSectionHtml( SearchResult $result ) {
$title = $result->getSectionTitle();
return $title === null
? ''
: $this->generateAltTitleHtml( 'search-section', $title, $result->getSectionSnippet() );
}
/**
* @param SearchResult $result
* @return string HTML
*/
protected function generateCategoryHtml( SearchResult $result ) {
$snippet = $result->getCategorySnippet();
return $snippet
? $this->generateAltTitleHtml( 'search-category', null, $snippet )
: '';
}
/**
* @param SearchResult $result
* @return string HTML
*/
protected function generateSizeHtml( SearchResult $result ) {
$title = $result->getTitle();
if ( $title->getNamespace() === NS_CATEGORY ) {
$cat = Category::newFromTitle( $title );
return $this->specialPage->msg( 'search-result-category-size' )
->numParams( $cat->getMemberCount(), $cat->getSubcatCount(), $cat->getFileCount() )
->escaped();
// TODO: This is a bit odd...but requires changing the i18n message to fix
} elseif ( $result->getByteSize() !== null || $result->getWordCount() > 0 ) {
return $this->specialPage->msg( 'search-result-size' )
->sizeParams( $result->getByteSize() )
->numParams( $result->getWordCount() )
->escaped();
}
return '';
}
/**
* @param SearchResult $result
* @return array Three element array containing the main file html,
* a text description of the file, and finally the thumbnail html.
* If no thumbnail is available the second and third will be null.
*/
protected function generateFileHtml( SearchResult $result ) {
$title = $result->getTitle();
// don't assume that result is a valid title; e.g. could be an interwiki link target
if ( $title === null || !$title->canExist() ) {
return [ '', null, null ];
}
$html = '';
if ( $result->isFileMatch() ) {
$html = Html::rawElement(
'span',
[ 'class' => 'searchalttitle' ],
$this->specialPage->msg( 'search-file-match' )->escaped()
);
}
$allowExtraThumbsFromRequest = $this->specialPage->getRequest()->getVal( 'search-thumbnail-extra-namespaces' );
$allowExtraThumbsFromPreference = $this->userOptionsManager->getOption(
$this->specialPage->getUser(),
'search-thumbnail-extra-namespaces'
);
$allowExtraThumbs = (bool)( $allowExtraThumbsFromRequest ?? $allowExtraThumbsFromPreference );
if ( !$allowExtraThumbs && $title->getNamespace() !== NS_FILE ) {
return [ $html, null, null ];
}
$thumbnail = $this->getThumbnail( $result, self::THUMBNAIL_SIZE );
$thumbnailName = $thumbnail ? $thumbnail->getName() : null;
if ( !$thumbnailName ) {
return [ $html, null, $this->generateThumbnailHtml( $result ) ];
}
$img = $this->repoGroup->findFile( $thumbnailName );
if ( !$img ) {
return [ $html, null, $this->generateThumbnailHtml( $result ) ];
}
return [
$html,
$this->specialPage->msg( 'parentheses' )->rawParams( $img->getShortDesc() )->escaped(),
$this->generateThumbnailHtml( $result, $thumbnail )
];
}
/**
* @param SearchResult $result
* @param int $size
* @return SearchResultThumbnail|null
*/
private function getThumbnail( SearchResult $result, int $size ): ?SearchResultThumbnail {
$title = $result->getTitle();
// don't assume that result is a valid title; e.g. could be an interwiki link target
if ( $title === null || !$title->canExist() ) {
return null;
}
$thumbnails = $this->thumbnailProvider->getThumbnails( [ $title->getArticleID() => $title ], $size );
return $thumbnails[ $title->getArticleID() ] ?? null;
}
/**
* @param SearchResult $result
* @param SearchResultThumbnail|null $thumbnail
* @return string|null
*/
private function generateThumbnailHtml( SearchResult $result, SearchResultThumbnail $thumbnail = null ): ?string {
$title = $result->getTitle();
// don't assume that result is a valid title; e.g. could be an interwiki link target
if ( $title === null || !$title->canExist() ) {
return null;
}
$namespacesWithThumbnails = $this->specialPage->getConfig()->get( MainConfigNames::ThumbnailNamespaces );
$showThumbnail = in_array( $title->getNamespace(), $namespacesWithThumbnails );
if ( !$showThumbnail ) {
return null;
}
$thumbnailName = $thumbnail ? $thumbnail->getName() : null;
if ( !$thumbnail || !$thumbnailName ) {
return $this->generateThumbnailPlaceholderHtml();
}
$img = $this->repoGroup->findFile( $thumbnailName );
if ( !$img ) {
return $this->generateThumbnailPlaceholderHtml();
}
$thumb = $this->transformThumbnail( $img, $thumbnail );
if ( $thumb ) {
if ( $title->getNamespace() === NS_FILE ) {
// don't use a custom link, just use traditional thumbnail HTML
return $thumb->toHtml( [
'desc-link' => true,
'loading' => 'lazy',
'alt' => $this->specialPage->msg( 'search-thumbnail-alt' )->params( $title ),
] );
}
// thumbnails for non-file results should link to the relevant title
return $thumb->toHtml( [
'desc-link' => true,
'custom-title-link' => $title,
'loading' => 'lazy',
'alt' => $this->specialPage->msg( 'search-thumbnail-alt' )->params( $title ),
] );
}
return $this->generateThumbnailPlaceholderHtml();
}
/**
* @param File $img
* @param SearchResultThumbnail $thumbnail
* @return ThumbnailImage|MediaTransformOutput|bool False on failure
*/
private function transformThumbnail( File $img, SearchResultThumbnail $thumbnail ) {
$optimalThumbnailWidth = $thumbnail->getWidth();
// $thumb will have rescaled to fit within a <$size>x<$size> bounding
// box, but we want it to cover a full square (at the cost of losing
// some of the edges)
// instead of the largest side matching up with $size, we want the
// smallest size to match (or exceed) $size
$thumbnailMaxDimension = max( $thumbnail->getWidth(), $thumbnail->getHeight() );
$thumbnailMinDimension = min( $thumbnail->getWidth(), $thumbnail->getHeight() );
$rescaleCoefficient = $thumbnailMinDimension
? $thumbnailMaxDimension / $thumbnailMinDimension : 1;
// we'll only deal with width from now on since conventions for
// standard sizes have formed around width; height will simply
// follow according to aspect ratio
$rescaledWidth = (int)round( $rescaleCoefficient * $thumbnail->getWidth() );
// we'll also be looking at $wgThumbLimits to ensure that we pick
// from within the predefined list of sizes
// NOTE: only do this when there is a difference in the rescaled
// size vs the original thumbnail size - some media types are
// different and thumb limits don't matter (e.g. for audio, the
// player must remain at the size we want, regardless of whether or
// not it fits the thumb limits, which in this case are irrelevant)
if ( $rescaledWidth !== $thumbnail->getWidth() ) {
$thumbLimits = $this->specialPage->getConfig()->get( MainConfigNames::ThumbLimits );
$largerThumbLimits = array_filter(
$thumbLimits,
static function ( $limit ) use ( $rescaledWidth ) {
return $limit >= $rescaledWidth;
}
);
$optimalThumbnailWidth = $largerThumbLimits ? min( $largerThumbLimits ) : max( $thumbLimits );
}
return $img->transform( [ 'width' => $optimalThumbnailWidth ] );
}
/**
* @return string
*/
private function generateThumbnailPlaceholderHtml(): string {
if ( $this->thumbnailPlaceholderHtml ) {
return $this->thumbnailPlaceholderHtml;
}
$path = MW_INSTALL_PATH . '/resources/lib/ooui/themes/wikimediaui/images/icons/imageLayoutFrameless.svg';
$this->thumbnailPlaceholderHtml = Html::rawElement(
'div',
[
'class' => 'searchResultImage-thumbnail-placeholder',
'aria-hidden' => 'true',
],
file_get_contents( $path )
);
return $this->thumbnailPlaceholderHtml;
}
/**
* @param string $desc HTML description of result, ex: size in bytes, or empty string
* @param string $date HTML representation of last edit date, or empty string
* @return string HTML A div combining $desc and $date with a separator in a <div>.
* If either is missing only one will be represented. If both are missing an empty
* string will be returned.
*/
protected function buildMeta( $desc, $date ) {
if ( $desc && $date ) {
$meta = "{$desc} - {$date}";
} elseif ( $desc ) {
$meta = $desc;
} elseif ( $date ) {
$meta = $date;
} else {
return '';
}
return "<div class='mw-search-result-data'>{$meta}</div>";
}
}