MediaWiki  master
FullSearchResultWidget.php
Go to the documentation of this file.
1 <?php
2 
4 
5 use File;
6 use HtmlArmor;
17 use RepoGroup;
18 use SearchResult;
19 use SpecialSearch;
20 use ThumbnailImage;
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' ],
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:88
Implements some public methods and some protected utility functions which are required by multiple ch...
Definition: File.php:68
getShortDesc()
Definition: File.php:2387
transform( $params, $flags=0)
Transform a media file.
Definition: File.php:1173
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:41
static newFromTitle(PageIdentity $page)
Factory function.
Definition: Category.php:176
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:568
This class is a collection of static functions that serve two purposes:
Definition: Html.php:55
static rawElement( $element, $attribs=[], $contents='')
Returns an HTML element in a string.
Definition: Html.php:219
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)
Represents a title within MediaWiki.
Definition: Title.php:82
A service class to control user options.
getTitle()
Get the Title object that we'll be acting on, as specified in the WebRequest.
Definition: MediaWiki.php:163
Prioritized list of file repositories.
Definition: RepoGroup.php:30
NOTE: this class is being refactored into an abstract base class.
implements Special:Search - Run text & title search and display the output
Media transform output for images.
Renders a single search result to HTML.
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition: router.php:42