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 HtmlArmor; |
6 | use MediaTransformOutput; |
7 | use MediaWiki\Category\Category; |
8 | use MediaWiki\FileRepo\File\File; |
9 | use MediaWiki\FileRepo\RepoGroup; |
10 | use MediaWiki\HookContainer\HookContainer; |
11 | use MediaWiki\HookContainer\HookRunner; |
12 | use MediaWiki\Html\Html; |
13 | use MediaWiki\Linker\LinkRenderer; |
14 | use MediaWiki\MainConfigNames; |
15 | use MediaWiki\Search\Entity\SearchResultThumbnail; |
16 | use MediaWiki\Search\SearchResultThumbnailProvider; |
17 | use MediaWiki\Specials\SpecialSearch; |
18 | use MediaWiki\Title\Title; |
19 | use MediaWiki\User\Options\UserOptionsManager; |
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 | protected SpecialSearch $specialPage; |
35 | protected LinkRenderer $linkRenderer; |
36 | private HookRunner $hookRunner; |
37 | private RepoGroup $repoGroup; |
38 | private SearchResultThumbnailProvider $thumbnailProvider; |
39 | private UserOptionsManager $userOptionsManager; |
40 | |
41 | /** @var string */ |
42 | private $thumbnailPlaceholderHtml; |
43 | |
44 | public function __construct( |
45 | SpecialSearch $specialPage, |
46 | LinkRenderer $linkRenderer, |
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 | |
60 | /** |
61 | * @param SearchResult $result The result to render |
62 | * @param int $position The result position, including offset |
63 | * @return string HTML |
64 | */ |
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 | |
159 | /** |
160 | * Generates HTML for the primary call to action. It is |
161 | * typically the article title, but the search engine can |
162 | * return an exact snippet to use (typically the article |
163 | * title with highlighted words). |
164 | * |
165 | * @param SearchResult $result |
166 | * @param int $position |
167 | * @return string HTML |
168 | */ |
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 | |
197 | /** |
198 | * Generates an alternate title link, such as (redirect from <a>Foo</a>). |
199 | * |
200 | * @param string $msgKey i18n message used to wrap title |
201 | * @param Title|null $title The title to link to, or null to generate |
202 | * the message without a link. In that case $text must be non-null. |
203 | * @param string|null $text The text snippet to display, or null |
204 | * to use the title |
205 | * @return string HTML |
206 | */ |
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 | |
217 | /** |
218 | * @param SearchResult $result |
219 | * @return string HTML |
220 | */ |
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 | |
228 | /** |
229 | * @param SearchResult $result |
230 | * @return string HTML |
231 | */ |
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 | |
239 | /** |
240 | * @param SearchResult $result |
241 | * @return string HTML |
242 | */ |
243 | protected function generateCategoryHtml( SearchResult $result ) { |
244 | $snippet = $result->getCategorySnippet(); |
245 | return $snippet |
246 | ? $this->generateAltTitleHtml( 'search-category', null, $snippet ) |
247 | : ''; |
248 | } |
249 | |
250 | /** |
251 | * @param SearchResult $result |
252 | * @return string HTML |
253 | */ |
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 | |
272 | /** |
273 | * @param SearchResult $result |
274 | * @return array Three element array containing the main file html, |
275 | * a text description of the file, and finally the thumbnail html. |
276 | * If no thumbnail is available the second and third will be null. |
277 | */ |
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 | |
322 | /** |
323 | * @param SearchResult $result |
324 | * @param int $size |
325 | * @return SearchResultThumbnail|null |
326 | */ |
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 | |
339 | /** |
340 | * @param SearchResult $result |
341 | * @param SearchResultThumbnail|null $thumbnail |
342 | * @return string|null |
343 | */ |
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 | |
390 | /** |
391 | * @param File $img |
392 | * @param SearchResultThumbnail $thumbnail |
393 | * @return ThumbnailImage|MediaTransformOutput|bool False on failure |
394 | */ |
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 | |
451 | /** |
452 | * @param string $desc HTML description of result, ex: size in bytes, or empty string |
453 | * @param string $date HTML representation of last edit date, or empty string |
454 | * @return string HTML A div combining $desc and $date with a separator in a <div>. |
455 | * If either is missing only one will be represented. If both are missing an empty |
456 | * string will be returned. |
457 | */ |
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 | } |