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