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