Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 214 |
|
0.00% |
0 / 14 |
CRAP | |
0.00% |
0 / 1 |
FullSearchResultWidget | |
0.00% |
0 / 214 |
|
0.00% |
0 / 14 |
3660 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
render | |
0.00% |
0 / 62 |
|
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\Search\Entity\SearchResultThumbnail; |
14 | use MediaWiki\Search\SearchResultThumbnailProvider; |
15 | use MediaWiki\Specials\SpecialSearch; |
16 | use MediaWiki\Title\Title; |
17 | use MediaWiki\User\Options\UserOptionsManager; |
18 | use RepoGroup; |
19 | use SearchResult; |
20 | use ThumbnailImage; |
21 | |
22 | /** |
23 | * Renders a 'full' multi-line search result with metadata. |
24 | * |
25 | * The Title |
26 | * some *highlighted* *text* about the search result |
27 | * 5 KiB (651 words) - 12:40, 6 Aug 2016 |
28 | */ |
29 | class FullSearchResultWidget implements SearchResultWidget { |
30 | /** @var int */ |
31 | public const THUMBNAIL_SIZE = 90; |
32 | /** @var SpecialSearch */ |
33 | protected $specialPage; |
34 | /** @var LinkRenderer */ |
35 | protected $linkRenderer; |
36 | /** @var HookRunner */ |
37 | private $hookRunner; |
38 | /** @var RepoGroup */ |
39 | private $repoGroup; |
40 | /** @var SearchResultThumbnailProvider */ |
41 | private $thumbnailProvider; |
42 | /** @var string */ |
43 | private $thumbnailPlaceholderHtml; |
44 | /** @var UserOptionsManager */ |
45 | private $userOptionsManager; |
46 | |
47 | public function __construct( |
48 | SpecialSearch $specialPage, |
49 | LinkRenderer $linkRenderer, |
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 | |
63 | /** |
64 | * @param SearchResult $result The result to render |
65 | * @param int $position The result position, including offset |
66 | * @return string HTML |
67 | */ |
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' ], |
151 | Html::rawElement( |
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 | |
166 | /** |
167 | * Generates HTML for the primary call to action. It is |
168 | * typically the article title, but the search engine can |
169 | * return an exact snippet to use (typically the article |
170 | * title with highlighted words). |
171 | * |
172 | * @param SearchResult $result |
173 | * @param int $position |
174 | * @return string HTML |
175 | */ |
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 | |
204 | /** |
205 | * Generates an alternate title link, such as (redirect from <a>Foo</a>). |
206 | * |
207 | * @param string $msgKey i18n message used to wrap title |
208 | * @param Title|null $title The title to link to, or null to generate |
209 | * the message without a link. In that case $text must be non-null. |
210 | * @param string|null $text The text snippet to display, or null |
211 | * to use the title |
212 | * @return string HTML |
213 | */ |
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 | |
224 | /** |
225 | * @param SearchResult $result |
226 | * @return string HTML |
227 | */ |
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 | |
235 | /** |
236 | * @param SearchResult $result |
237 | * @return string HTML |
238 | */ |
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 | |
246 | /** |
247 | * @param SearchResult $result |
248 | * @return string HTML |
249 | */ |
250 | protected function generateCategoryHtml( SearchResult $result ) { |
251 | $snippet = $result->getCategorySnippet(); |
252 | return $snippet |
253 | ? $this->generateAltTitleHtml( 'search-category', null, $snippet ) |
254 | : ''; |
255 | } |
256 | |
257 | /** |
258 | * @param SearchResult $result |
259 | * @return string HTML |
260 | */ |
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 | |
279 | /** |
280 | * @param SearchResult $result |
281 | * @return array Three element array containing the main file html, |
282 | * a text description of the file, and finally the thumbnail html. |
283 | * If no thumbnail is available the second and third will be null. |
284 | */ |
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 | |
329 | /** |
330 | * @param SearchResult $result |
331 | * @param int $size |
332 | * @return SearchResultThumbnail|null |
333 | */ |
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 | |
346 | /** |
347 | * @param SearchResult $result |
348 | * @param SearchResultThumbnail|null $thumbnail |
349 | * @return string|null |
350 | */ |
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 | |
397 | /** |
398 | * @param File $img |
399 | * @param SearchResultThumbnail $thumbnail |
400 | * @return ThumbnailImage|MediaTransformOutput|bool False on failure |
401 | */ |
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 | |
441 | /** |
442 | * @return string |
443 | */ |
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 | |
461 | /** |
462 | * @param string $desc HTML description of result, ex: size in bytes, or empty string |
463 | * @param string $date HTML representation of last edit date, or empty string |
464 | * @return string HTML A div combining $desc and $date with a separator in a <div>. |
465 | * If either is missing only one will be represented. If both are missing an empty |
466 | * string will be returned. |
467 | */ |
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 | } |