Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 213
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
FullSearchResultWidget
0.00% covered (danger)
0.00%
0 / 213
0.00% covered (danger)
0.00%
0 / 14
3422
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 render
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
110
 generateMainLinkHtml
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 generateAltTitleHtml
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 generateRedirectHtml
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 generateSectionHtml
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 generateCategoryHtml
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 generateSizeHtml
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 generateFileHtml
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
90
 getThumbnail
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 generateThumbnailHtml
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
72
 transformThumbnail
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 generateThumbnailPlaceholderHtml
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 buildMeta
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace MediaWiki\Search\SearchWidgets;
4
5use MediaWiki\Category\Category;
6use MediaWiki\FileRepo\File\File;
7use MediaWiki\FileRepo\RepoGroup;
8use MediaWiki\HookContainer\HookContainer;
9use MediaWiki\HookContainer\HookRunner;
10use MediaWiki\Html\Html;
11use MediaWiki\Linker\LinkRenderer;
12use MediaWiki\MainConfigNames;
13use MediaWiki\Media\MediaTransformOutput;
14use MediaWiki\Media\ThumbnailImage;
15use MediaWiki\Message\Message;
16use MediaWiki\Parser\Sanitizer;
17use MediaWiki\Search\Entity\SearchResultThumbnail;
18use MediaWiki\Search\SearchResult;
19use MediaWiki\Search\SearchResultThumbnailProvider;
20use MediaWiki\Specials\SpecialSearch;
21use MediaWiki\Title\Title;
22use MediaWiki\User\Options\UserOptionsManager;
23use Wikimedia\HtmlArmor\HtmlArmor;
24
25/**
26 * Renders a 'full' multi-line search result with metadata.
27 *
28 *  The Title
29 *  some *highlighted* *text* about the search result
30 *  5 KiB (651 words) - 12:40, 6 Aug 2016
31 */
32class FullSearchResultWidget implements SearchResultWidget {
33
34    public const THUMBNAIL_SIZE = 90;
35
36    protected SpecialSearch $specialPage;
37    protected LinkRenderer $linkRenderer;
38    private HookRunner $hookRunner;
39    private RepoGroup $repoGroup;
40    private SearchResultThumbnailProvider $thumbnailProvider;
41    private UserOptionsManager $userOptionsManager;
42
43    /** @var string */
44    private $thumbnailPlaceholderHtml;
45
46    public function __construct(
47        SpecialSearch $specialPage,
48        LinkRenderer $linkRenderer,
49        HookContainer $hookContainer,
50        RepoGroup $repoGroup,
51        SearchResultThumbnailProvider $thumbnailProvider,
52        UserOptionsManager $userOptionsManager
53    ) {
54        $this->specialPage = $specialPage;
55        $this->linkRenderer = $linkRenderer;
56        $this->hookRunner = new HookRunner( $hookContainer );
57        $this->repoGroup = $repoGroup;
58        $this->thumbnailProvider = $thumbnailProvider;
59        $this->userOptionsManager = $userOptionsManager;
60    }
61
62    /**
63     * @param SearchResult $result The result to render
64     * @param int $position The result position, including offset
65     * @return string HTML
66     */
67    public function render( SearchResult $result, $position ) {
68        // If the page doesn't *exist*... our search index is out of date.
69        // The least confusing at this point is to drop the result.
70        // You may get less results, but... on well. :P
71        if ( $result->isBrokenTitle() || $result->isMissingRevision() ) {
72            return '';
73        }
74
75        $link = $this->generateMainLinkHtml( $result, $position );
76        // If page content is not readable, just return ths title.
77        // This is not quite safe, but better than showing excerpts from
78        // non-readable pages. Note that hiding the entry entirely would
79        // screw up paging (really?).
80        if ( !$this->specialPage->getAuthority()->definitelyCan( 'read', $result->getTitle() ) ) {
81            return Html::rawElement( 'li', [], $link );
82        }
83
84        $redirect = $this->generateRedirectHtml( $result );
85        $section = $this->generateSectionHtml( $result );
86        $category = $this->generateCategoryHtml( $result );
87        $date = htmlspecialchars(
88            $this->specialPage->getLanguage()->userTimeAndDate(
89                $result->getTimestamp(),
90                $this->specialPage->getUser()
91            )
92        );
93        [ $file, $desc, $thumb ] = $this->generateFileHtml( $result );
94        $snippet = $result->getTextSnippet();
95        if ( $snippet ) {
96            $snippetWithEllipsis = $snippet . $this->specialPage->msg( 'ellipsis' );
97            $extract = Html::rawElement( 'div', [ 'class' => 'searchresult' ], $snippetWithEllipsis );
98        } else {
99            $extract = '';
100        }
101
102        if ( $result->getTitle() && $result->getTitle()->getNamespace() !== NS_FILE ) {
103            // If no file, then the description is about size
104            $desc = $this->generateSizeHtml( $result );
105
106            // Let hooks do their own final construction if desired.
107            // FIXME: Not sure why this is only for results without thumbnails,
108            // but keeping it as-is for now to prevent breaking hook consumers.
109            $html = null;
110            $score = '';
111            $related = '';
112            // TODO: remove this instanceof and always pass [], let implementers do the cast if
113            // they want to be SearchDatabase specific
114            $terms = $result instanceof \MediaWiki\Search\SqlSearchResult ? $result->getTermMatches() : [];
115            if ( !$this->hookRunner->onShowSearchHit( $this->specialPage, $result,
116                $terms, $link, $redirect, $section, $extract, $score,
117                // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
118                $desc, $date, $related, $html )
119            ) {
120                return $html;
121            }
122        }
123
124        // All the pieces have been collected. Now generate the final HTML
125        $joined = "{$link} {$redirect} {$category} {$section} {$file}";
126        $meta = $this->buildMeta( $desc, $date );
127
128        // Text portion of the search result
129        $html = Html::rawElement(
130            'div',
131            [ 'class' => 'mw-search-result-heading' ],
132            $joined
133        );
134        $html .= $extract . ' ' . $meta;
135
136        // If the result has a thumbnail, place it next to the text block
137        if ( $thumb ) {
138            $gridCells = Html::rawElement(
139                'div',
140                [ 'class' => 'searchResultImage-thumbnail' ],
141                $thumb
142            ) . Html::rawElement(
143                'div',
144                [ 'class' => 'searchResultImage-text' ],
145                $html
146            );
147            $html = Html::rawElement(
148                'div',
149                [ 'class' => 'searchResultImage' ],
150                $gridCells
151            );
152        }
153
154        return Html::rawElement(
155            'li',
156            [ 'class' => [ 'mw-search-result', 'mw-search-result-ns-' . $result->getTitle()->getNamespace() ] ],
157            $html
158        );
159    }
160
161    /**
162     * Generates HTML for the primary call to action. It is
163     * typically the article title, but the search engine can
164     * return an exact snippet to use (typically the article
165     * title with highlighted words).
166     *
167     * @param SearchResult $result
168     * @param int $position
169     * @return string HTML
170     */
171    protected function generateMainLinkHtml( SearchResult $result, $position ) {
172        $snippet = $result->getTitleSnippet();
173        if ( $snippet === '' ) {
174            $snippet = null;
175        } else {
176            $snippet = new HtmlArmor( $snippet );
177        }
178
179        // clone to prevent hook from changing the title stored inside $result
180        $title = clone $result->getTitle();
181        $query = [];
182
183        $attributes = [ 'data-serp-pos' => $position ];
184        $this->hookRunner->onShowSearchHitTitle( $title, $snippet, $result,
185            $result instanceof \MediaWiki\Search\SqlSearchResult ? $result->getTermMatches() : [],
186            // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
187            $this->specialPage, $query, $attributes );
188
189        $link = $this->linkRenderer->makeLink(
190            $title,
191            $snippet,
192            $attributes,
193            $query
194        );
195
196        return $link;
197    }
198
199    /**
200     * Generates an alternate title link, such as (redirect from <a>Foo</a>).
201     *
202     * @param string $msgKey i18n message  used to wrap title
203     * @param Title|null $title The title to link to, or null to generate
204     *  the message without a link. In that case $text must be non-null.
205     * @param string|null $text The text snippet to display, or null
206     *  to use the title
207     * @return string HTML
208     */
209    protected function generateAltTitleHtml( $msgKey, ?Title $title, $text ) {
210        $inner = $title === null
211            ? $text
212            : $this->linkRenderer->makeLink( $title, $text ? new HtmlArmor( $text ) : null );
213
214        return "<span class='searchalttitle'>" .
215                $this->specialPage->msg( $msgKey )->rawParams( $inner )->parse()
216            . "</span>";
217    }
218
219    /**
220     * @param SearchResult $result
221     * @return string HTML
222     */
223    protected function generateRedirectHtml( SearchResult $result ) {
224        $title = $result->getRedirectTitle();
225        return $title === null
226            ? ''
227            : $this->generateAltTitleHtml( 'search-redirect', $title, $result->getRedirectSnippet() );
228    }
229
230    /**
231     * @param SearchResult $result
232     * @return string HTML
233     */
234    protected function generateSectionHtml( SearchResult $result ) {
235        $title = $result->getSectionTitle();
236        return $title === null
237            ? ''
238            : $this->generateAltTitleHtml( 'search-section', $title, $result->getSectionSnippet() );
239    }
240
241    /**
242     * @param SearchResult $result
243     * @return string HTML
244     */
245    protected function generateCategoryHtml( SearchResult $result ) {
246        $snippet = $result->getCategorySnippet();
247        return $snippet
248            ? $this->generateAltTitleHtml( 'search-category', null, $snippet )
249            : '';
250    }
251
252    /**
253     * @param SearchResult $result
254     * @return string HTML
255     */
256    protected function generateSizeHtml( SearchResult $result ) {
257        $title = $result->getTitle();
258        if ( $title->getNamespace() === NS_CATEGORY ) {
259            $cat = Category::newFromTitle( $title );
260            return $this->specialPage->msg( 'search-result-category-size' )
261                ->numParams( $cat->getMemberCount(), $cat->getSubcatCount(), $cat->getFileCount() )
262                ->escaped();
263        // TODO: This is a bit odd...but requires changing the i18n message to fix
264        } elseif ( $result->getByteSize() !== null || $result->getWordCount() > 0 ) {
265            return $this->specialPage->msg( 'search-result-size' )
266                ->sizeParams( $result->getByteSize() )
267                ->numParams( $result->getWordCount() )
268                ->escaped();
269        }
270
271        return '';
272    }
273
274    /**
275     * @param SearchResult $result
276     * @return array Three element array containing the main file html,
277     *  a text description of the file, and finally the thumbnail html.
278     *  If no thumbnail is available the second and third will be null.
279     */
280    protected function generateFileHtml( SearchResult $result ) {
281        $title = $result->getTitle();
282        // don't assume that result is a valid title; e.g. could be an interwiki link target
283        if ( $title === null || !$title->canExist() ) {
284            return [ '', null, null ];
285        }
286
287        $html = '';
288        if ( $result->isFileMatch() ) {
289            $html = Html::element( 'span',
290                [ 'class' => 'searchalttitle' ],
291                $this->specialPage->msg( 'search-file-match' )->text()
292            );
293        }
294
295        $allowExtraThumbsFromRequest = $this->specialPage->getRequest()->getVal( 'search-thumbnail-extra-namespaces' );
296        $allowExtraThumbsFromPreference = $this->userOptionsManager->getOption(
297            $this->specialPage->getUser(),
298            'search-thumbnail-extra-namespaces'
299        );
300        $allowExtraThumbs = (bool)( $allowExtraThumbsFromRequest ?? $allowExtraThumbsFromPreference );
301        if ( !$allowExtraThumbs && $title->getNamespace() !== NS_FILE ) {
302            return [ $html, null, null ];
303        }
304
305        $thumbnail = $this->getThumbnail( $result, self::THUMBNAIL_SIZE );
306        $thumbnailName = $thumbnail ? $thumbnail->getName() : null;
307        if ( !$thumbnailName ) {
308            return [ $html, null, $this->generateThumbnailHtml( $result ) ];
309        }
310
311        $img = $this->repoGroup->findFile( $thumbnailName );
312        if ( !$img ) {
313            return [ $html, null, $this->generateThumbnailHtml( $result ) ];
314        }
315
316        // File::getShortDesc() is documented to return HTML, but many handlers used to incorrectly
317        // return plain text (T395834), so sanitize it in case the same bug is present in extensions.
318        $unsafeShortDesc = $img->getShortDesc( $this->specialPage->getLanguage() );
319        $shortDesc = Sanitizer::removeSomeTags( $unsafeShortDesc );
320
321        return [
322            $html,
323            $this->specialPage->msg( 'parentheses' )->rawParams( $shortDesc )->escaped(),
324            $this->generateThumbnailHtml( $result, $thumbnail )
325        ];
326    }
327
328    /**
329     * @param SearchResult $result
330     * @param int $size
331     * @return SearchResultThumbnail|null
332     */
333    private function getThumbnail( SearchResult $result, int $size ): ?SearchResultThumbnail {
334        $title = $result->getTitle();
335        // don't assume that result is a valid title; e.g. could be an interwiki link target
336        if ( $title === null || !$title->canExist() ) {
337            return null;
338        }
339
340        $thumbnails = $this->thumbnailProvider->getThumbnails( [ $title->getArticleID() => $title ], $size );
341
342        return $thumbnails[ $title->getArticleID() ] ?? null;
343    }
344
345    /**
346     * @param SearchResult $result
347     * @param SearchResultThumbnail|null $thumbnail
348     * @return string|null
349     */
350    private function generateThumbnailHtml( SearchResult $result, ?SearchResultThumbnail $thumbnail = null ): ?string {
351        $title = $result->getTitle();
352        // don't assume that result is a valid title; e.g. could be an interwiki link target
353        if ( $title === null || !$title->canExist() ) {
354            return null;
355        }
356
357        $thumbnailNamespaces = $this->specialPage->getConfig()->get( MainConfigNames::ThumbnailNamespaces );
358        if ( !$title->inNamespaces( $thumbnailNamespaces ) ) {
359            return null;
360        }
361
362        $thumbnailName = $thumbnail?->getName();
363        if ( $thumbnailName === null ) {
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                // @phan-suppress-next-line SecurityCheck-DoubleEscaped
377                return $thumb->toHtml( [
378                    'desc-link' => true,
379                    'loading' => 'lazy',
380                    'alt' => $this->specialPage->msg( 'search-thumbnail-alt', $title->getPrefixedText() ),
381                ] );
382            }
383
384            // thumbnails for non-file results should link to the relevant title
385            // @phan-suppress-next-line SecurityCheck-DoubleEscaped
386            return $thumb->toHtml( [
387                'desc-link' => true,
388                'custom-title-link' => $title,
389                'loading' => 'lazy',
390                'alt' => $this->specialPage->msg( 'search-thumbnail-alt', $title->getPrefixedText() ),
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( MainConfigNames::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    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
458    /**
459     * @param string $desc HTML description of result, ex: size in bytes, or empty string
460     * @param string $date HTML representation of last edit date, or empty string
461     * @return string HTML A div combining $desc and $date with a separator in a <div>.
462     *  If either is missing only one will be represented. If both are missing an empty
463     *  string will be returned.
464     */
465    protected function buildMeta( $desc, $date ) {
466        if ( $desc && $date ) {
467            $meta = $this->specialPage->msg( 'search-result-meta-separator',
468                Message::rawParam( $desc ),
469                Message::rawParam( $date )
470            )->escaped();
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}