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