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