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