MediaWiki master
FullSearchResultWidget.php
Go to the documentation of this file.
1<?php
2
4
5use File;
6use HtmlArmor;
19use RepoGroup;
20use SearchResult;
22
31
32 public const THUMBNAIL_SIZE = 90;
33
36 private HookRunner $hookRunner;
37 private RepoGroup $repoGroup;
38 private SearchResultThumbnailProvider $thumbnailProvider;
39 private UserOptionsManager $userOptionsManager;
40
42 private $thumbnailPlaceholderHtml;
43
44 public function __construct(
47 HookContainer $hookContainer,
48 RepoGroup $repoGroup,
49 SearchResultThumbnailProvider $thumbnailProvider,
50 UserOptionsManager $userOptionsManager
51 ) {
52 $this->specialPage = $specialPage;
53 $this->linkRenderer = $linkRenderer;
54 $this->hookRunner = new HookRunner( $hookContainer );
55 $this->repoGroup = $repoGroup;
56 $this->thumbnailProvider = $thumbnailProvider;
57 $this->userOptionsManager = $userOptionsManager;
58 }
59
65 public function render( SearchResult $result, $position ) {
66 // If the page doesn't *exist*... our search index is out of date.
67 // The least confusing at this point is to drop the result.
68 // You may get less results, but... on well. :P
69 if ( $result->isBrokenTitle() || $result->isMissingRevision() ) {
70 return '';
71 }
72
73 $link = $this->generateMainLinkHtml( $result, $position );
74 // If page content is not readable, just return ths title.
75 // This is not quite safe, but better than showing excerpts from
76 // non-readable pages. Note that hiding the entry entirely would
77 // screw up paging (really?).
78 if ( !$this->specialPage->getAuthority()->definitelyCan( 'read', $result->getTitle() ) ) {
79 return Html::rawElement( 'li', [], $link );
80 }
81
82 $redirect = $this->generateRedirectHtml( $result );
83 $section = $this->generateSectionHtml( $result );
84 $category = $this->generateCategoryHtml( $result );
85 $date = htmlspecialchars(
86 $this->specialPage->getLanguage()->userTimeAndDate(
87 $result->getTimestamp(),
88 $this->specialPage->getUser()
89 )
90 );
91 [ $file, $desc, $thumb ] = $this->generateFileHtml( $result );
92 $snippet = $result->getTextSnippet();
93 if ( $snippet ) {
94 $snippetWithEllipsis = $snippet . $this->specialPage->msg( 'ellipsis' );
95 $extract = Html::rawElement( 'div', [ 'class' => 'searchresult' ], $snippetWithEllipsis );
96 } else {
97 $extract = '';
98 }
99
100 if ( $result->getTitle() && $result->getTitle()->getNamespace() !== NS_FILE ) {
101 // If no file, then the description is about size
102 $desc = $this->generateSizeHtml( $result );
103
104 // Let hooks do their own final construction if desired.
105 // FIXME: Not sure why this is only for results without thumbnails,
106 // but keeping it as-is for now to prevent breaking hook consumers.
107 $html = null;
108 $score = '';
109 $related = '';
110 // TODO: remove this instanceof and always pass [], let implementors do the cast if
111 // they want to be SearchDatabase specific
112 $terms = $result instanceof \SqlSearchResult ? $result->getTermMatches() : [];
113 if ( !$this->hookRunner->onShowSearchHit( $this->specialPage, $result,
114 $terms, $link, $redirect, $section, $extract, $score,
115 // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
116 $desc, $date, $related, $html )
117 ) {
118 return $html;
119 }
120 }
121
122 // All the pieces have been collected. Now generate the final HTML
123 $joined = "{$link} {$redirect} {$category} {$section} {$file}";
124 $meta = $this->buildMeta( $desc, $date );
125
126 // Text portion of the search result
127 $html = Html::rawElement(
128 'div',
129 [ 'class' => 'mw-search-result-heading' ],
130 $joined
131 );
132 $html .= $extract . ' ' . $meta;
133
134 // If the result has a thumbnail, place it next to the text block
135 if ( $thumb ) {
136 $gridCells = Html::rawElement(
137 'div',
138 [ 'class' => 'searchResultImage-thumbnail' ],
139 $thumb
140 ) . Html::rawElement(
141 'div',
142 [ 'class' => 'searchResultImage-text' ],
143 $html
144 );
145 $html = Html::rawElement(
146 'div',
147 [ 'class' => 'searchResultImage' ],
148 $gridCells
149 );
150 }
151
152 return Html::rawElement(
153 'li',
154 [ 'class' => [ 'mw-search-result', 'mw-search-result-ns-' . $result->getTitle()->getNamespace() ] ],
155 $html
156 );
157 }
158
169 protected function generateMainLinkHtml( SearchResult $result, $position ) {
170 $snippet = $result->getTitleSnippet();
171 if ( $snippet === '' ) {
172 $snippet = null;
173 } else {
174 $snippet = new HtmlArmor( $snippet );
175 }
176
177 // clone to prevent hook from changing the title stored inside $result
178 $title = clone $result->getTitle();
179 $query = [];
180
181 $attributes = [ 'data-serp-pos' => $position ];
182 $this->hookRunner->onShowSearchHitTitle( $title, $snippet, $result,
183 $result instanceof \SqlSearchResult ? $result->getTermMatches() : [],
184 // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
185 $this->specialPage, $query, $attributes );
186
187 $link = $this->linkRenderer->makeLink(
188 $title,
189 $snippet,
190 $attributes,
191 $query
192 );
193
194 return $link;
195 }
196
207 protected function generateAltTitleHtml( $msgKey, ?Title $title, $text ) {
208 $inner = $title === null
209 ? $text
210 : $this->linkRenderer->makeLink( $title, $text ? new HtmlArmor( $text ) : null );
211
212 return "<span class='searchalttitle'>" .
213 $this->specialPage->msg( $msgKey )->rawParams( $inner )->parse()
214 . "</span>";
215 }
216
221 protected function generateRedirectHtml( SearchResult $result ) {
222 $title = $result->getRedirectTitle();
223 return $title === null
224 ? ''
225 : $this->generateAltTitleHtml( 'search-redirect', $title, $result->getRedirectSnippet() );
226 }
227
232 protected function generateSectionHtml( SearchResult $result ) {
233 $title = $result->getSectionTitle();
234 return $title === null
235 ? ''
236 : $this->generateAltTitleHtml( 'search-section', $title, $result->getSectionSnippet() );
237 }
238
243 protected function generateCategoryHtml( SearchResult $result ) {
244 $snippet = $result->getCategorySnippet();
245 return $snippet
246 ? $this->generateAltTitleHtml( 'search-category', null, $snippet )
247 : '';
248 }
249
254 protected function generateSizeHtml( SearchResult $result ) {
255 $title = $result->getTitle();
256 if ( $title->getNamespace() === NS_CATEGORY ) {
257 $cat = Category::newFromTitle( $title );
258 return $this->specialPage->msg( 'search-result-category-size' )
259 ->numParams( $cat->getMemberCount(), $cat->getSubcatCount(), $cat->getFileCount() )
260 ->escaped();
261 // TODO: This is a bit odd...but requires changing the i18n message to fix
262 } elseif ( $result->getByteSize() !== null || $result->getWordCount() > 0 ) {
263 return $this->specialPage->msg( 'search-result-size' )
264 ->sizeParams( $result->getByteSize() )
265 ->numParams( $result->getWordCount() )
266 ->escaped();
267 }
268
269 return '';
270 }
271
278 protected function generateFileHtml( SearchResult $result ) {
279 $title = $result->getTitle();
280 // don't assume that result is a valid title; e.g. could be an interwiki link target
281 if ( $title === null || !$title->canExist() ) {
282 return [ '', null, null ];
283 }
284
285 $html = '';
286 if ( $result->isFileMatch() ) {
287 $html = Html::rawElement(
288 'span',
289 [ 'class' => 'searchalttitle' ],
290 $this->specialPage->msg( 'search-file-match' )->escaped()
291 );
292 }
293
294 $allowExtraThumbsFromRequest = $this->specialPage->getRequest()->getVal( 'search-thumbnail-extra-namespaces' );
295 $allowExtraThumbsFromPreference = $this->userOptionsManager->getOption(
296 $this->specialPage->getUser(),
297 'search-thumbnail-extra-namespaces'
298 );
299 $allowExtraThumbs = (bool)( $allowExtraThumbsFromRequest ?? $allowExtraThumbsFromPreference );
300 if ( !$allowExtraThumbs && $title->getNamespace() !== NS_FILE ) {
301 return [ $html, null, null ];
302 }
303
304 $thumbnail = $this->getThumbnail( $result, self::THUMBNAIL_SIZE );
305 $thumbnailName = $thumbnail ? $thumbnail->getName() : null;
306 if ( !$thumbnailName ) {
307 return [ $html, null, $this->generateThumbnailHtml( $result ) ];
308 }
309
310 $img = $this->repoGroup->findFile( $thumbnailName );
311 if ( !$img ) {
312 return [ $html, null, $this->generateThumbnailHtml( $result ) ];
313 }
314
315 return [
316 $html,
317 $this->specialPage->msg( 'parentheses' )->rawParams( $img->getShortDesc() )->escaped(),
318 $this->generateThumbnailHtml( $result, $thumbnail )
319 ];
320 }
321
327 private function getThumbnail( SearchResult $result, int $size ): ?SearchResultThumbnail {
328 $title = $result->getTitle();
329 // don't assume that result is a valid title; e.g. could be an interwiki link target
330 if ( $title === null || !$title->canExist() ) {
331 return null;
332 }
333
334 $thumbnails = $this->thumbnailProvider->getThumbnails( [ $title->getArticleID() => $title ], $size );
335
336 return $thumbnails[ $title->getArticleID() ] ?? null;
337 }
338
344 private function generateThumbnailHtml( SearchResult $result, ?SearchResultThumbnail $thumbnail = null ): ?string {
345 $title = $result->getTitle();
346 // don't assume that result is a valid title; e.g. could be an interwiki link target
347 if ( $title === null || !$title->canExist() ) {
348 return null;
349 }
350
351 $namespacesWithThumbnails = $this->specialPage->getConfig()->get( MainConfigNames::ThumbnailNamespaces );
352 $showThumbnail = in_array( $title->getNamespace(), $namespacesWithThumbnails );
353 if ( !$showThumbnail ) {
354 return null;
355 }
356
357 $thumbnailName = $thumbnail ? $thumbnail->getName() : null;
358 if ( !$thumbnail || !$thumbnailName ) {
359 return $this->generateThumbnailPlaceholderHtml();
360 }
361
362 $img = $this->repoGroup->findFile( $thumbnailName );
363 if ( !$img ) {
364 return $this->generateThumbnailPlaceholderHtml();
365 }
366
367 $thumb = $this->transformThumbnail( $img, $thumbnail );
368 if ( $thumb ) {
369 if ( $title->getNamespace() === NS_FILE ) {
370 // don't use a custom link, just use traditional thumbnail HTML
371 return $thumb->toHtml( [
372 'desc-link' => true,
373 'loading' => 'lazy',
374 'alt' => $this->specialPage->msg( 'search-thumbnail-alt' )->params( $title ),
375 ] );
376 }
377
378 // thumbnails for non-file results should link to the relevant title
379 return $thumb->toHtml( [
380 'desc-link' => true,
381 'custom-title-link' => $title,
382 'loading' => 'lazy',
383 'alt' => $this->specialPage->msg( 'search-thumbnail-alt' )->params( $title ),
384 ] );
385 }
386
387 return $this->generateThumbnailPlaceholderHtml();
388 }
389
395 private function transformThumbnail( File $img, SearchResultThumbnail $thumbnail ) {
396 $optimalThumbnailWidth = $thumbnail->getWidth();
397
398 // $thumb will have rescaled to fit within a <$size>x<$size> bounding
399 // box, but we want it to cover a full square (at the cost of losing
400 // some of the edges)
401 // instead of the largest side matching up with $size, we want the
402 // smallest size to match (or exceed) $size
403 $thumbnailMaxDimension = max( $thumbnail->getWidth(), $thumbnail->getHeight() );
404 $thumbnailMinDimension = min( $thumbnail->getWidth(), $thumbnail->getHeight() );
405 $rescaleCoefficient = $thumbnailMinDimension
406 ? $thumbnailMaxDimension / $thumbnailMinDimension : 1;
407
408 // we'll only deal with width from now on since conventions for
409 // standard sizes have formed around width; height will simply
410 // follow according to aspect ratio
411 $rescaledWidth = (int)round( $rescaleCoefficient * $thumbnail->getWidth() );
412
413 // we'll also be looking at $wgThumbLimits to ensure that we pick
414 // from within the predefined list of sizes
415 // NOTE: only do this when there is a difference in the rescaled
416 // size vs the original thumbnail size - some media types are
417 // different and thumb limits don't matter (e.g. for audio, the
418 // player must remain at the size we want, regardless of whether or
419 // not it fits the thumb limits, which in this case are irrelevant)
420 if ( $rescaledWidth !== $thumbnail->getWidth() ) {
421 $thumbLimits = $this->specialPage->getConfig()->get( MainConfigNames::ThumbLimits );
422 $largerThumbLimits = array_filter(
423 $thumbLimits,
424 static function ( $limit ) use ( $rescaledWidth ) {
425 return $limit >= $rescaledWidth;
426 }
427 );
428 $optimalThumbnailWidth = $largerThumbLimits ? min( $largerThumbLimits ) : max( $thumbLimits );
429 }
430
431 return $img->transform( [ 'width' => $optimalThumbnailWidth ] );
432 }
433
434 private function generateThumbnailPlaceholderHtml(): string {
435 if ( $this->thumbnailPlaceholderHtml ) {
436 return $this->thumbnailPlaceholderHtml;
437 }
438
439 $path = MW_INSTALL_PATH . '/resources/lib/ooui/themes/wikimediaui/images/icons/imageLayoutFrameless.svg';
440 $this->thumbnailPlaceholderHtml = Html::rawElement(
441 'div',
442 [
443 'class' => 'searchResultImage-thumbnail-placeholder',
444 'aria-hidden' => 'true',
445 ],
446 file_get_contents( $path )
447 );
448 return $this->thumbnailPlaceholderHtml;
449 }
450
458 protected function buildMeta( $desc, $date ) {
459 if ( $desc && $date ) {
460 $meta = "{$desc} - {$date}";
461 } elseif ( $desc ) {
462 $meta = $desc;
463 } elseif ( $date ) {
464 $meta = $date;
465 } else {
466 return '';
467 }
468
469 return "<div class='mw-search-result-data'>{$meta}</div>";
470 }
471}
const NS_FILE
Definition Defines.php:71
const NS_CATEGORY
Definition Defines.php:79
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:79
getShortDesc()
Definition File.php:2415
transform( $params, $flags=0)
Transform a media file.
Definition File.php:1197
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.
A class containing constants representing the names of configuration variables.
const ThumbnailNamespaces
Name constant for the ThumbnailNamespaces setting, for use with Config::get()
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)
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:32
NOTE: this class is being refactored into an abstract base class.
Media transform output for images.
Renders a single search result to HTML.