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