Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 214
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 / 214
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 / 62
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\Search\Entity\SearchResultThumbnail;
14use MediaWiki\Search\SearchResultThumbnailProvider;
15use MediaWiki\Specials\SpecialSearch;
16use MediaWiki\Title\Title;
17use MediaWiki\User\Options\UserOptionsManager;
18use RepoGroup;
19use SearchResult;
20use 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 */
29class 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}