MediaWiki master
FullSearchResultWidget.php
Go to the documentation of this file.
1<?php
2
4
5use File;
6use HtmlArmor;
18use RepoGroup;
19use SearchResult;
21
31 public const THUMBNAIL_SIZE = 90;
33 protected $specialPage;
35 protected $linkRenderer;
37 private $hookRunner;
39 private $repoGroup;
41 private $thumbnailProvider;
43 private $thumbnailPlaceholderHtml;
45 private $userOptionsManager;
46
47 public function __construct(
50 HookContainer $hookContainer,
51 RepoGroup $repoGroup,
52 SearchResultThumbnailProvider $thumbnailProvider,
53 UserOptionsManager $userOptionsManager
54 ) {
55 $this->specialPage = $specialPage;
56 $this->linkRenderer = $linkRenderer;
57 $this->hookRunner = new HookRunner( $hookContainer );
58 $this->repoGroup = $repoGroup;
59 $this->thumbnailProvider = $thumbnailProvider;
60 $this->userOptionsManager = $userOptionsManager;
61 }
62
68 public function render( SearchResult $result, $position ) {
69 // If the page doesn't *exist*... our search index is out of date.
70 // The least confusing at this point is to drop the result.
71 // You may get less results, but... on well. :P
72 if ( $result->isBrokenTitle() || $result->isMissingRevision() ) {
73 return '';
74 }
75
76 $link = $this->generateMainLinkHtml( $result, $position );
77 // If page content is not readable, just return ths title.
78 // This is not quite safe, but better than showing excerpts from
79 // non-readable pages. Note that hiding the entry entirely would
80 // screw up paging (really?).
81 if ( !$this->specialPage->getAuthority()->definitelyCan( 'read', $result->getTitle() ) ) {
82 return Html::rawElement( 'li', [], $link );
83 }
84
85 $redirect = $this->generateRedirectHtml( $result );
86 $section = $this->generateSectionHtml( $result );
87 $category = $this->generateCategoryHtml( $result );
88 $date = htmlspecialchars(
89 $this->specialPage->getLanguage()->userTimeAndDate(
90 $result->getTimestamp(),
91 $this->specialPage->getUser()
92 )
93 );
94 [ $file, $desc, $thumb ] = $this->generateFileHtml( $result );
95 $snippet = $result->getTextSnippet();
96 if ( $snippet ) {
97 $snippetWithEllipsis = $snippet . $this->specialPage->msg( 'ellipsis' );
98 $extract = Html::rawElement( 'div', [ 'class' => 'searchresult' ], $snippetWithEllipsis );
99 } else {
100 $extract = '';
101 }
102
103 if ( $result->getTitle() && $result->getTitle()->getNamespace() !== NS_FILE ) {
104 // If no file, then the description is about size
105 $desc = $this->generateSizeHtml( $result );
106
107 // Let hooks do their own final construction if desired.
108 // FIXME: Not sure why this is only for results without thumbnails,
109 // but keeping it as-is for now to prevent breaking hook consumers.
110 $html = null;
111 $score = '';
112 $related = '';
113 // TODO: remove this instanceof and always pass [], let implementors do the cast if
114 // they want to be SearchDatabase specific
115 $terms = $result instanceof \SqlSearchResult ? $result->getTermMatches() : [];
116 if ( !$this->hookRunner->onShowSearchHit( $this->specialPage, $result,
117 $terms, $link, $redirect, $section, $extract, $score,
118 // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
119 $desc, $date, $related, $html )
120 ) {
121 return $html;
122 }
123 }
124
125 // All the pieces have been collected. Now generate the final HTML
126 $joined = "{$link} {$redirect} {$category} {$section} {$file}";
127 $meta = $this->buildMeta( $desc, $date );
128
129 // Text portion of the search result
130 $html = Html::rawElement(
131 'div',
132 [ 'class' => 'mw-search-result-heading' ],
133 $joined
134 );
135 $html .= $extract . ' ' . $meta;
136
137 // If the result has a thumbnail, wrap a table around it and the text
138 if ( $thumb ) {
139 $tableCells = Html::rawElement(
140 'td',
141 [ 'class' => 'searchResultImage-thumbnail' ],
142 $thumb
143 ) . Html::rawElement(
144 'td',
145 [ 'class' => 'searchResultImage-text' ],
146 $html
147 );
148 $html = Html::rawElement(
149 'table',
150 [ 'class' => 'searchResultImage' ],
151 Html::rawElement(
152 'tr',
153 [],
154 $tableCells
155 )
156 );
157 }
158
159 return Html::rawElement(
160 'li',
161 [ 'class' => [ 'mw-search-result', 'mw-search-result-ns-' . $result->getTitle()->getNamespace() ] ],
162 $html
163 );
164 }
165
176 protected function generateMainLinkHtml( SearchResult $result, $position ) {
177 $snippet = $result->getTitleSnippet();
178 if ( $snippet === '' ) {
179 $snippet = null;
180 } else {
181 $snippet = new HtmlArmor( $snippet );
182 }
183
184 // clone to prevent hook from changing the title stored inside $result
185 $title = clone $result->getTitle();
186 $query = [];
187
188 $attributes = [ 'data-serp-pos' => $position ];
189 $this->hookRunner->onShowSearchHitTitle( $title, $snippet, $result,
190 $result instanceof \SqlSearchResult ? $result->getTermMatches() : [],
191 // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
192 $this->specialPage, $query, $attributes );
193
194 $link = $this->linkRenderer->makeLink(
195 $title,
196 $snippet,
197 $attributes,
198 $query
199 );
200
201 return $link;
202 }
203
214 protected function generateAltTitleHtml( $msgKey, ?Title $title, $text ) {
215 $inner = $title === null
216 ? $text
217 : $this->linkRenderer->makeLink( $title, $text ? new HtmlArmor( $text ) : null );
218
219 return "<span class='searchalttitle'>" .
220 $this->specialPage->msg( $msgKey )->rawParams( $inner )->parse()
221 . "</span>";
222 }
223
228 protected function generateRedirectHtml( SearchResult $result ) {
229 $title = $result->getRedirectTitle();
230 return $title === null
231 ? ''
232 : $this->generateAltTitleHtml( 'search-redirect', $title, $result->getRedirectSnippet() );
233 }
234
239 protected function generateSectionHtml( SearchResult $result ) {
240 $title = $result->getSectionTitle();
241 return $title === null
242 ? ''
243 : $this->generateAltTitleHtml( 'search-section', $title, $result->getSectionSnippet() );
244 }
245
250 protected function generateCategoryHtml( SearchResult $result ) {
251 $snippet = $result->getCategorySnippet();
252 return $snippet
253 ? $this->generateAltTitleHtml( 'search-category', null, $snippet )
254 : '';
255 }
256
261 protected function generateSizeHtml( SearchResult $result ) {
262 $title = $result->getTitle();
263 if ( $title->getNamespace() === NS_CATEGORY ) {
264 $cat = Category::newFromTitle( $title );
265 return $this->specialPage->msg( 'search-result-category-size' )
266 ->numParams( $cat->getMemberCount(), $cat->getSubcatCount(), $cat->getFileCount() )
267 ->escaped();
268 // TODO: This is a bit odd...but requires changing the i18n message to fix
269 } elseif ( $result->getByteSize() !== null || $result->getWordCount() > 0 ) {
270 return $this->specialPage->msg( 'search-result-size' )
271 ->sizeParams( $result->getByteSize() )
272 ->numParams( $result->getWordCount() )
273 ->escaped();
274 }
275
276 return '';
277 }
278
285 protected function generateFileHtml( SearchResult $result ) {
286 $title = $result->getTitle();
287 // don't assume that result is a valid title; e.g. could be an interwiki link target
288 if ( $title === null || !$title->canExist() ) {
289 return [ '', null, null ];
290 }
291
292 $html = '';
293 if ( $result->isFileMatch() ) {
294 $html = Html::rawElement(
295 'span',
296 [ 'class' => 'searchalttitle' ],
297 $this->specialPage->msg( 'search-file-match' )->escaped()
298 );
299 }
300
301 $allowExtraThumbsFromRequest = $this->specialPage->getRequest()->getVal( 'search-thumbnail-extra-namespaces' );
302 $allowExtraThumbsFromPreference = $this->userOptionsManager->getOption(
303 $this->specialPage->getUser(),
304 'search-thumbnail-extra-namespaces'
305 );
306 $allowExtraThumbs = (bool)( $allowExtraThumbsFromRequest ?? $allowExtraThumbsFromPreference );
307 if ( !$allowExtraThumbs && $title->getNamespace() !== NS_FILE ) {
308 return [ $html, null, null ];
309 }
310
311 $thumbnail = $this->getThumbnail( $result, self::THUMBNAIL_SIZE );
312 $thumbnailName = $thumbnail ? $thumbnail->getName() : null;
313 if ( !$thumbnailName ) {
314 return [ $html, null, $this->generateThumbnailHtml( $result ) ];
315 }
316
317 $img = $this->repoGroup->findFile( $thumbnailName );
318 if ( !$img ) {
319 return [ $html, null, $this->generateThumbnailHtml( $result ) ];
320 }
321
322 return [
323 $html,
324 $this->specialPage->msg( 'parentheses' )->rawParams( $img->getShortDesc() )->escaped(),
325 $this->generateThumbnailHtml( $result, $thumbnail )
326 ];
327 }
328
334 private function getThumbnail( SearchResult $result, int $size ): ?SearchResultThumbnail {
335 $title = $result->getTitle();
336 // don't assume that result is a valid title; e.g. could be an interwiki link target
337 if ( $title === null || !$title->canExist() ) {
338 return null;
339 }
340
341 $thumbnails = $this->thumbnailProvider->getThumbnails( [ $title->getArticleID() => $title ], $size );
342
343 return $thumbnails[ $title->getArticleID() ] ?? null;
344 }
345
351 private function generateThumbnailHtml( SearchResult $result, SearchResultThumbnail $thumbnail = null ): ?string {
352 $title = $result->getTitle();
353 // don't assume that result is a valid title; e.g. could be an interwiki link target
354 if ( $title === null || !$title->canExist() ) {
355 return null;
356 }
357
358 $namespacesWithThumbnails = $this->specialPage->getConfig()->get( 'ThumbnailNamespaces' );
359 $showThumbnail = in_array( $title->getNamespace(), $namespacesWithThumbnails );
360 if ( !$showThumbnail ) {
361 return null;
362 }
363
364 $thumbnailName = $thumbnail ? $thumbnail->getName() : null;
365 if ( !$thumbnail || !$thumbnailName ) {
366 return $this->generateThumbnailPlaceholderHtml();
367 }
368
369 $img = $this->repoGroup->findFile( $thumbnailName );
370 if ( !$img ) {
371 return $this->generateThumbnailPlaceholderHtml();
372 }
373
374 $thumb = $this->transformThumbnail( $img, $thumbnail );
375 if ( $thumb ) {
376 if ( $title->getNamespace() === NS_FILE ) {
377 // don't use a custom link, just use traditional thumbnail HTML
378 return $thumb->toHtml( [
379 'desc-link' => true,
380 'loading' => 'lazy',
381 'alt' => $this->specialPage->msg( 'search-thumbnail-alt' )->params( $title ),
382 ] );
383 }
384
385 // thumbnails for non-file results should link to the relevant title
386 return $thumb->toHtml( [
387 'desc-link' => true,
388 'custom-title-link' => $title,
389 'loading' => 'lazy',
390 'alt' => $this->specialPage->msg( 'search-thumbnail-alt' )->params( $title ),
391 ] );
392 }
393
394 return $this->generateThumbnailPlaceholderHtml();
395 }
396
402 private function transformThumbnail( File $img, SearchResultThumbnail $thumbnail ) {
403 $optimalThumbnailWidth = $thumbnail->getWidth();
404
405 // $thumb will have rescaled to fit within a <$size>x<$size> bounding
406 // box, but we want it to cover a full square (at the cost of losing
407 // some of the edges)
408 // instead of the largest side matching up with $size, we want the
409 // smallest size to match (or exceed) $size
410 $thumbnailMaxDimension = max( $thumbnail->getWidth(), $thumbnail->getHeight() );
411 $thumbnailMinDimension = min( $thumbnail->getWidth(), $thumbnail->getHeight() );
412 $rescaleCoefficient = $thumbnailMinDimension
413 ? $thumbnailMaxDimension / $thumbnailMinDimension : 1;
414
415 // we'll only deal with width from now on since conventions for
416 // standard sizes have formed around width; height will simply
417 // follow according to aspect ratio
418 $rescaledWidth = (int)round( $rescaleCoefficient * $thumbnail->getWidth() );
419
420 // we'll also be looking at $wgThumbLimits to ensure that we pick
421 // from within the predefined list of sizes
422 // NOTE: only do this when there is a difference in the rescaled
423 // size vs the original thumbnail size - some media types are
424 // different and thumb limits don't matter (e.g. for audio, the
425 // player must remain at the size we want, regardless of whether or
426 // not it fits the thumb limits, which in this case are irrelevant)
427 if ( $rescaledWidth !== $thumbnail->getWidth() ) {
428 $thumbLimits = $this->specialPage->getConfig()->get( 'ThumbLimits' );
429 $largerThumbLimits = array_filter(
430 $thumbLimits,
431 static function ( $limit ) use ( $rescaledWidth ) {
432 return $limit >= $rescaledWidth;
433 }
434 );
435 $optimalThumbnailWidth = $largerThumbLimits ? min( $largerThumbLimits ) : max( $thumbLimits );
436 }
437
438 return $img->transform( [ 'width' => $optimalThumbnailWidth ] );
439 }
440
444 private function generateThumbnailPlaceholderHtml(): string {
445 if ( $this->thumbnailPlaceholderHtml ) {
446 return $this->thumbnailPlaceholderHtml;
447 }
448
449 $path = MW_INSTALL_PATH . '/resources/lib/ooui/themes/wikimediaui/images/icons/imageLayoutFrameless.svg';
450 $this->thumbnailPlaceholderHtml = Html::rawElement(
451 'div',
452 [
453 'class' => 'searchResultImage-thumbnail-placeholder',
454 'aria-hidden' => 'true',
455 ],
456 file_get_contents( $path )
457 );
458 return $this->thumbnailPlaceholderHtml;
459 }
460
468 protected function buildMeta( $desc, $date ) {
469 if ( $desc && $date ) {
470 $meta = "{$desc} - {$date}";
471 } elseif ( $desc ) {
472 $meta = $desc;
473 } elseif ( $date ) {
474 $meta = $date;
475 } else {
476 return '';
477 }
478
479 return "<div class='mw-search-result-data'>{$meta}</div>";
480 }
481}
const NS_FILE
Definition Defines.php:70
const NS_CATEGORY
Definition Defines.php:78
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:81
Implements some public methods and some protected utility functions which are required by multiple ch...
Definition File.php:73
getShortDesc()
Definition File.php:2394
transform( $params, $flags=0)
Transform a media file.
Definition File.php:1177
Marks HTML that shouldn't be escaped.
Definition HtmlArmor.php:30
Base class for the output of MediaHandler::doTransform() and File::transform().
Category objects are immutable, strictly speaking.
Definition Category.php:42
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
This class is a collection of static functions that serve two purposes:
Definition Html.php:56
Class that generates HTML for internal links.
Class that stores information about thumbnail, e.
Renders a 'full' multi-line search result with metadata.
generateAltTitleHtml( $msgKey, ?Title $title, $text)
Generates an alternate title link, such as (redirect from Foo).
generateMainLinkHtml(SearchResult $result, $position)
Generates HTML for the primary call to action.
__construct(SpecialSearch $specialPage, LinkRenderer $linkRenderer, HookContainer $hookContainer, RepoGroup $repoGroup, SearchResultThumbnailProvider $thumbnailProvider, UserOptionsManager $userOptionsManager)
implements Special:Search - Run text & title search and display the output
Represents a title within MediaWiki.
Definition Title.php:78
A service class to control user options.
Prioritized list of file repositories.
Definition RepoGroup.php:30
NOTE: this class is being refactored into an abstract base class.
Media transform output for images.
Renders a single search result to HTML.