Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.90% covered (success)
92.90%
419 / 451
45.71% covered (danger)
45.71%
16 / 35
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContributionsPager
92.90% covered (success)
92.90%
419 / 451
45.71% covered (danger)
45.71%
16 / 35
134.94
0.00% covered (danger)
0.00%
0 / 1
 __construct
95.92% covered (success)
95.92%
47 / 49
0.00% covered (danger)
0.00%
0 / 1
8
 getDefaultQuery
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 reallyDoQuery
97.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
9
 getRevisionQuery
n/a
0 / 0
n/a
0 / 0
0
 getQueryInfo
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
12
 getNamespaceCond
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getTagFilter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTagInvert
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTarget
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isNewOnly
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNamespace
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doBatchLookups
76.92% covered (warning)
76.92%
30 / 39
0.00% covered (danger)
0.00%
0 / 1
11.23
 getStartBody
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEndBody
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEmptyBody
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tryCreatingRevisionRecord
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
 createRevisionRecord
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 populateAttributes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatArticleLink
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 formatDiffHistLinks
98.33% covered (success)
98.33%
59 / 60
0.00% covered (danger)
0.00%
0 / 1
7
 formatDateLink
93.94% covered (success)
93.94%
31 / 33
0.00% covered (danger)
0.00%
0 / 1
7.01
 formatTopMarkText
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
7
 formatCharDiff
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 formatComment
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 formatUserLink
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 formatFlags
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 formatVisibilityLink
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 formatTags
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 revisionUserIsDeleted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatRow
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
11.06
 getTemplateParams
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
4
 getProcessedTemplate
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getSqlComment
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 preventClickjacking
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setPreventClickjacking
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPreventClickjacking
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 * @ingroup Pager
20 */
21
22namespace MediaWiki\Pager;
23
24use ChangesList;
25use ChangeTags;
26use HtmlArmor;
27use InvalidArgumentException;
28use MapCacheLRU;
29use MediaWiki\Cache\LinkBatchFactory;
30use MediaWiki\CommentFormatter\CommentFormatter;
31use MediaWiki\Context\IContextSource;
32use MediaWiki\HookContainer\HookContainer;
33use MediaWiki\HookContainer\HookRunner;
34use MediaWiki\Html\Html;
35use MediaWiki\Html\TemplateParser;
36use MediaWiki\Linker\Linker;
37use MediaWiki\Linker\LinkRenderer;
38use MediaWiki\MainConfigNames;
39use MediaWiki\MediaWikiServices;
40use MediaWiki\Parser\Sanitizer;
41use MediaWiki\Revision\RevisionRecord;
42use MediaWiki\Revision\RevisionStore;
43use MediaWiki\SpecialPage\SpecialPage;
44use MediaWiki\Title\NamespaceInfo;
45use MediaWiki\Title\Title;
46use MediaWiki\User\UserFactory;
47use MediaWiki\User\UserIdentity;
48use MediaWiki\User\UserRigorOptions;
49use stdClass;
50use Wikimedia\Rdbms\FakeResultWrapper;
51use Wikimedia\Rdbms\IResultWrapper;
52
53/**
54 * Pager for Special:Contributions
55 * @ingroup Pager
56 */
57abstract class ContributionsPager extends RangeChronologicalPager {
58
59    /** @inheritDoc */
60    public $mGroupByDate = true;
61
62    /**
63     * @var string[] Local cache for escaped messages
64     */
65    protected $messages;
66
67    /**
68     * @var bool Get revisions from the archive table (if true) or the revision table (if false)
69     */
70    protected $isArchive;
71
72    /**
73     * @var bool Run hooks to allow extensions to modify the page
74     */
75    protected $runHooks;
76
77    /**
78     * @var string User name, or a string describing an IP address range
79     */
80    protected $target;
81
82    /**
83     * @var string|int A single namespace number, or an empty string for all namespaces
84     */
85    private $namespace;
86
87    /**
88     * @var string[]|false Name of tag to filter, or false to ignore tags
89     */
90    private $tagFilter;
91
92    /**
93     * @var bool Set to true to invert the tag selection
94     */
95    private $tagInvert;
96
97    /**
98     * @var bool Set to true to invert the namespace selection
99     */
100    private $nsInvert;
101
102    /**
103     * @var bool Set to true to show both the subject and talk namespace, no matter which got
104     *  selected
105     */
106    private $associated;
107
108    /**
109     * @var bool Set to true to show only deleted revisions
110     */
111    private $deletedOnly;
112
113    /**
114     * @var bool Set to true to show only latest (a.k.a. current) revisions
115     */
116    private $topOnly;
117
118    /**
119     * @var bool Set to true to show only new pages
120     */
121    private $newOnly;
122
123    /**
124     * @var bool Set to true to hide edits marked as minor by the user
125     */
126    private $hideMinor;
127
128    /**
129     * @var bool Set to true to only include mediawiki revisions.
130     * (restricts extensions from executing additional queries to include their own contributions)
131     */
132    private $revisionsOnly;
133
134    /** @var bool */
135    private $preventClickjacking = false;
136
137    protected ?Title $currentPage;
138    protected ?RevisionRecord $currentRevRecord;
139
140    /**
141     * @var array
142     */
143    private $mParentLens;
144
145    /** @var UserIdentity */
146    protected $targetUser;
147
148    /**
149     * Set to protected to allow subclasses access for overrides
150     */
151    protected TemplateParser $templateParser;
152
153    private CommentFormatter $commentFormatter;
154    private HookRunner $hookRunner;
155    private LinkBatchFactory $linkBatchFactory;
156    protected NamespaceInfo $namespaceInfo;
157    protected RevisionStore $revisionStore;
158
159    /** @var string[] */
160    private $formattedComments = [];
161
162    /** @var RevisionRecord[] Cached revisions by ID */
163    private $revisions = [];
164
165    /** @var MapCacheLRU */
166    private $tagsCache;
167
168    /**
169     * Field names for various attributes. These may be overridden in a subclass,
170     * for example for getting revisions from the archive table.
171     */
172    protected string $revisionIdField = 'rev_id';
173    protected string $revisionParentIdField = 'rev_parent_id';
174    protected string $revisionTimestampField = 'rev_timestamp';
175    protected string $revisionLengthField = 'rev_len';
176    protected string $revisionDeletedField = 'rev_deleted';
177    protected string $revisionMinorField = 'rev_minor_edit';
178    protected string $userNameField = 'rev_user_text';
179    protected string $pageNamespaceField = 'page_namespace';
180    protected string $pageTitleField = 'page_title';
181
182    /**
183     * @param LinkRenderer $linkRenderer
184     * @param LinkBatchFactory $linkBatchFactory
185     * @param HookContainer $hookContainer
186     * @param RevisionStore $revisionStore
187     * @param NamespaceInfo $namespaceInfo
188     * @param CommentFormatter $commentFormatter
189     * @param UserFactory $userFactory
190     * @param IContextSource $context
191     * @param array $options
192     * @param UserIdentity|null $targetUser
193     */
194    public function __construct(
195        LinkRenderer $linkRenderer,
196        LinkBatchFactory $linkBatchFactory,
197        HookContainer $hookContainer,
198        RevisionStore $revisionStore,
199        NamespaceInfo $namespaceInfo,
200        CommentFormatter $commentFormatter,
201        UserFactory $userFactory,
202        IContextSource $context,
203        array $options,
204        ?UserIdentity $targetUser
205    ) {
206        $this->isArchive = $options['isArchive'] ?? false;
207        $this->runHooks = $options['runHooks'] ?? true;
208
209        // Set ->target before calling parent::__construct() so
210        // parent can call $this->getIndexField() and get the right result. Set
211        // the rest too just to keep things simple.
212        if ( $targetUser ) {
213            $this->target = $options['target'] ?? $targetUser->getName();
214            $this->targetUser = $targetUser;
215        } else {
216            // Use target option
217            // It's possible for the target to be empty. This is used by
218            // ContribsPagerTest and does not cause newFromName() to return
219            // false. It's probably not used by any production code.
220            $this->target = $options['target'] ?? '';
221            // @phan-suppress-next-line PhanPossiblyNullTypeMismatchProperty RIGOR_NONE never returns null
222            $this->targetUser = $userFactory->newFromName(
223                $this->target, UserRigorOptions::RIGOR_NONE
224            );
225            if ( !$this->targetUser ) {
226                // This can happen if the target contained "#". Callers
227                // typically pass user input through title normalization to
228                // avoid it.
229                throw new InvalidArgumentException( __METHOD__ . ': the user name is too ' .
230                    'broken to use even with validation disabled.' );
231            }
232        }
233
234        $this->namespace = $options['namespace'] ?? '';
235        $this->tagFilter = $options['tagfilter'] ?? false;
236        $this->tagInvert = $options['tagInvert'] ?? false;
237        $this->nsInvert = $options['nsInvert'] ?? false;
238        $this->associated = $options['associated'] ?? false;
239
240        $this->deletedOnly = !empty( $options['deletedOnly'] );
241        $this->topOnly = !empty( $options['topOnly'] );
242        $this->newOnly = !empty( $options['newOnly'] );
243        $this->hideMinor = !empty( $options['hideMinor'] );
244        $this->revisionsOnly = !empty( $options['revisionsOnly'] );
245
246        parent::__construct( $context, $linkRenderer );
247
248        $msgs = [
249            'diff',
250            'hist',
251            'pipe-separator',
252            'uctop',
253            'changeslist-nocomment',
254            'undeleteviewlink',
255            'undeleteviewlink',
256            'deletionlog',
257        ];
258
259        foreach ( $msgs as $msg ) {
260            $this->messages[$msg] = $this->msg( $msg )->escaped();
261        }
262
263        // Date filtering: use timestamp if available
264        $startTimestamp = '';
265        $endTimestamp = '';
266        if ( isset( $options['start'] ) && $options['start'] ) {
267            $startTimestamp = $options['start'] . ' 00:00:00';
268        }
269        if ( isset( $options['end'] ) && $options['end'] ) {
270            $endTimestamp = $options['end'] . ' 23:59:59';
271        }
272        $this->getDateRangeCond( $startTimestamp, $endTimestamp );
273
274        $this->templateParser = new TemplateParser();
275        $this->linkBatchFactory = $linkBatchFactory;
276        $this->hookRunner = new HookRunner( $hookContainer );
277        $this->revisionStore = $revisionStore;
278        $this->namespaceInfo = $namespaceInfo;
279        $this->commentFormatter = $commentFormatter;
280        $this->tagsCache = new MapCacheLRU( 50 );
281    }
282
283    public function getDefaultQuery() {
284        $query = parent::getDefaultQuery();
285        $query['target'] = $this->target;
286
287        return $query;
288    }
289
290    /**
291     * This method basically executes the exact same code as the parent class, though with
292     * a hook added, to allow extensions to add additional queries.
293     *
294     * @param string $offset Index offset, inclusive
295     * @param int $limit Exact query limit
296     * @param bool $order IndexPager::QUERY_ASCENDING or IndexPager::QUERY_DESCENDING
297     * @return IResultWrapper
298     */
299    public function reallyDoQuery( $offset, $limit, $order ) {
300        [ $tables, $fields, $conds, $fname, $options, $join_conds ] = $this->buildQueryInfo(
301            $offset,
302            $limit,
303            $order
304        );
305
306        $options['MAX_EXECUTION_TIME'] =
307            $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries );
308        /*
309         * This hook will allow extensions to add in additional queries, so they can get their data
310         * in My Contributions as well. Extensions should append their results to the $data array.
311         *
312         * Extension queries have to implement the navbar requirement as well. They should
313         * - have a column aliased as $pager->getIndexField()
314         * - have LIMIT set
315         * - have a WHERE-clause that compares the $pager->getIndexField()-equivalent column to the offset
316         * - have the ORDER BY specified based upon the details provided by the navbar
317         *
318         * See includes/Pager.php buildQueryInfo() method on how to build LIMIT, WHERE & ORDER BY
319         *
320         * &$data: an array of results of all contribs queries
321         * $pager: the ContribsPager object hooked into
322         * $offset: see phpdoc above
323         * $limit: see phpdoc above
324         * $descending: see phpdoc above
325         */
326        $dbr = $this->getDatabase();
327        $data = [ $dbr->newSelectQueryBuilder()
328            ->tables( is_array( $tables ) ? $tables : [ $tables ] )
329            ->fields( $fields )
330            ->conds( $conds )
331            ->caller( $fname )
332            ->options( $options )
333            ->joinConds( $join_conds )
334            ->setMaxExecutionTime( $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries ) )
335            ->fetchResultSet() ];
336        if ( !$this->revisionsOnly && $this->runHooks ) {
337            // These hooks were moved from ContribsPager and DeletedContribsPager. For backwards
338            // compatability, they keep the same names. But they should be run for any contributions
339            // pager, otherwise the entries from extensions would be missing.
340            $reallyDoQueryHook = $this->isArchive ?
341                'onDeletedContribsPager__reallyDoQuery' :
342                'onContribsPager__reallyDoQuery';
343            // TODO: Range offsets are fairly important and all handlers should take care of it.
344            // If this hook will be replaced (e.g. unified with the DeletedContribsPager one),
345            // please consider passing [ $this->endOffset, $this->startOffset ] to it (T167577).
346            $this->hookRunner->$reallyDoQueryHook( $data, $this, $offset, $limit, $order );
347        }
348
349        $result = [];
350
351        // loop all results and collect them in an array
352        foreach ( $data as $query ) {
353            foreach ( $query as $i => $row ) {
354                // If the query results are in descending order, the indexes must also be in descending order
355                $index = $order === self::QUERY_ASCENDING ? $i : $limit - 1 - $i;
356                // Left-pad with zeroes, because these values will be sorted as strings
357                $index = str_pad( (string)$index, strlen( (string)$limit ), '0', STR_PAD_LEFT );
358                // use index column as key, allowing us to easily sort in PHP
359                $indexFieldValues = array_map(
360                    static fn ( $fieldName ) => $row->$fieldName,
361                    (array)$this->mIndexField
362                );
363                $result[implode( '-', $indexFieldValues ) . "-$index"] = $row;
364            }
365        }
366
367        // sort results
368        if ( $order === self::QUERY_ASCENDING ) {
369            ksort( $result );
370        } else {
371            krsort( $result );
372        }
373
374        // enforce limit
375        $result = array_slice( $result, 0, $limit );
376
377        // get rid of array keys
378        $result = array_values( $result );
379
380        return new FakeResultWrapper( $result );
381    }
382
383    /**
384     * Get queryInfo for the main query selecting revisions, not including
385     * filtering on namespace, date, etc.
386     *
387     * @return array
388     */
389    abstract protected function getRevisionQuery();
390
391    public function getQueryInfo() {
392        $queryInfo = $this->getRevisionQuery();
393
394        if ( $this->deletedOnly ) {
395            $queryInfo['conds'][] = $this->revisionDeletedField . ' != 0';
396        }
397
398        if ( !$this->isArchive && $this->topOnly ) {
399            $queryInfo['conds'][] = $this->revisionIdField . ' = page_latest';
400        }
401
402        if ( $this->newOnly ) {
403            $queryInfo['conds'][] = $this->revisionParentIdField . ' = 0';
404        }
405
406        if ( $this->hideMinor ) {
407            $queryInfo['conds'][] = $this->revisionMinorField . ' = 0';
408        }
409
410        $queryInfo['conds'] = array_merge( $queryInfo['conds'], $this->getNamespaceCond() );
411
412        // Paranoia: avoid brute force searches (T19342)
413        $dbr = $this->getDatabase();
414        if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
415            $queryInfo['conds'][] = $dbr->bitAnd(
416                $this->revisionDeletedField, RevisionRecord::DELETED_USER
417                ) . ' = 0';
418        } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
419            $queryInfo['conds'][] = $dbr->bitAnd(
420                $this->revisionDeletedField, RevisionRecord::SUPPRESSED_USER
421                ) . ' != ' . RevisionRecord::SUPPRESSED_USER;
422        }
423
424        // Index fields must be present in the result rows, as reallyDoQuery() tries to access them.
425        $indexFields = array_diff(
426            (array)$this->mIndexField,
427            $queryInfo['fields']
428        );
429
430        foreach ( $indexFields as $indexField ) {
431            // Skip if already added as an alias
432            if ( !array_key_exists( $indexField, $queryInfo['fields'] ) ) {
433                $queryInfo['fields'][] = $indexField;
434            }
435        }
436
437        MediaWikiServices::getInstance()->getChangeTagsStore()->modifyDisplayQuery(
438            $queryInfo['tables'],
439            $queryInfo['fields'],
440            $queryInfo['conds'],
441            $queryInfo['join_conds'],
442            $queryInfo['options'],
443            $this->tagFilter,
444            $this->tagInvert,
445        );
446
447        if ( !$this->isArchive && $this->runHooks ) {
448            $this->hookRunner->onContribsPager__getQueryInfo( $this, $queryInfo );
449        }
450
451        return $queryInfo;
452    }
453
454    protected function getNamespaceCond() {
455        if ( $this->namespace !== '' ) {
456            $dbr = $this->getDatabase();
457            $namespaces = [ $this->namespace ];
458            $eq_op = $this->nsInvert ? '!=' : '=';
459            if ( $this->associated ) {
460                $namespaces[] = $this->namespaceInfo->getAssociated( $this->namespace );
461            }
462            return [ $dbr->expr( $this->pageNamespaceField, $eq_op, $namespaces ) ];
463        }
464
465        return [];
466    }
467
468    /**
469     * @return false|string[]
470     */
471    public function getTagFilter() {
472        return $this->tagFilter;
473    }
474
475    /**
476     * @return bool
477     */
478    public function getTagInvert() {
479        return $this->tagInvert;
480    }
481
482    /**
483     * @return string
484     */
485    public function getTarget() {
486        return $this->target;
487    }
488
489    /**
490     * @return bool
491     */
492    public function isNewOnly() {
493        return $this->newOnly;
494    }
495
496    /**
497     * @return int|string
498     */
499    public function getNamespace() {
500        return $this->namespace;
501    }
502
503    protected function doBatchLookups() {
504        # Do a link batch query
505        $this->mResult->seek( 0 );
506        $parentRevIds = [];
507        $this->mParentLens = [];
508        $revisions = [];
509        $linkBatch = $this->linkBatchFactory->newLinkBatch();
510        # Give some pointers to make (last) links
511        foreach ( $this->mResult as $row ) {
512            $revisionRecord = $this->tryCreatingRevisionRecord( $row );
513            if ( !$revisionRecord ) {
514                continue;
515            }
516            if ( isset( $row->{$this->revisionParentIdField} ) && $row->{$this->revisionParentIdField} ) {
517                $parentRevIds[] = (int)$row->{$this->revisionParentIdField};
518            }
519            $this->mParentLens[(int)$row->{$this->revisionIdField}] = $row->{$this->revisionLengthField};
520            if ( $this->target !== $row->{$this->userNameField} ) {
521                // If the target does not match the author, batch the author's talk page
522                $linkBatch->add( NS_USER_TALK, $row->{$this->userNameField} );
523            }
524            $linkBatch->add( $row->{$this->pageNamespaceField}, $row->{$this->pageTitleField} );
525            $revisions[$row->{$this->revisionIdField}] = $this->createRevisionRecord( $row );
526        }
527        // Fetch rev_len/ar_len for revisions not already scanned above
528        // TODO: is it possible to make this fully abstract?
529        if ( $this->isArchive ) {
530            $parentRevIds = array_diff( $parentRevIds, array_keys( $this->mParentLens ) );
531            if ( $parentRevIds ) {
532                $result = $this->revisionStore
533                    ->newArchiveSelectQueryBuilder( $this->getDatabase() )
534                    ->clearFields()
535                    ->fields( [ $this->revisionIdField, $this->revisionLengthField ] )
536                    ->where( [ $this->revisionIdField => $parentRevIds ] )
537                    ->caller( __METHOD__ )
538                    ->fetchResultSet();
539                foreach ( $result as $row ) {
540                    $this->mParentLens[(int)$row->{$this->revisionIdField}] = $row->{$this->revisionLengthField};
541                }
542            }
543        }
544        $this->mParentLens += $this->revisionStore->getRevisionSizes(
545            array_diff( $parentRevIds, array_keys( $this->mParentLens ) )
546        );
547        $linkBatch->execute();
548
549        $revisionBatch = $this->commentFormatter->createRevisionBatch()
550            ->authority( $this->getAuthority() )
551            ->revisions( $revisions );
552
553        if ( !$this->isArchive ) {
554            // Only show public comments, because this page might be public
555            $revisionBatch = $revisionBatch->hideIfDeleted();
556        }
557
558        $this->formattedComments = $revisionBatch->execute();
559
560        # For performance, save the revision objects for later.
561        # The array is indexed by rev_id. doBatchLookups() may be called
562        # multiple times with different results, so merge the revisions array,
563        # ignoring any duplicates.
564        $this->revisions += $revisions;
565    }
566
567    /**
568     * @inheritDoc
569     */
570    protected function getStartBody() {
571        return "<section class='mw-pager-body'>\n";
572    }
573
574    /**
575     * @inheritDoc
576     */
577    protected function getEndBody() {
578        return "</section>\n";
579    }
580
581    /**
582     * @inheritDoc
583     */
584    protected function getEmptyBody() {
585        return $this->msg( 'nocontribs' )->parse();
586    }
587
588    /**
589     * If the object looks like a revision row, or corresponds to a previously
590     * cached revision, return the RevisionRecord. Otherwise, return null.
591     *
592     * @since 1.35
593     *
594     * @param mixed $row
595     * @param Title|null $title
596     * @return RevisionRecord|null
597     */
598    public function tryCreatingRevisionRecord( $row, $title = null ) {
599        if ( $row instanceof stdClass && isset( $row->{$this->revisionIdField} )
600            && isset( $this->revisions[$row->{$this->revisionIdField}] )
601        ) {
602            return $this->revisions[$row->{$this->revisionIdField}];
603        }
604
605        if (
606            $this->isArchive &&
607            $this->revisionStore->isRevisionRow( $row, 'archive' )
608        ) {
609            return $this->revisionStore->newRevisionFromArchiveRow( $row, 0, $title );
610        }
611
612        if (
613            !$this->isArchive &&
614            $this->revisionStore->isRevisionRow( $row )
615        ) {
616            return $this->revisionStore->newRevisionFromRow( $row, 0, $title );
617        }
618
619        return null;
620    }
621
622    /**
623     * Create a revision record from a $row that models a revision.
624     *
625     * @param mixed $row
626     * @param Title|null $title
627     * @return RevisionRecord
628     */
629    public function createRevisionRecord( $row, $title = null ) {
630        if ( $this->isArchive ) {
631            return $this->revisionStore->newRevisionFromArchiveRow( $row, 0, $title );
632        }
633
634        return $this->revisionStore->newRevisionFromRow( $row, 0, $title );
635    }
636
637    /**
638     * Populate the HTML attributes.
639     *
640     * @param mixed $row
641     * @param string[] &$attributes
642     */
643    protected function populateAttributes( $row, &$attributes ) {
644        $attributes['data-mw-revid'] = $this->currentRevRecord->getId();
645    }
646
647    /**
648     * Format a link to an article.
649     *
650     * @param mixed $row
651     * @return string
652     */
653    protected function formatArticleLink( $row ) {
654        if ( !$this->currentPage ) {
655            return '';
656        }
657        $dir = $this->getLanguage()->getDir();
658        return Html::rawElement( 'bdi', [ 'dir' => $dir ], $this->getLinkRenderer()->makeLink(
659            $this->currentPage,
660            $this->currentPage->getPrefixedText(),
661            [ 'class' => 'mw-contributions-title' ],
662            $this->currentPage->isRedirect() ? [ 'redirect' => 'no' ] : []
663        ) );
664    }
665
666    /**
667     * Format diff and history links.
668     *
669     * @param mixed $row
670     * @return string
671     */
672    protected function formatDiffHistLinks( $row ) {
673        if ( !$this->currentPage || !$this->currentRevRecord ) {
674            return '';
675        }
676        if ( $this->isArchive ) {
677            // Add the same links as DeletedContribsPager::formatRevisionRow
678            $undelete = SpecialPage::getTitleFor( 'Undelete' );
679            if ( $this->getAuthority()->isAllowed( 'deletedtext' ) ) {
680                $last = $this->getLinkRenderer()->makeKnownLink(
681                    $undelete,
682                    new HtmlArmor( $this->messages['diff'] ),
683                    [],
684                    [
685                        'target' => $this->currentPage->getPrefixedText(),
686                        'timestamp' => $this->currentRevRecord->getTimestamp(),
687                        'diff' => 'prev'
688                    ]
689                );
690            } else {
691                $last = $this->messages['diff'];
692            }
693
694            $logs = SpecialPage::getTitleFor( 'Log' );
695            $dellog = $this->getLinkRenderer()->makeKnownLink(
696                $logs,
697                new HtmlArmor( $this->messages['deletionlog'] ),
698                [],
699                [
700                    'type' => 'delete',
701                    'page' => $this->currentPage->getPrefixedText()
702                ]
703            );
704
705            $reviewlink = $this->getLinkRenderer()->makeKnownLink(
706                SpecialPage::getTitleFor( 'Undelete', $this->currentPage->getPrefixedDBkey() ),
707                new HtmlArmor( $this->messages['undeleteviewlink'] )
708            );
709
710            return Html::rawElement(
711                'span',
712                [ 'class' => 'mw-deletedcontribs-tools' ],
713                $this->msg( 'parentheses' )->rawParams( $this->getLanguage()->pipeList(
714                    [ $last, $dellog, $reviewlink ] ) )->escaped()
715            );
716        } else {
717            # Is there a visible previous revision?
718            if ( $this->currentRevRecord->getParentId() !== 0 &&
719                $this->currentRevRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
720            ) {
721                $difftext = $this->getLinkRenderer()->makeKnownLink(
722                    $this->currentPage,
723                    new HtmlArmor( $this->messages['diff'] ),
724                    [ 'class' => 'mw-changeslist-diff' ],
725                    [
726                        'diff' => 'prev',
727                        'oldid' => $row->{$this->revisionIdField},
728                    ]
729                );
730            } else {
731                $difftext = $this->messages['diff'];
732            }
733            $histlink = $this->getLinkRenderer()->makeKnownLink(
734                $this->currentPage,
735                new HtmlArmor( $this->messages['hist'] ),
736                [ 'class' => 'mw-changeslist-history' ],
737                [ 'action' => 'history' ]
738            );
739
740            // While it might be tempting to use a list here
741            // this would result in clutter and slows down navigating the content
742            // in assistive technology.
743            // See https://phabricator.wikimedia.org/T205581#4734812
744            return Html::rawElement( 'span',
745                [ 'class' => 'mw-changeslist-links' ],
746                // The spans are needed to ensure the dividing '|' elements are not
747                // themselves styled as links.
748                Html::rawElement( 'span', [], $difftext ) .
749                ' ' . // Space needed for separating two words.
750                Html::rawElement( 'span', [], $histlink )
751            );
752        }
753    }
754
755    /**
756     * Format a date link.
757     *
758     * @param mixed $row
759     * @return string
760     */
761    protected function formatDateLink( $row ) {
762        if ( !$this->currentPage || !$this->currentRevRecord ) {
763            return '';
764        }
765        if ( $this->isArchive ) {
766            $date = $this->getLanguage()->userTimeAndDate(
767                $this->currentRevRecord->getTimestamp(),
768                $this->getUser()
769            );
770
771            if ( $this->getAuthority()->isAllowed( 'undelete' ) &&
772                $this->currentRevRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
773            ) {
774                $dateLink = $this->getLinkRenderer()->makeKnownLink(
775                    SpecialPage::getTitleFor( 'Undelete' ),
776                    $date,
777                    [ 'class' => 'mw-changeslist-date' ],
778                    [
779                        'target' => $this->currentPage->getPrefixedText(),
780                        'timestamp' => $this->currentRevRecord->getTimestamp()
781                    ]
782                );
783            } else {
784                $dateLink = htmlspecialchars( $date );
785            }
786            if ( $this->currentRevRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
787                $class = Linker::getRevisionDeletedClass( $this->currentRevRecord );
788                $dateLink = Html::rawElement(
789                    'span',
790                    [ 'class' => $class ],
791                    $dateLink
792                );
793            }
794        } else {
795            $dateLink = ChangesList::revDateLink(
796                $this->currentRevRecord,
797                $this->getAuthority(),
798                $this->getLanguage(),
799                $this->currentPage
800            );
801        }
802        return $dateLink;
803    }
804
805    /**
806     * Format annotation and add extra class if a row represents a latest revision.
807     *
808     * @param mixed $row
809     * @param string[] &$classes
810     * @return string
811     */
812    protected function formatTopMarkText( $row, &$classes ) {
813        if ( !$this->currentPage || !$this->currentRevRecord ) {
814            return '';
815        }
816        $topmarktext = '';
817        if ( !$this->isArchive ) {
818            $pagerTools = new PagerTools(
819                $this->currentRevRecord,
820                null,
821                $row->{$this->revisionIdField} === $row->page_latest && !$row->page_is_new,
822                $this->hookRunner,
823                $this->currentPage,
824                $this->getContext(),
825                $this->getLinkRenderer()
826            );
827            if ( $row->{$this->revisionIdField} === $row->page_latest ) {
828                $topmarktext .= '<span class="mw-uctop">' . $this->messages['uctop'] . '</span>';
829                $classes[] = 'mw-contributions-current';
830            }
831            if ( $pagerTools->shouldPreventClickjacking() ) {
832                $this->setPreventClickjacking( true );
833            }
834            $topmarktext .= $pagerTools->toHTML();
835        }
836        return $topmarktext;
837    }
838
839    /**
840     * Format annotation to show the size of a diff.
841     *
842     * @param mixed $row
843     * @return string
844     */
845    protected function formatCharDiff( $row ) {
846        if ( $row->{$this->revisionParentIdField} === null ) {
847            // For some reason rev_parent_id isn't populated for this row.
848            // Its rumoured this is true on wikipedia for some revisions (T36922).
849            // Next best thing is to have the total number of bytes.
850            $chardiff = ' <span class="mw-changeslist-separator"></span> ';
851            $chardiff .= Linker::formatRevisionSize( $row->{$this->revisionLengthField} );
852            $chardiff .= ' <span class="mw-changeslist-separator"></span> ';
853        } else {
854            $parentLen = 0;
855            if ( isset( $this->mParentLens[$row->{$this->revisionParentIdField}] ) ) {
856                $parentLen = $this->mParentLens[$row->{$this->revisionParentIdField}];
857            }
858
859            $chardiff = ' <span class="mw-changeslist-separator"></span> ';
860            $chardiff .= ChangesList::showCharacterDifference(
861                $parentLen,
862                $row->{$this->revisionLengthField},
863                $this->getContext()
864            );
865            $chardiff .= ' <span class="mw-changeslist-separator"></span> ';
866        }
867        return $chardiff;
868    }
869
870    /**
871     * Format a comment for a revision.
872     *
873     * @param mixed $row
874     * @return string
875     */
876    protected function formatComment( $row ) {
877        $comment = $this->formattedComments[$row->{$this->revisionIdField}];
878
879        if ( $comment === '' ) {
880            $defaultComment = $this->messages['changeslist-nocomment'];
881            $comment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
882        }
883
884        // Don't wrap result of this with <bdi> or any other element, see T377555
885        return $comment;
886    }
887
888    /**
889     * Format a user link.
890     *
891     * @param mixed $row
892     * @return string
893     */
894    protected function formatUserLink( $row ) {
895        if ( !$this->currentRevRecord ) {
896            return '';
897        }
898        $dir = $this->getLanguage()->getDir();
899
900        // When the author is different from the target, always show user and user talk links
901        $userlink = '';
902        $revUser = $this->currentRevRecord->getUser();
903        $revUserId = $revUser ? $revUser->getId() : 0;
904        $revUserText = $revUser ? $revUser->getName() : '';
905        if ( $this->target !== $revUserText ) {
906            $userlink = ' <span class="mw-changeslist-separator"></span> '
907                . Html::rawElement( 'bdi', [ 'dir' => $dir ],
908                    Linker::userLink( $revUserId, $revUserText ) );
909            $userlink .= ' ' . $this->msg( 'parentheses' )->rawParams(
910                Linker::userTalkLink( $revUserId, $revUserText ) )->escaped() . ' ';
911        }
912        return $userlink;
913    }
914
915    /**
916     * @param mixed $row
917     * @return string[]
918     */
919    protected function formatFlags( $row ) {
920        if ( !$this->currentRevRecord ) {
921            return [];
922        }
923        $flags = [];
924        if ( $this->currentRevRecord->getParentId() === 0 ) {
925            $flags[] = ChangesList::flag( 'newpage' );
926        }
927
928        if ( $this->currentRevRecord->isMinor() ) {
929            $flags[] = ChangesList::flag( 'minor' );
930        }
931        return $flags;
932    }
933
934    /**
935     * Format link for changing visibility.
936     *
937     * @param mixed $row
938     * @return string
939     */
940    protected function formatVisibilityLink( $row ) {
941        if ( !$this->currentPage || !$this->currentRevRecord ) {
942            return '';
943        }
944        $del = Linker::getRevDeleteLink(
945            $this->getAuthority(),
946            $this->currentRevRecord,
947            $this->currentPage
948        );
949        if ( $del !== '' ) {
950            $del .= ' ';
951        }
952        return $del;
953    }
954
955    /**
956     * @param mixed $row
957     * @param string[] &$classes
958     * @return string
959     */
960    protected function formatTags( $row, &$classes ) {
961        # Tags, if any. Save some time using a cache.
962        [ $tagSummary, $newClasses ] = $this->tagsCache->getWithSetCallback(
963            $this->tagsCache->makeKey(
964                $row->ts_tags ?? '',
965                $this->getUser()->getName(),
966                $this->getLanguage()->getCode()
967            ),
968            fn () => ChangeTags::formatSummaryRow(
969                $row->ts_tags,
970                null,
971                $this->getContext()
972            )
973        );
974        $classes = array_merge( $classes, $newClasses );
975        return $tagSummary;
976    }
977
978    /**
979     * Check whether the revision author is deleted
980     *
981     * @param mixed $row
982     * @return bool
983     */
984    public function revisionUserIsDeleted( $row ) {
985        return $this->currentRevRecord->isDeleted( RevisionRecord::DELETED_USER );
986    }
987
988    /**
989     * Generates each row in the contributions list.
990     *
991     * Contributions which are marked "top" are currently on top of the history.
992     * For these contributions, a [rollback] link is shown for users with roll-
993     * back privileges. The rollback link restores the most recent version that
994     * was not written by the target user.
995     *
996     * @todo This would probably look a lot nicer in a table.
997     * @param stdClass|mixed $row
998     * @return string
999     */
1000    public function formatRow( $row ) {
1001        $ret = '';
1002        $classes = [];
1003        $attribs = [];
1004
1005        $this->currentPage = null;
1006        $this->currentRevRecord = null;
1007
1008        // Create a title for the revision if possible
1009        // Rows from the hook may not include title information
1010        if ( isset( $row->{$this->pageNamespaceField} ) && isset( $row->{$this->pageTitleField} ) ) {
1011            $this->currentPage = Title::makeTitle( $row->{$this->pageNamespaceField}, $row->{$this->pageTitleField} );
1012        }
1013
1014        // Flow overrides the ContribsPager::reallyDoQuery hook, causing this
1015        // function to be called with a special object for $row. It expects us
1016        // skip formatting so that the row can be formatted by the
1017        // ContributionsLineEnding hook below.
1018        // FIXME: have some better way for extensions to provide formatted rows.
1019        $this->currentRevRecord = $this->tryCreatingRevisionRecord( $row, $this->currentPage );
1020        if ( $this->revisionsOnly || ( $this->currentRevRecord && $this->currentPage ) ) {
1021            $this->populateAttributes( $row, $attribs );
1022
1023            $templateParams = $this->getTemplateParams( $row, $classes );
1024            $ret = $this->getProcessedTemplate( $templateParams );
1025        }
1026
1027        if ( $this->runHooks ) {
1028            // Let extensions add data
1029            $lineEndingsHook = $this->isArchive ?
1030                'onDeletedContributionsLineEnding' :
1031                'onContributionsLineEnding';
1032            $this->hookRunner->$lineEndingsHook( $this, $ret, $row, $classes, $attribs );
1033        }
1034
1035        $attribs = array_filter( $attribs,
1036            [ Sanitizer::class, 'isReservedDataAttribute' ],
1037            ARRAY_FILTER_USE_KEY
1038        );
1039
1040        // TODO: Handle exceptions in the catch block above.  Do any extensions rely on
1041        // receiving empty rows?
1042
1043        if ( $classes === [] && $attribs === [] && $ret === '' ) {
1044            wfDebug( "Dropping ContributionsSpecialPage row that could not be formatted" );
1045            return "<!-- Could not format ContributionsSpecialPage row. -->\n";
1046        }
1047        $attribs['class'] = $classes;
1048
1049        // FIXME: The signature of the ContributionsLineEnding hook makes it
1050        // very awkward to move this LI wrapper into the template.