Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 417 |
|
0.00% |
0 / 42 |
CRAP | |
0.00% |
0 / 1 |
ChangesList | |
0.00% |
0 / 417 |
|
0.00% |
0 / 42 |
13110 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
newFromContext | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
12 | |||
recentChangesLine | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getHighlightsContainerDiv | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
6 | |||
setWatchlistDivs | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isWatchlist | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
preCacheMessages | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
recentChangesFlags | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
getHTMLClasses | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
getHTMLClassesForFilters | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
flag | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
30 | |||
beginRecentChangesList | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
initChangesListRows | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
showCharacterDifference | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
72 | |||
formatCharacterDifference | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
endRecentChangesList | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
revDateLink | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
12 | |||
insertDateHeader | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
insertLog | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
insertDiffHist | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
42 | |||
getArticleLink | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
42 | |||
getWatchlistExpiry | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
6 | |||
getTimestamp | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
insertTimestamp | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
insertUserRelatedLinks | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
12 | |||
insertLogEntry | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
insertComment | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
30 | |||
numberofWatchingusers | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
6 | |||
isDeleted | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
userCan | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
maybeWatchedLink | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
insertRollback | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
insertPageTools | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
30 | |||
getRollback | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
insertTags | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
6 | |||
getTags | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
insertExtra | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
showAsUnpatrolled | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isUnpatrolled | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
56 | |||
isCategorizationWithoutRevision | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
getDataAttributes | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
30 | |||
setChangeLinePrefixer | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * Base class for all changes lists. |
4 | * |
5 | * The class is used for formatting recent changes, related changes and watchlist. |
6 | * |
7 | * This program is free software; you can redistribute it and/or modify |
8 | * it under the terms of the GNU General Public License as published by |
9 | * the Free Software Foundation; either version 2 of the License, or |
10 | * (at your option) any later version. |
11 | * |
12 | * This program is distributed in the hope that it will be useful, |
13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
15 | * GNU General Public License for more details. |
16 | * |
17 | * You should have received a copy of the GNU General Public License along |
18 | * with this program; if not, write to the Free Software Foundation, Inc., |
19 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
20 | * http://www.gnu.org/copyleft/gpl.html |
21 | * |
22 | * @file |
23 | */ |
24 | |
25 | use MediaWiki\CommentFormatter\RowCommentFormatter; |
26 | use MediaWiki\Context\ContextSource; |
27 | use MediaWiki\Context\IContextSource; |
28 | use MediaWiki\Context\RequestContext; |
29 | use MediaWiki\HookContainer\HookRunner; |
30 | use MediaWiki\HookContainer\ProtectedHookAccessorTrait; |
31 | use MediaWiki\Html\Html; |
32 | use MediaWiki\Linker\Linker; |
33 | use MediaWiki\Linker\LinkRenderer; |
34 | use MediaWiki\MainConfigNames; |
35 | use MediaWiki\MediaWikiServices; |
36 | use MediaWiki\Pager\PagerTools; |
37 | use MediaWiki\Parser\Sanitizer; |
38 | use MediaWiki\Permissions\Authority; |
39 | use MediaWiki\Revision\MutableRevisionRecord; |
40 | use MediaWiki\Revision\RevisionRecord; |
41 | use MediaWiki\Title\Title; |
42 | use MediaWiki\User\User; |
43 | use MediaWiki\User\UserIdentityValue; |
44 | use OOUI\IconWidget; |
45 | use Wikimedia\Rdbms\IResultWrapper; |
46 | |
47 | class ChangesList extends ContextSource { |
48 | use ProtectedHookAccessorTrait; |
49 | |
50 | public const CSS_CLASS_PREFIX = 'mw-changeslist-'; |
51 | |
52 | protected $watchlist = false; |
53 | protected $lastdate; |
54 | protected $message; |
55 | protected $rc_cache; |
56 | protected $rcCacheIndex; |
57 | protected $rclistOpen; |
58 | protected $rcMoveIndex; |
59 | |
60 | /** @var callable */ |
61 | protected $changeLinePrefixer; |
62 | |
63 | /** @var MapCacheLRU */ |
64 | protected $watchMsgCache; |
65 | |
66 | /** |
67 | * @var LinkRenderer |
68 | */ |
69 | protected $linkRenderer; |
70 | |
71 | /** |
72 | * @var RowCommentFormatter |
73 | */ |
74 | protected $commentFormatter; |
75 | |
76 | /** |
77 | * @var string[] Comments indexed by rc_id |
78 | */ |
79 | protected $formattedComments; |
80 | |
81 | /** |
82 | * @var ChangesListFilterGroup[] |
83 | */ |
84 | protected $filterGroups; |
85 | |
86 | /** |
87 | * @var MapCacheLRU |
88 | */ |
89 | protected $tagsCache; |
90 | |
91 | /** |
92 | * @var MapCacheLRU |
93 | */ |
94 | protected $userLinkCache; |
95 | |
96 | /** |
97 | * @param IContextSource $context |
98 | * @param ChangesListFilterGroup[] $filterGroups Array of ChangesListFilterGroup objects (currently optional) |
99 | */ |
100 | public function __construct( $context, array $filterGroups = [] ) { |
101 | $this->setContext( $context ); |
102 | $this->preCacheMessages(); |
103 | $this->watchMsgCache = new MapCacheLRU( 50 ); |
104 | $this->filterGroups = $filterGroups; |
105 | |
106 | $services = MediaWikiServices::getInstance(); |
107 | $this->linkRenderer = $services->getLinkRenderer(); |
108 | $this->commentFormatter = $services->getRowCommentFormatter(); |
109 | $this->tagsCache = new MapCacheLRU( 50 ); |
110 | $this->userLinkCache = new MapCacheLRU( 50 ); |
111 | } |
112 | |
113 | /** |
114 | * Fetch an appropriate changes list class for the specified context |
115 | * Some users might want to use an enhanced list format, for instance |
116 | * |
117 | * @param IContextSource $context |
118 | * @param array $groups Array of ChangesListFilterGroup objects (currently optional) |
119 | * @return ChangesList |
120 | */ |
121 | public static function newFromContext( IContextSource $context, array $groups = [] ) { |
122 | $user = $context->getUser(); |
123 | $sk = $context->getSkin(); |
124 | $services = MediaWikiServices::getInstance(); |
125 | $list = null; |
126 | if ( ( new HookRunner( $services->getHookContainer() ) )->onFetchChangesList( $user, $sk, $list, $groups ) ) { |
127 | $userOptionsLookup = $services->getUserOptionsLookup(); |
128 | $new = $context->getRequest()->getBool( |
129 | 'enhanced', |
130 | $userOptionsLookup->getBoolOption( $user, 'usenewrc' ) |
131 | ); |
132 | |
133 | return $new ? |
134 | new EnhancedChangesList( $context, $groups ) : |
135 | new OldChangesList( $context, $groups ); |
136 | } else { |
137 | return $list; |
138 | } |
139 | } |
140 | |
141 | /** |
142 | * Format a line |
143 | * |
144 | * @since 1.27 |
145 | * |
146 | * @param RecentChange &$rc Passed by reference |
147 | * @param bool $watched (default false) |
148 | * @param int|null $linenumber (default null) |
149 | * |
150 | * @return string|bool |
151 | */ |
152 | public function recentChangesLine( &$rc, $watched = false, $linenumber = null ) { |
153 | throw new RuntimeException( 'recentChangesLine should be implemented' ); |
154 | } |
155 | |
156 | /** |
157 | * Get the container for highlights that are used in the new StructuredFilters |
158 | * system |
159 | * |
160 | * @return string HTML structure of the highlight container div |
161 | */ |
162 | protected function getHighlightsContainerDiv() { |
163 | $highlightColorDivs = ''; |
164 | foreach ( [ 'none', 'c1', 'c2', 'c3', 'c4', 'c5' ] as $color ) { |
165 | $highlightColorDivs .= Html::rawElement( |
166 | 'div', |
167 | [ |
168 | 'class' => 'mw-rcfilters-ui-highlights-color-' . $color, |
169 | 'data-color' => $color |
170 | ] |
171 | ); |
172 | } |
173 | |
174 | return Html::rawElement( |
175 | 'div', |
176 | [ 'class' => 'mw-rcfilters-ui-highlights' ], |
177 | $highlightColorDivs |
178 | ); |
179 | } |
180 | |
181 | /** |
182 | * Sets the list to use a "<li class='watchlist-(namespace)-(page)'>" tag |
183 | * @param bool $value |
184 | */ |
185 | public function setWatchlistDivs( $value = true ) { |
186 | $this->watchlist = $value; |
187 | } |
188 | |
189 | /** |
190 | * @return bool True when setWatchlistDivs has been called |
191 | * @since 1.23 |
192 | */ |
193 | public function isWatchlist() { |
194 | return (bool)$this->watchlist; |
195 | } |
196 | |
197 | /** |
198 | * As we use the same small set of messages in various methods and that |
199 | * they are called often, we call them once and save them in $this->message |
200 | */ |
201 | private function preCacheMessages() { |
202 | if ( !isset( $this->message ) ) { |
203 | $this->message = []; |
204 | foreach ( [ |
205 | 'cur', 'diff', 'hist', 'enhancedrc-history', 'last', 'blocklink', 'history', |
206 | 'semicolon-separator', 'pipe-separator', 'word-separator' ] as $msg |
207 | ) { |
208 | $this->message[$msg] = $this->msg( $msg )->escaped(); |
209 | } |
210 | } |
211 | } |
212 | |
213 | /** |
214 | * Returns the appropriate flags for new page, minor change and patrolling |
215 | * @param array $flags Associative array of 'flag' => Bool |
216 | * @param string $nothing To use for empty space |
217 | * @return string |
218 | */ |
219 | public function recentChangesFlags( $flags, $nothing = "\u{00A0}" ) { |
220 | $f = ''; |
221 | foreach ( |
222 | $this->getConfig()->get( MainConfigNames::RecentChangesFlags ) as $flag => $_ |
223 | ) { |
224 | $f .= isset( $flags[$flag] ) && $flags[$flag] |
225 | ? self::flag( $flag, $this->getContext() ) |
226 | : $nothing; |
227 | } |
228 | |
229 | return $f; |
230 | } |
231 | |
232 | /** |
233 | * Get an array of default HTML class attributes for the change. |
234 | * |
235 | * @param RecentChange|RCCacheEntry $rc |
236 | * @param string|bool $watched Optionally timestamp for adding watched class |
237 | * |
238 | * @return string[] List of CSS class names |
239 | */ |
240 | protected function getHTMLClasses( $rc, $watched ) { |
241 | $classes = [ self::CSS_CLASS_PREFIX . 'line' ]; |
242 | $logType = $rc->mAttribs['rc_log_type']; |
243 | |
244 | if ( $logType ) { |
245 | $classes[] = self::CSS_CLASS_PREFIX . 'log'; |
246 | $classes[] = Sanitizer::escapeClass( self::CSS_CLASS_PREFIX . 'log-' . $logType ); |
247 | } else { |
248 | $classes[] = self::CSS_CLASS_PREFIX . 'edit'; |
249 | $classes[] = Sanitizer::escapeClass( self::CSS_CLASS_PREFIX . 'ns' . |
250 | $rc->mAttribs['rc_namespace'] . '-' . $rc->mAttribs['rc_title'] ); |
251 | } |
252 | |
253 | // Indicate watched status on the line to allow for more |
254 | // comprehensive styling. |
255 | $classes[] = $watched && $rc->mAttribs['rc_timestamp'] >= $watched |
256 | ? self::CSS_CLASS_PREFIX . 'line-watched' |
257 | : self::CSS_CLASS_PREFIX . 'line-not-watched'; |
258 | |
259 | $classes = array_merge( $classes, $this->getHTMLClassesForFilters( $rc ) ); |
260 | |
261 | return $classes; |
262 | } |
263 | |
264 | /** |
265 | * Get an array of CSS classes attributed to filters for this row. Used for highlighting |
266 | * in the front-end. |
267 | * |
268 | * @param RecentChange $rc |
269 | * @return string[] Array of CSS classes |
270 | */ |
271 | protected function getHTMLClassesForFilters( $rc ) { |
272 | $classes = []; |
273 | |
274 | $classes[] = Sanitizer::escapeClass( self::CSS_CLASS_PREFIX . 'ns-' . |
275 | $rc->mAttribs['rc_namespace'] ); |
276 | |
277 | $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo(); |
278 | $classes[] = Sanitizer::escapeClass( |
279 | self::CSS_CLASS_PREFIX . |
280 | 'ns-' . |
281 | ( $nsInfo->isTalk( $rc->mAttribs['rc_namespace'] ) ? 'talk' : 'subject' ) |
282 | ); |
283 | |
284 | foreach ( $this->filterGroups as $filterGroup ) { |
285 | foreach ( $filterGroup->getFilters() as $filter ) { |
286 | $filter->applyCssClassIfNeeded( $this, $rc, $classes ); |
287 | } |
288 | } |
289 | |
290 | return $classes; |
291 | } |
292 | |
293 | /** |
294 | * Make an "<abbr>" element for a given change flag. The flag indicating a new page, minor edit, |
295 | * bot edit, or unpatrolled edit. In English it typically contains "N", "m", "b", or "!". |
296 | * |
297 | * Styling for these flags is provided through mediawiki.interface.helpers.styles. |
298 | * |
299 | * @param string $flag One key of $wgRecentChangesFlags |
300 | * @param IContextSource|null $context |
301 | * @return string HTML |
302 | */ |
303 | public static function flag( $flag, IContextSource $context = null ) { |
304 | static $map = [ 'minoredit' => 'minor', 'botedit' => 'bot' ]; |
305 | static $flagInfos = null; |
306 | |
307 | if ( $flagInfos === null ) { |
308 | $recentChangesFlags = MediaWikiServices::getInstance()->getMainConfig() |
309 | ->get( MainConfigNames::RecentChangesFlags ); |
310 | $flagInfos = []; |
311 | foreach ( $recentChangesFlags as $key => $value ) { |
312 | $flagInfos[$key]['letter'] = $value['letter']; |
313 | $flagInfos[$key]['title'] = $value['title']; |
314 | // Allow customized class name, fall back to flag name |
315 | $flagInfos[$key]['class'] = $value['class'] ?? $key; |
316 | } |
317 | } |
318 | |
319 | $context = $context ?: RequestContext::getMain(); |
320 | |
321 | // Inconsistent naming, kept for b/c |
322 | if ( isset( $map[$flag] ) ) { |
323 | $flag = $map[$flag]; |
324 | } |
325 | |
326 | $info = $flagInfos[$flag]; |
327 | return Html::element( 'abbr', [ |
328 | 'class' => $info['class'], |
329 | 'title' => wfMessage( $info['title'] )->setContext( $context )->text(), |
330 | ], wfMessage( $info['letter'] )->setContext( $context )->text() ); |
331 | } |
332 | |
333 | /** |
334 | * Returns text for the start of the tabular part of RC |
335 | * @return string |
336 | */ |
337 | public function beginRecentChangesList() { |
338 | $this->rc_cache = []; |
339 | $this->rcMoveIndex = 0; |
340 | $this->rcCacheIndex = 0; |
341 | $this->lastdate = ''; |
342 | $this->rclistOpen = false; |
343 | $this->getOutput()->addModuleStyles( [ |
344 | 'mediawiki.interface.helpers.styles', |
345 | 'mediawiki.special.changeslist' |
346 | ] ); |
347 | |
348 | return '<div class="mw-changeslist">'; |
349 | } |
350 | |
351 | /** |
352 | * @param IResultWrapper|stdClass[] $rows |
353 | */ |
354 | public function initChangesListRows( $rows ) { |
355 | $this->getHookRunner()->onChangesListInitRows( $this, $rows ); |
356 | $this->formattedComments = $this->commentFormatter->createBatch() |
357 | ->comments( |
358 | $this->commentFormatter->rows( $rows ) |
359 | ->commentKey( 'rc_comment' ) |
360 | ->namespaceField( 'rc_namespace' ) |
361 | ->titleField( 'rc_title' ) |
362 | ->indexField( 'rc_id' ) |
363 | ) |
364 | ->useBlock() |
365 | ->execute(); |
366 | } |
367 | |
368 | /** |
369 | * Show formatted char difference |
370 | * |
371 | * Needs the css module 'mediawiki.special.changeslist' to style output |
372 | * |
373 | * @param int $old Number of bytes |
374 | * @param int $new Number of bytes |
375 | * @param IContextSource|null $context |
376 | * @return string |
377 | */ |
378 | public static function showCharacterDifference( $old, $new, IContextSource $context = null ) { |
379 | if ( !$context ) { |
380 | $context = RequestContext::getMain(); |
381 | } |
382 | |
383 | $new = (int)$new; |
384 | $old = (int)$old; |
385 | $szdiff = $new - $old; |
386 | |
387 | $lang = $context->getLanguage(); |
388 | $config = $context->getConfig(); |
389 | $code = $lang->getCode(); |
390 | static $fastCharDiff = []; |
391 | if ( !isset( $fastCharDiff[$code] ) ) { |
392 | $fastCharDiff[$code] = $config->get( MainConfigNames::MiserMode ) |
393 | || $context->msg( 'rc-change-size' )->plain() === '$1'; |
394 | } |
395 | |
396 | $formattedSize = $lang->formatNum( $szdiff ); |
397 | |
398 | if ( !$fastCharDiff[$code] ) { |
399 | $formattedSize = $context->msg( 'rc-change-size', $formattedSize )->text(); |
400 | } |
401 | |
402 | if ( abs( $szdiff ) > abs( $config->get( MainConfigNames::RCChangedSizeThreshold ) ) ) { |
403 | $tag = 'strong'; |
404 | } else { |
405 | $tag = 'span'; |
406 | } |
407 | |
408 | if ( $szdiff === 0 ) { |
409 | $formattedSizeClass = 'mw-plusminus-null'; |
410 | } elseif ( $szdiff > 0 ) { |
411 | $formattedSize = '+' . $formattedSize; |
412 | $formattedSizeClass = 'mw-plusminus-pos'; |
413 | } else { |
414 | $formattedSizeClass = 'mw-plusminus-neg'; |
415 | } |
416 | $formattedSizeClass .= ' mw-diff-bytes'; |
417 | |
418 | $formattedTotalSize = $context->msg( 'rc-change-size-new' )->numParams( $new )->text(); |
419 | |
420 | return Html::element( $tag, |
421 | [ 'dir' => 'ltr', 'class' => $formattedSizeClass, 'title' => $formattedTotalSize ], |
422 | $formattedSize ) . $lang->getDirMark(); |
423 | } |
424 | |
425 | /** |
426 | * Format the character difference of one or several changes. |
427 | * |
428 | * @param RecentChange $old |
429 | * @param RecentChange|null $new Last change to use, if not provided, $old will be used |
430 | * @return string HTML fragment |
431 | */ |
432 | public function formatCharacterDifference( RecentChange $old, RecentChange $new = null ) { |
433 | $oldlen = $old->mAttribs['rc_old_len']; |
434 | |
435 | if ( $new ) { |
436 | $newlen = $new->mAttribs['rc_new_len']; |
437 | } else { |
438 | $newlen = $old->mAttribs['rc_new_len']; |
439 | } |
440 | |
441 | if ( $oldlen === null || $newlen === null ) { |
442 | return ''; |
443 | } |
444 | |
445 | return self::showCharacterDifference( $oldlen, $newlen, $this->getContext() ); |
446 | } |
447 | |
448 | /** |
449 | * Returns text for the end of RC |
450 | * @return string |
451 | */ |
452 | public function endRecentChangesList() { |
453 | $out = $this->rclistOpen ? "</ul>\n" : ''; |
454 | $out .= '</div>'; |
455 | |
456 | return $out; |
457 | } |
458 | |
459 | /** |
460 | * Render the date and time of a revision in the current user language |
461 | * based on whether the user is able to view this information or not. |
462 | * @param RevisionRecord $rev |
463 | * @param Authority $performer |
464 | * @param Language $lang |
465 | * @param Title|null $title (optional) where Title does not match |
466 | * the Title associated with the RevisionRecord |
467 | * @param string $className (optional) to append to .mw-changelist-date element for access to the |
468 | * associated timestamp string. |
469 | * @internal For usage by Pager classes only (e.g. HistoryPager, NewPagesPager and ContribsPager). |
470 | * @return string HTML |
471 | */ |
472 | public static function revDateLink( |
473 | RevisionRecord $rev, |
474 | Authority $performer, |
475 | Language $lang, |
476 | $title = null, |
477 | $className = '' |
478 | ) { |
479 | $ts = $rev->getTimestamp(); |
480 | $time = $lang->userTime( $ts, $performer->getUser() ); |
481 | $date = $lang->userTimeAndDate( $ts, $performer->getUser() ); |
482 | $class = trim( 'mw-changeslist-date ' . $className ); |
483 | if ( $rev->userCan( RevisionRecord::DELETED_TEXT, $performer ) ) { |
484 | $link = MediaWikiServices::getInstance()->getLinkRenderer()->makeKnownLink( |
485 | $title ?? $rev->getPageAsLinkTarget(), |
486 | $date, |
487 | [ 'class' => $class ], |
488 | [ 'oldid' => $rev->getId() ] |
489 | ); |
490 | } else { |
491 | $link = htmlspecialchars( $date ); |
492 | } |
493 | if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) { |
494 | $class = Linker::getRevisionDeletedClass( $rev ) . " $class"; |
495 | $link = "<span class=\"$class\">$link</span>"; |
496 | } |
497 | return Html::element( 'span', [ |
498 | 'class' => 'mw-changeslist-time' |
499 | ], $time ) . $link; |
500 | } |
501 | |
502 | /** |
503 | * @param string &$s HTML to update |
504 | * @param mixed $rc_timestamp |
505 | */ |
506 | public function insertDateHeader( &$s, $rc_timestamp ) { |
507 | # Make date header if necessary |
508 | $date = $this->getLanguage()->userDate( $rc_timestamp, $this->getUser() ); |
509 | if ( $date != $this->lastdate ) { |
510 | if ( $this->lastdate != '' ) { |
511 | $s .= "</ul>\n"; |
512 | } |
513 | $s .= Html::element( 'h4', [], $date ) . "\n<ul class=\"special\">"; |
514 | $this->lastdate = $date; |
515 | $this->rclistOpen = true; |
516 | } |
517 | } |
518 | |
519 | /** |
520 | * @param string &$s HTML to update |
521 | * @param Title $title |
522 | * @param string $logtype |
523 | * @param bool $useParentheses (optional) Wrap log entry in parentheses where needed |
524 | */ |
525 | public function insertLog( &$s, $title, $logtype, $useParentheses = true ) { |
526 | $page = new LogPage( $logtype ); |
527 | $logname = $page->getName()->setContext( $this->getContext() )->text(); |
528 | $link = $this->linkRenderer->makeKnownLink( $title, $logname, [ |
529 | 'class' => $useParentheses ? '' : 'mw-changeslist-links' |
530 | ] ); |
531 | if ( $useParentheses ) { |
532 | $s .= $this->msg( 'parentheses' )->rawParams( |
533 | $link |
534 | )->escaped(); |
535 | } else { |
536 | $s .= $link; |
537 | } |
538 | } |
539 | |
540 | /** |
541 | * @param string &$s HTML to update |
542 | * @param RecentChange &$rc |
543 | * @param bool|null $unpatrolled Unused variable, since 1.27. |
544 | */ |
545 | public function insertDiffHist( &$s, &$rc, $unpatrolled = null ) { |
546 | # Diff link |
547 | if ( |
548 | $rc->mAttribs['rc_type'] == RC_NEW || |
549 | $rc->mAttribs['rc_type'] == RC_LOG || |
550 | $rc->mAttribs['rc_type'] == RC_CATEGORIZE |
551 | ) { |
552 | $diffLink = $this->message['diff']; |
553 | } elseif ( !self::userCan( $rc, RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) { |
554 | $diffLink = $this->message['diff']; |
555 | } else { |
556 | $query = [ |
557 | 'curid' => $rc->mAttribs['rc_cur_id'], |
558 | 'diff' => $rc->mAttribs['rc_this_oldid'], |
559 | 'oldid' => $rc->mAttribs['rc_last_oldid'] |
560 | ]; |
561 | |
562 | $diffLink = $this->linkRenderer->makeKnownLink( |
563 | $rc->getTitle(), |
564 | new HtmlArmor( $this->message['diff'] ), |
565 | [ 'class' => 'mw-changeslist-diff' ], |
566 | $query |
567 | ); |
568 | } |
569 | if ( $rc->mAttribs['rc_type'] == RC_CATEGORIZE ) { |
570 | $histLink = $this->message['hist']; |
571 | } else { |
572 | $histLink = $this->linkRenderer->makeKnownLink( |
573 | $rc->getTitle(), |
574 | new HtmlArmor( $this->message['hist'] ), |
575 | [ 'class' => 'mw-changeslist-history' ], |
576 | [ |
577 | 'curid' => $rc->mAttribs['rc_cur_id'], |
578 | 'action' => 'history' |
579 | ] |
580 | ); |
581 | } |
582 | |
583 | $s .= Html::rawElement( 'span', [ 'class' => 'mw-changeslist-links' ], |
584 | Html::rawElement( 'span', [], $diffLink ) . |
585 | Html::rawElement( 'span', [], $histLink ) |
586 | ) . |
587 | ' <span class="mw-changeslist-separator"></span> '; |
588 | } |
589 | |
590 | /** |
591 | * Get the HTML link to the changed page, possibly with a prefix from hook handlers, and a |
592 | * suffix for temporarily watched items. |
593 | * |
594 | * @param RecentChange &$rc |
595 | * @param bool $unpatrolled |
596 | * @param bool $watched |
597 | * @return string HTML |
598 | * @since 1.26 |
599 | */ |
600 | public function getArticleLink( &$rc, $unpatrolled, $watched ) { |
601 | $params = []; |
602 | if ( $rc->getTitle()->isRedirect() ) { |
603 | $params = [ 'redirect' => 'no' ]; |
604 | } |
605 | |
606 | $articlelink = $this->linkRenderer->makeLink( |
607 | $rc->getTitle(), |
608 | null, |
609 | [ 'class' => 'mw-changeslist-title' ], |
610 | $params |
611 | ); |
612 | if ( static::isDeleted( $rc, RevisionRecord::DELETED_TEXT ) ) { |
613 | $class = 'history-deleted'; |
614 | if ( static::isDeleted( $rc, RevisionRecord::DELETED_RESTRICTED ) ) { |
615 | $class .= ' mw-history-suppressed'; |
616 | } |
617 | $articlelink = '<span class="' . $class . '">' . $articlelink . '</span>'; |
618 | } |
619 | # To allow for boldening pages watched by this user |
620 | $articlelink = "<span class=\"mw-title\">{$articlelink}</span>"; |
621 | # RTL/LTR marker |
622 | $articlelink .= $this->getLanguage()->getDirMark(); |
623 | |
624 | # TODO: Deprecate the $s argument, it seems happily unused. |
625 | $s = ''; |
626 | $this->getHookRunner()->onChangesListInsertArticleLink( $this, $articlelink, |
627 | $s, $rc, $unpatrolled, $watched ); |
628 | |
629 | // Watchlist expiry icon. |
630 | $watchlistExpiry = ''; |
631 | if ( isset( $rc->watchlistExpiry ) && $rc->watchlistExpiry ) { |
632 | $watchlistExpiry = $this->getWatchlistExpiry( $rc ); |
633 | } |
634 | |
635 | return "{$s} {$articlelink}{$watchlistExpiry}"; |
636 | } |
637 | |
638 | /** |
639 | * Get HTML to display the clock icon for watched items that have a watchlist expiry time. |
640 | * @since 1.35 |
641 | * @param RecentChange $recentChange |
642 | * @return string The HTML to display an indication of the expiry time. |
643 | */ |
644 | public function getWatchlistExpiry( RecentChange $recentChange ): string { |
645 | $item = WatchedItem::newFromRecentChange( $recentChange, $this->getUser() ); |
646 | // Guard against expired items, even though they shouldn't come here. |
647 | if ( $item->isExpired() ) { |
648 | return ''; |
649 | } |
650 | $daysLeftText = $item->getExpiryInDaysText( $this->getContext() ); |
651 | // Matching widget is also created in ChangesListSpecialPage, for the legend. |
652 | $widget = new IconWidget( [ |
653 | 'icon' => 'clock', |
654 | 'title' => $daysLeftText, |
655 | 'classes' => [ 'mw-changesList-watchlistExpiry' ], |
656 | ] ); |
657 | $widget->setAttributes( [ |
658 | // Add labels for assistive technologies. |
659 | 'role' => 'img', |
660 | 'aria-label' => $this->msg( 'watchlist-expires-in-aria-label' )->text(), |
661 | // Days-left is used in resources/src/mediawiki.special.changeslist.watchlistexpiry/watchlistexpiry.js |
662 | 'data-days-left' => $item->getExpiryInDays(), |
663 | ] ); |
664 | // Add spaces around the widget (the page title is to one side, |
665 | // and a semicolon or opening-parenthesis to the other). |
666 | return " $widget "; |
667 | } |
668 | |
669 | /** |
670 | * Get the timestamp from $rc formatted with current user's settings |
671 | * and a separator |
672 | * |
673 | * @param RecentChange $rc |
674 | * @deprecated use revDateLink instead. |
675 | * @return string HTML fragment |
676 | */ |
677 | public function getTimestamp( $rc ) { |
678 | // This uses the semi-colon separator unless there's a watchlist expiry date for the entry, |
679 | // because in that case the timestamp is preceded by a clock icon. |
680 | // A space is important after `.mw-changeslist-separator--semicolon` to make sure |
681 | // that whatever comes before it is distinguishable. |
682 | // (Otherwise your have the text of titles pushing up against the timestamp) |
683 | // A specific element is used for this purpose rather than styling `.mw-changeslist-date` |
684 | // as the `.mw-changeslist-date` class is used in a variety |
685 | // of other places with a different position and the information proceeding getTimestamp can vary. |
686 | // The `.mw-changeslist-time` class allows us to distinguish from `.mw-changeslist-date` elements that |
687 | // contain the full date (month, year) and adds consistency with Special:Contributions |
688 | // and other pages. |
689 | $separatorClass = $rc->watchlistExpiry ? 'mw-changeslist-separator' : 'mw-changeslist-separator--semicolon'; |
690 | return Html::element( 'span', [ 'class' => $separatorClass ] ) . ' ' . |
691 | '<span class="mw-changeslist-date mw-changeslist-time">' . |
692 | htmlspecialchars( $this->getLanguage()->userTime( |
693 | $rc->mAttribs['rc_timestamp'], |
694 | $this->getUser() |
695 | ) ) . '</span> <span class="mw-changeslist-separator"></span> '; |
696 | } |
697 | |
698 | /** |
699 | * Insert time timestamp string from $rc into $s |
700 | * |
701 | * @param string &$s HTML to update |
702 | * @param RecentChange $rc |
703 | */ |
704 | public function insertTimestamp( &$s, $rc ) { |
705 | $s .= $this->getTimestamp( $rc ); |
706 | } |
707 | |
708 | /** |
709 | * Insert links to user page, user talk page and eventually a blocking link |
710 | * |
711 | * @param string &$s HTML to update |
712 | * @param RecentChange &$rc |
713 | */ |
714 | public function insertUserRelatedLinks( &$s, &$rc ) { |
715 | if ( static::isDeleted( $rc, RevisionRecord::DELETED_USER ) ) { |
716 | $deletedClass = 'history-deleted'; |
717 | if ( static::isDeleted( $rc, RevisionRecord::DELETED_RESTRICTED ) ) { |
718 | $deletedClass .= ' mw-history-suppressed'; |
719 | } |
720 | $s .= ' <span class="' . $deletedClass . '">' . |
721 | $this->msg( 'rev-deleted-user' )->escaped() . '</span>'; |
722 | } else { |
723 | $s .= $this->getLanguage()->getDirMark(); |
724 | $s .= $this->userLinkCache->getWithSetCallback( |
725 | $this->userLinkCache->makeKey( |
726 | $rc->mAttribs['rc_user_text'], |
727 | $this->getUser()->getName(), |
728 | $this->getLanguage()->getCode() |
729 | ), |
730 | static function () use ( $rc ) { |
731 | return Linker::userLink( |
732 | $rc->mAttribs['rc_user'], |
733 | $rc->mAttribs['rc_user_text'] |
734 | ) . Linker::userToolLinks( |
735 | $rc->mAttribs['rc_user'], $rc->mAttribs['rc_user_text'], |
736 | false, 0, null, |
737 | // The text content of tools is not wrapped with parentheses or "piped". |
738 | // This will be handled in CSS (T205581). |
739 | false |
740 | ); |
741 | } |
742 | ); |
743 | } |
744 | } |
745 | |
746 | /** |
747 | * Insert a formatted action |
748 | * |
749 | * @param RecentChange $rc |
750 | * @return string |
751 | */ |
752 | public function insertLogEntry( $rc ) { |
753 | $formatter = LogFormatter::newFromRow( $rc->mAttribs ); |
754 | $formatter->setContext( $this->getContext() ); |
755 | $formatter->setShowUserToolLinks( true ); |
756 | $mark = $this->getLanguage()->getDirMark(); |
757 | |
758 | return Html::openElement( 'span', [ 'class' => 'mw-changeslist-log-entry' ] ) |
759 | . $formatter->getActionText() |
760 | . " $mark" |
761 | . $formatter->getComment() |
762 | . $this->message['word-separator'] |
763 | . $formatter->getActionLinks() |
764 | . Html::closeElement( 'span' ); |
765 | } |
766 | |
767 | /** |
768 | * Insert a formatted comment |
769 | * @param RecentChange $rc |
770 | * @return string |
771 | */ |
772 | public function insertComment( $rc ) { |
773 | if ( static::isDeleted( $rc, RevisionRecord::DELETED_COMMENT ) ) { |
774 | $deletedClass = 'history-deleted'; |
775 | if ( static::isDeleted( $rc, RevisionRecord::DELETED_RESTRICTED ) ) { |
776 | $deletedClass .= ' mw-history-suppressed'; |
777 | } |
778 | return ' <span class="' . $deletedClass . ' comment">' . |
779 | $this->msg( 'rev-deleted-comment' )->escaped() . '</span>'; |
780 | } elseif ( isset( $rc->mAttribs['rc_id'] ) |
781 | && isset( $this->formattedComments[$rc->mAttribs['rc_id']] ) |
782 | ) { |
783 | return $this->formattedComments[$rc->mAttribs['rc_id']]; |
784 | } else { |
785 | return $this->commentFormatter->formatBlock( |
786 | $rc->mAttribs['rc_comment'], |
787 | $rc->getTitle(), |
788 | // Whether section links should refer to local page (using default false) |
789 | false, |
790 | // wikid to generate links for (using default null) */ |
791 | null, |
792 | // whether parentheses should be rendered as part of the message |
793 | false |
794 | ); |
795 | } |
796 | } |
797 | |
798 | /** |
799 | * Returns the string which indicates the number of watching users |
800 | * @param int $count Number of user watching a page |
801 | * @return string |
802 | */ |
803 | protected function numberofWatchingusers( $count ) { |
804 | if ( $count <= 0 ) { |
805 | return ''; |
806 | } |
807 | |
808 | return $this->watchMsgCache->getWithSetCallback( |
809 | $this->watchMsgCache->makeKey( |
810 | 'watching-users-msg', |
811 | strval( $count ), |
812 | $this->getUser()->getName(), |
813 | $this->getLanguage()->getCode() |
814 | ), |
815 | function () use ( $count ) { |
816 | return $this->msg( 'number-of-watching-users-for-recent-changes' ) |
817 | ->numParams( $count )->escaped(); |
818 | } |
819 | ); |
820 | } |
821 | |
822 | /** |
823 | * Determine if said field of a revision is hidden |
824 | * @param RCCacheEntry|RecentChange $rc |
825 | * @param int $field One of DELETED_* bitfield constants |
826 | * @return bool |
827 | */ |
828 | public static function isDeleted( $rc, $field ) { |
829 | return ( $rc->mAttribs['rc_deleted'] & $field ) == $field; |
830 | } |
831 | |
832 | /** |
833 | * Determine if the current user is allowed to view a particular |
834 | * field of this revision, if it's marked as deleted. |
835 | * @param RCCacheEntry|RecentChange $rc |
836 | * @param int $field |
837 | * @param Authority|null $performer to check permissions against. If null, the global RequestContext's |
838 | * User is assumed instead. |
839 | * @return bool |
840 | */ |
841 | public static function userCan( $rc, $field, Authority $performer = null ) { |
842 | $performer ??= RequestContext::getMain()->getAuthority(); |
843 | |
844 | if ( $rc->mAttribs['rc_type'] == RC_LOG ) { |
845 | return LogEventsList::userCanBitfield( $rc->mAttribs['rc_deleted'], $field, $performer ); |
846 | } |
847 | |
848 | return RevisionRecord::userCanBitfield( $rc->mAttribs['rc_deleted'], $field, $performer ); |
849 | } |
850 | |
851 | /** |
852 | * @param string $link |
853 | * @param bool $watched |
854 | * @return string |
855 | */ |
856 | protected function maybeWatchedLink( $link, $watched = false ) { |
857 | if ( $watched ) { |
858 | return '<strong class="mw-watched">' . $link . '</strong>'; |
859 | } else { |
860 | return '<span class="mw-rc-unwatched">' . $link . '</span>'; |
861 | } |
862 | } |
863 | |
864 | /** |
865 | * Insert a rollback link |
866 | * |
867 | * @param string &$s |
868 | * @param RecentChange &$rc |
869 | */ |
870 | public function insertRollback( &$s, &$rc ) { |
871 | $this->insertPageTools( $s, $rc ); |
872 | } |
873 | |
874 | /** |
875 | * Insert an extensible set of page tools into the changelist row |
876 | * which includes a rollback link and undo link if applicable. |
877 | * |
878 | * @param string &$s |
879 | * @param RecentChange &$rc |
880 | * |
881 | */ |
882 | private function insertPageTools( &$s, &$rc ) { |
883 | // FIXME Some page tools (e.g. thanks) might make sense for log entries. |
884 | if ( !in_array( $rc->mAttribs['rc_type'], [ RC_EDIT, RC_NEW ] ) |
885 | // FIXME When would either of these not exist when type is RC_EDIT? Document. |
886 | || !$rc->mAttribs['rc_this_oldid'] |
887 | || !$rc->mAttribs['rc_cur_id'] |
888 | ) { |
889 | return; |
890 | } |
891 | |
892 | // Construct a fake revision for PagerTools. FIXME can't we just obtain the real one? |
893 | $title = $rc->getTitle(); |
894 | $revRecord = new MutableRevisionRecord( $title ); |
895 | $revRecord->setId( (int)$rc->mAttribs['rc_this_oldid'] ); |
896 | $revRecord->setVisibility( (int)$rc->mAttribs['rc_deleted'] ); |
897 | $user = new UserIdentityValue( |
898 | (int)$rc->mAttribs['rc_user'], |
899 | $rc->mAttribs['rc_user_text'] |
900 | ); |
901 | $revRecord->setUser( $user ); |
902 | |
903 | $tools = new PagerTools( |
904 | $revRecord, |
905 | null, |
906 | // only show a rollback link on the top-most revision |
907 | $rc->getAttribute( 'page_latest' ) == $rc->mAttribs['rc_this_oldid'] |
908 | && $rc->mAttribs['rc_type'] != RC_NEW, |
909 | $this->getHookRunner(), |
910 | $title, |
911 | $this->getContext(), |
912 | // @todo: Inject |
913 | MediaWikiServices::getInstance()->getLinkRenderer() |
914 | ); |
915 | |
916 | $s .= $tools->toHTML(); |
917 | } |
918 | |
919 | /** |
920 | * @param RecentChange $rc |
921 | * @return string |
922 | * @since 1.26 |
923 | */ |
924 | public function getRollback( RecentChange $rc ) { |
925 | $s = ''; |
926 | $this->insertRollback( $s, $rc ); |
927 | return $s; |
928 | } |
929 | |
930 | /** |
931 | * @param string &$s |
932 | * @param RecentChange &$rc |
933 | * @param string[] &$classes |
934 | */ |
935 | public function insertTags( &$s, &$rc, &$classes ) { |
936 | if ( empty( $rc->mAttribs['ts_tags'] ) ) { |
937 | return; |
938 | } |
939 | |
940 | /** |
941 | * Tags are repeated for a lot of the records, so during single run of RecentChanges, we |
942 | * should cache those that were already processed as doing that for each record takes |
943 | * significant amount of time. |
944 | */ |
945 | [ $tagSummary, $newClasses ] = $this->tagsCache->getWithSetCallback( |
946 | $this->tagsCache->makeKey( |
947 | $rc->mAttribs['ts_tags'], |
948 | $this->getUser()->getName(), |
949 | $this->getLanguage()->getCode() |
950 | ), |
951 | fn () => ChangeTags::formatSummaryRow( |
952 | $rc->mAttribs['ts_tags'], |
953 | 'changeslist', |
954 | $this->getContext() |
955 | ) |
956 | ); |
957 | $classes = array_merge( $classes, $newClasses ); |
958 | $s .= ' ' . $tagSummary; |
959 | } |
960 | |
961 | /** |
962 | * @param RecentChange $rc |
963 | * @param string[] &$classes |
964 | * @return string |
965 | * @since 1.26 |
966 | */ |
967 | public function getTags( RecentChange $rc, array &$classes ) { |
968 | $s = ''; |
969 | $this->insertTags( $s, $rc, $classes ); |
970 | return $s; |
971 | } |
972 | |
973 | public function insertExtra( &$s, &$rc, &$classes ) { |
974 | // Empty, used for subclasses to add anything special. |
975 | } |
976 | |
977 | protected function showAsUnpatrolled( RecentChange $rc ) { |
978 | return self::isUnpatrolled( $rc, $this->getUser() ); |
979 | } |
980 | |
981 | /** |
982 | * @param stdClass|RecentChange $rc Database row from recentchanges or a RecentChange object |
983 | * @param User $user |
984 | * @return bool |
985 | */ |
986 | public static function isUnpatrolled( $rc, User $user ) { |
987 | if ( $rc instanceof RecentChange ) { |
988 | $isPatrolled = $rc->mAttribs['rc_patrolled']; |
989 | $rcType = $rc->mAttribs['rc_type']; |
990 | $rcLogType = $rc->mAttribs['rc_log_type']; |
991 | } else { |
992 | $isPatrolled = $rc->rc_patrolled; |
993 | $rcType = $rc->rc_type; |
994 | $rcLogType = $rc->rc_log_type; |
995 | } |
996 | |
997 | if ( $isPatrolled ) { |
998 | return false; |
999 | } |
1000 | |
1001 | return $user->useRCPatrol() || |
1002 | ( $rcType == RC_NEW && $user->useNPPatrol() ) || |
1003 | ( $rcLogType === 'upload' && $user->useFilePatrol() ); |
1004 | } |
1005 | |
1006 | /** |
1007 | * Determines whether a revision is linked to this change; this may not be the case |
1008 | * when the categorization wasn't done by an edit but a conditional parser function |
1009 | * |
1010 | * @since 1.27 |
1011 | * |
1012 | * @param RecentChange|RCCacheEntry $rcObj |
1013 | * @return bool |
1014 | */ |
1015 | protected function isCategorizationWithoutRevision( $rcObj ) { |
1016 | return intval( $rcObj->getAttribute( 'rc_type' ) ) === RC_CATEGORIZE |
1017 | && intval( $rcObj->getAttribute( 'rc_this_oldid' ) ) === 0; |
1018 | } |
1019 | |
1020 | /** |
1021 | * Get recommended data attributes for a change line. |
1022 | * @param RecentChange $rc |
1023 | * @return string[] attribute name => value |
1024 | */ |
1025 | protected function getDataAttributes( RecentChange $rc ) { |
1026 | $attrs = []; |
1027 | |
1028 | $type = $rc->getAttribute( 'rc_source' ); |
1029 | switch ( $type ) { |
1030 | case RecentChange::SRC_EDIT: |
1031 | case RecentChange::SRC_CATEGORIZE: |
1032 | case RecentChange::SRC_NEW: |
1033 | $attrs['data-mw-revid'] = $rc->mAttribs['rc_this_oldid']; |
1034 | break; |
1035 | case RecentChange::SRC_LOG: |
1036 | $attrs['data-mw-logid'] = $rc->mAttribs['rc_logid']; |
1037 | $attrs['data-mw-logaction'] = |
1038 | $rc->mAttribs['rc_log_type'] . '/' . $rc->mAttribs['rc_log_action']; |
1039 | break; |
1040 | } |
1041 | |
1042 | $attrs[ 'data-mw-ts' ] = $rc->getAttribute( 'rc_timestamp' ); |
1043 | |
1044 | return $attrs; |
1045 | } |
1046 | |
1047 | /** |
1048 | * Sets the callable that generates a change line prefix added to the beginning of each line. |
1049 | * |
1050 | * @param callable $prefixer Callable to run that generates the change line prefix. |
1051 | * Takes three parameters: a RecentChange object, a ChangesList object, |
1052 | * and whether the current entry is a grouped entry. |
1053 | */ |
1054 | public function setChangeLinePrefixer( callable $prefixer ) { |
1055 | $this->changeLinePrefixer = $prefixer; |
1056 | } |
1057 | } |