Merge "Selenium: replace UserLoginPage with BlankPage where possible"
[lhc/web/wiklou.git] / includes / widget / search / FullSearchResultWidget.php
1 <?php
2
3 namespace MediaWiki\Widget\Search;
4
5 use Category;
6 use Hooks;
7 use HtmlArmor;
8 use MediaWiki\Linker\LinkRenderer;
9 use MediaWiki\MediaWikiServices;
10 use SearchResult;
11 use SpecialSearch;
12 use Title;
13
14 /**
15 * Renders a 'full' multi-line search result with metadata.
16 *
17 * The Title
18 * some *highlighted* *text* about the search result
19 * 5KB (651 words) - 12:40, 6 Aug 2016
20 */
21 class FullSearchResultWidget implements SearchResultWidget {
22 /** @var SpecialSearch */
23 protected $specialPage;
24 /** @var LinkRenderer */
25 protected $linkRenderer;
26
27 public function __construct( SpecialSearch $specialPage, LinkRenderer $linkRenderer ) {
28 $this->specialPage = $specialPage;
29 $this->linkRenderer = $linkRenderer;
30 }
31
32 /**
33 * @param SearchResult $result The result to render
34 * @param int $position The result position, including offset
35 * @return string HTML
36 */
37 public function render( SearchResult $result, $position ) {
38 // If the page doesn't *exist*... our search index is out of date.
39 // The least confusing at this point is to drop the result.
40 // You may get less results, but... on well. :P
41 if ( $result->isBrokenTitle() || $result->isMissingRevision() ) {
42 return '';
43 }
44
45 $link = $this->generateMainLinkHtml( $result, $position );
46 // If page content is not readable, just return ths title.
47 // This is not quite safe, but better than showing excerpts from
48 // non-readable pages. Note that hiding the entry entirely would
49 // screw up paging (really?).
50 $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
51 if ( !$permissionManager->userCan(
52 'read', $this->specialPage->getUser(), $result->getTitle()
53 ) ) {
54 return "<li>{$link}</li>";
55 }
56
57 $redirect = $this->generateRedirectHtml( $result );
58 $section = $this->generateSectionHtml( $result );
59 $category = $this->generateCategoryHtml( $result );
60 $date = $this->specialPage->getLanguage()->userTimeAndDate(
61 $result->getTimestamp(),
62 $this->specialPage->getUser()
63 );
64 list( $file, $desc, $thumb ) = $this->generateFileHtml( $result );
65 $snippet = $result->getTextSnippet();
66 if ( $snippet ) {
67 $extract = "<div class='searchresult'>$snippet</div>";
68 } else {
69 $extract = '';
70 }
71
72 if ( $thumb === null ) {
73 // If no thumb, then the description is about size
74 $desc = $this->generateSizeHtml( $result );
75
76 // Let hooks do their own final construction if desired.
77 // FIXME: Not sure why this is only for results without thumbnails,
78 // but keeping it as-is for now to prevent breaking hook consumers.
79 $html = null;
80 $score = '';
81 $related = '';
82 // TODO: remove this instanceof and always pass [], let implementors do the cast if
83 // they want to be SearchDatabase specific
84 $terms = $result instanceof \SqlSearchResult ? $result->getTermMatches() : [];
85 if ( !Hooks::run( 'ShowSearchHit', [
86 $this->specialPage, $result, $terms,
87 &$link, &$redirect, &$section, &$extract,
88 &$score, &$desc, &$date, &$related, &$html
89 ] ) ) {
90 return $html;
91 }
92 }
93
94 // All the pieces have been collected. Now generate the final HTML
95 $joined = "{$link} {$redirect} {$category} {$section} {$file}";
96 $meta = $this->buildMeta( $desc, $date );
97
98 if ( $thumb === null ) {
99 $html =
100 "<div class='mw-search-result-heading'>{$joined}</div>" .
101 "{$extract} {$meta}";
102 } else {
103 $html =
104 "<table class='searchResultImage'>" .
105 "<tr>" .
106 "<td style='width: 120px; text-align: center; vertical-align: top'>" .
107 $thumb .
108 "</td>" .
109 "<td style='vertical-align: top'>" .
110 "{$joined} {$extract} {$meta}" .
111 "</td>" .
112 "</tr>" .
113 "</table>";
114 }
115
116 return "<li class='mw-search-result'>{$html}</li>";
117 }
118
119 /**
120 * Generates HTML for the primary call to action. It is
121 * typically the article title, but the search engine can
122 * return an exact snippet to use (typically the article
123 * title with highlighted words).
124 *
125 * @param SearchResult $result
126 * @param int $position
127 * @return string HTML
128 */
129 protected function generateMainLinkHtml( SearchResult $result, $position ) {
130 $snippet = $result->getTitleSnippet();
131 if ( $snippet === '' ) {
132 $snippet = null;
133 } else {
134 $snippet = new HtmlArmor( $snippet );
135 }
136
137 // clone to prevent hook from changing the title stored inside $result
138 $title = clone $result->getTitle();
139 $query = [];
140
141 $attributes = [ 'data-serp-pos' => $position ];
142 Hooks::run( 'ShowSearchHitTitle',
143 [ &$title, &$snippet, $result,
144 $result instanceof \SqlSearchResult ? $result->getTermMatches() : [],
145 $this->specialPage, &$query, &$attributes ] );
146
147 $link = $this->linkRenderer->makeLink(
148 $title,
149 $snippet,
150 $attributes,
151 $query
152 );
153
154 return $link;
155 }
156
157 /**
158 * Generates an alternate title link, such as (redirect from <a>Foo</a>).
159 *
160 * @param string $msgKey i18n message used to wrap title
161 * @param Title|null $title The title to link to, or null to generate
162 * the message without a link. In that case $text must be non-null.
163 * @param string|null $text The text snippet to display, or null
164 * to use the title
165 * @return string HTML
166 */
167 protected function generateAltTitleHtml( $msgKey, Title $title = null, $text ) {
168 $inner = $title === null
169 ? $text
170 : $this->linkRenderer->makeLink( $title, $text ? new HtmlArmor( $text ) : null );
171
172 return "<span class='searchalttitle'>" .
173 $this->specialPage->msg( $msgKey )->rawParams( $inner )->parse()
174 . "</span>";
175 }
176
177 /**
178 * @param SearchResult $result
179 * @return string HTML
180 */
181 protected function generateRedirectHtml( SearchResult $result ) {
182 $title = $result->getRedirectTitle();
183 return $title === null
184 ? ''
185 : $this->generateAltTitleHtml( 'search-redirect', $title, $result->getRedirectSnippet() );
186 }
187
188 /**
189 * @param SearchResult $result
190 * @return string HTML
191 */
192 protected function generateSectionHtml( SearchResult $result ) {
193 $title = $result->getSectionTitle();
194 return $title === null
195 ? ''
196 : $this->generateAltTitleHtml( 'search-section', $title, $result->getSectionSnippet() );
197 }
198
199 /**
200 * @param SearchResult $result
201 * @return string HTML
202 */
203 protected function generateCategoryHtml( SearchResult $result ) {
204 $snippet = $result->getCategorySnippet();
205 return $snippet
206 ? $this->generateAltTitleHtml( 'search-category', null, $snippet )
207 : '';
208 }
209
210 /**
211 * @param SearchResult $result
212 * @return string HTML
213 */
214 protected function generateSizeHtml( SearchResult $result ) {
215 $title = $result->getTitle();
216 if ( $title->getNamespace() === NS_CATEGORY ) {
217 $cat = Category::newFromTitle( $title );
218 return $this->specialPage->msg( 'search-result-category-size' )
219 ->numParams( $cat->getPageCount(), $cat->getSubcatCount(), $cat->getFileCount() )
220 ->escaped();
221 // TODO: This is a bit odd...but requires changing the i18n message to fix
222 } elseif ( $result->getByteSize() !== null || $result->getWordCount() > 0 ) {
223 $lang = $this->specialPage->getLanguage();
224 $bytes = $lang->formatSize( $result->getByteSize() );
225 $words = $result->getWordCount();
226
227 return $this->specialPage->msg( 'search-result-size', $bytes )
228 ->numParams( $words )
229 ->escaped();
230 }
231
232 return '';
233 }
234
235 /**
236 * @param SearchResult $result
237 * @return array Three element array containing the main file html,
238 * a text description of the file, and finally the thumbnail html.
239 * If no thumbnail is available the second and third will be null.
240 */
241 protected function generateFileHtml( SearchResult $result ) {
242 $title = $result->getTitle();
243 if ( $title->getNamespace() !== NS_FILE ) {
244 return [ '', null, null ];
245 }
246
247 if ( $result->isFileMatch() ) {
248 $html = "<span class='searchalttitle'>" .
249 $this->specialPage->msg( 'search-file-match' )->escaped() .
250 "</span>";
251 } else {
252 $html = '';
253 }
254
255 $descHtml = null;
256 $thumbHtml = null;
257
258 $img = $result->getFile() ?: MediaWikiServices::getInstance()->getRepoGroup()
259 ->findFile( $title );
260 if ( $img ) {
261 $thumb = $img->transform( [ 'width' => 120, 'height' => 120 ] );
262 if ( $thumb ) {
263 $descHtml = $this->specialPage->msg( 'parentheses' )
264 ->rawParams( $img->getShortDesc() )
265 ->escaped();
266 $thumbHtml = $thumb->toHtml( [ 'desc-link' => true ] );
267 }
268 }
269
270 return [ $html, $descHtml, $thumbHtml ];
271 }
272
273 /**
274 * @param string $desc HTML description of result, ex: size in bytes, or empty string
275 * @param string $date HTML representation of last edit date, or empty string
276 * @return string HTML A div combining $desc and $date with a separator in a <div>.
277 * If either is missing only one will be represented. If both are missing an empty
278 * string will be returned.
279 */
280 protected function buildMeta( $desc, $date ) {
281 if ( $desc && $date ) {
282 $meta = "{$desc} - {$date}";
283 } elseif ( $desc ) {
284 $meta = $desc;
285 } elseif ( $date ) {
286 $meta = $date;
287 } else {
288 return '';
289 }
290
291 return "<div class='mw-search-result-data'>{$meta}</div>";
292 }
293 }