Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 210
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 / 210
0.00% covered (danger)
0.00%
0 / 14
3660
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 / 30
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 / 28
0.00% covered (danger)
0.00%
0 / 1
110
 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 / 8
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace MediaWiki\Search\SearchWidgets;
4
5use HtmlArmor;
6use MediaTransformOutput;
7use MediaWiki\Category\Category;
8use MediaWiki\FileRepo\File\File;
9use MediaWiki\FileRepo\RepoGroup;
10use MediaWiki\HookContainer\HookContainer;
11use MediaWiki\HookContainer\HookRunner;
12use MediaWiki\Html\Html;
13use MediaWiki\Linker\LinkRenderer;
14use MediaWiki\MainConfigNames;
15use MediaWiki\Search\Entity\SearchResultThumbnail;
16use MediaWiki\Search\SearchResultThumbnailProvider;
17use MediaWiki\Specials\SpecialSearch;
18use MediaWiki\Title\Title;
19use MediaWiki\User\Options\UserOptionsManager;
20use SearchResult;
21use 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 */
30class 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}