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