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