Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / pager / ContributionsPager.php
blob04fcac0a6954fae665b515d22250daeb01b604ba
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.
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.
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
18 * @file
19 * @ingroup Pager
22 namespace MediaWiki\Pager;
24 use ChangesList;
25 use ChangeTags;
26 use HtmlArmor;
27 use InvalidArgumentException;
28 use MapCacheLRU;
29 use MediaWiki\Cache\LinkBatchFactory;
30 use MediaWiki\CommentFormatter\CommentFormatter;
31 use MediaWiki\Context\IContextSource;
32 use MediaWiki\HookContainer\HookContainer;
33 use MediaWiki\HookContainer\HookRunner;
34 use MediaWiki\Html\Html;
35 use MediaWiki\Html\TemplateParser;
36 use MediaWiki\Linker\Linker;
37 use MediaWiki\Linker\LinkRenderer;
38 use MediaWiki\MainConfigNames;
39 use MediaWiki\MediaWikiServices;
40 use MediaWiki\Parser\Sanitizer;
41 use MediaWiki\Revision\RevisionRecord;
42 use MediaWiki\Revision\RevisionStore;
43 use MediaWiki\SpecialPage\SpecialPage;
44 use MediaWiki\Title\NamespaceInfo;
45 use MediaWiki\Title\Title;
46 use MediaWiki\User\UserFactory;
47 use MediaWiki\User\UserIdentity;
48 use MediaWiki\User\UserRigorOptions;
49 use stdClass;
50 use Wikimedia\Rdbms\FakeResultWrapper;
51 use Wikimedia\Rdbms\IResultWrapper;
53 /**
54 * Pager for Special:Contributions
55 * @ingroup Pager
57 abstract class ContributionsPager extends RangeChronologicalPager {
59 /** @inheritDoc */
60 public $mGroupByDate = true;
62 /**
63 * @var string[] Local cache for escaped messages
65 protected $messages;
67 /**
68 * @var bool Get revisions from the archive table (if true) or the revision table (if false)
70 protected $isArchive;
72 /**
73 * @var string User name, or a string describing an IP address range
75 protected $target;
77 /**
78 * @var string|int A single namespace number, or an empty string for all namespaces
80 private $namespace;
82 /**
83 * @var string[]|false Name of tag to filter, or false to ignore tags
85 private $tagFilter;
87 /**
88 * @var bool Set to true to invert the tag selection
90 private $tagInvert;
92 /**
93 * @var bool Set to true to invert the namespace selection
95 private $nsInvert;
97 /**
98 * @var bool Set to true to show both the subject and talk namespace, no matter which got
99 * selected
101 private $associated;
104 * @var bool Set to true to show only deleted revisions
106 private $deletedOnly;
109 * @var bool Set to true to show only latest (a.k.a. current) revisions
111 private $topOnly;
114 * @var bool Set to true to show only new pages
116 private $newOnly;
119 * @var bool Set to true to hide edits marked as minor by the user
121 private $hideMinor;
124 * @var bool Set to true to only include mediawiki revisions.
125 * (restricts extensions from executing additional queries to include their own contributions)
127 private $revisionsOnly;
129 /** @var bool */
130 private $preventClickjacking = false;
132 protected ?Title $currentPage;
133 protected ?RevisionRecord $currentRevRecord;
136 * @var array
138 private $mParentLens;
140 /** @var UserIdentity */
141 protected $targetUser;
144 * Set to protected to allow subclasses access for overrides
146 protected TemplateParser $templateParser;
148 private CommentFormatter $commentFormatter;
149 private HookRunner $hookRunner;
150 private LinkBatchFactory $linkBatchFactory;
151 protected NamespaceInfo $namespaceInfo;
152 protected RevisionStore $revisionStore;
154 /** @var string[] */
155 private $formattedComments = [];
157 /** @var RevisionRecord[] Cached revisions by ID */
158 private $revisions = [];
160 /** @var MapCacheLRU */
161 private $tagsCache;
164 * Field names for various attributes. These may be overridden in a subclass,
165 * for example for getting revisions from the archive table.
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';
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
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
201 $this->isArchive = $options['isArchive'] ?? false;
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
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.' );
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;
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'] );
240 parent::__construct( $context, $linkRenderer );
242 $msgs = [
243 'diff',
244 'hist',
245 'pipe-separator',
246 'uctop',
247 'changeslist-nocomment',
248 'undeleteviewlink',
249 'undeleteviewlink',
250 'deletionlog',
253 foreach ( $msgs as $msg ) {
254 $this->messages[$msg] = $this->msg( $msg )->escaped();
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';
263 if ( isset( $options['end'] ) && $options['end'] ) {
264 $endTimestamp = $options['end'] . ' 23:59:59';
266 $this->getDateRangeCond( $startTimestamp, $endTimestamp );
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 );
277 public function getDefaultQuery() {
278 $query = parent::getDefaultQuery();
279 $query['target'] = $this->target;
281 return $query;
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.
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
293 public function reallyDoQuery( $offset, $limit, $order ) {
294 [ $tables, $fields, $conds, $fname, $options, $join_conds ] = $this->buildQueryInfo(
295 $offset,
296 $limit,
297 $order
300 $options['MAX_EXECUTION_TIME'] =
301 $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries );
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.
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
312 * See includes/Pager.php buildQueryInfo() method on how to build LIMIT, WHERE & ORDER BY
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
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 );
343 $result = [];
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;
357 // sort results
358 if ( $order === self::QUERY_ASCENDING ) {
359 ksort( $result );
360 } else {
361 krsort( $result );
364 // enforce limit
365 $result = array_slice( $result, 0, $limit );
367 // get rid of array keys
368 $result = array_values( $result );
370 return new FakeResultWrapper( $result );
374 * Get queryInfo for the main query selecting revisions, not including
375 * filtering on namespace, date, etc.
377 * @return array
379 abstract protected function getRevisionQuery();
381 public function getQueryInfo() {
382 $queryInfo = $this->getRevisionQuery();
384 if ( $this->deletedOnly ) {
385 $queryInfo['conds'][] = $this->revisionDeletedField . ' != 0';
388 if ( !$this->isArchive && $this->topOnly ) {
389 $queryInfo['conds'][] = $this->revisionIdField . ' = page_latest';
392 if ( $this->newOnly ) {
393 $queryInfo['conds'][] = $this->revisionParentIdField . ' = 0';
396 if ( $this->hideMinor ) {
397 $queryInfo['conds'][] = $this->revisionMinorField . ' = 0';
400 $queryInfo['conds'] = array_merge( $queryInfo['conds'], $this->getNamespaceCond() );
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;
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;
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,
430 if ( !$this->isArchive ) {
431 $this->hookRunner->onContribsPager__getQueryInfo( $this, $queryInfo );
434 return $queryInfo;
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 );
445 return [ $dbr->expr( $this->pageNamespaceField, $eq_op, $namespaces ) ];
448 return [];
452 * @return false|string[]
454 public function getTagFilter() {
455 return $this->tagFilter;
459 * @return bool
461 public function getTagInvert() {
462 return $this->tagInvert;
466 * @return string
468 public function getTarget() {
469 return $this->target;
473 * @return bool
475 public function isNewOnly() {
476 return $this->newOnly;
480 * @return int|string
482 public function getNamespace() {
483 return $this->namespace;
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;
499 if ( isset( $row->{$this->revisionParentIdField} ) && $row->{$this->revisionParentIdField} ) {
500 $parentRevIds[] = (int)$row->{$this->revisionParentIdField};
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} );
507 $linkBatch->add( $row->{$this->pageNamespaceField}, $row->{$this->pageTitleField} );
508 $revisions[$row->{$this->revisionIdField}] = $this->createRevisionRecord( $row );
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};
527 $this->mParentLens += $this->revisionStore->getRevisionSizes(
528 array_diff( $parentRevIds, array_keys( $this->mParentLens ) )
530 $linkBatch->execute();
532 $revisionBatch = $this->commentFormatter->createRevisionBatch()
533 ->authority( $this->getAuthority() )
534 ->revisions( $revisions );
536 if ( !$this->isArchive ) {
537 // Only show public comments, because this page might be public
538 $revisionBatch = $revisionBatch->hideIfDeleted();
541 $this->formattedComments = $revisionBatch->execute();
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;
551 * @inheritDoc
553 protected function getStartBody() {
554 return "<section class='mw-pager-body'>\n";
558 * @inheritDoc
560 protected function getEndBody() {
561 return "</section>\n";
565 * @inheritDoc
567 protected function getEmptyBody() {
568 return $this->msg( 'nocontribs' )->parse();
572 * If the object looks like a revision row, or corresponds to a previously
573 * cached revision, return the RevisionRecord. Otherwise, return null.
575 * @since 1.35
577 * @param mixed $row
578 * @param Title|null $title
579 * @return RevisionRecord|null
581 public function tryCreatingRevisionRecord( $row, $title = null ) {
582 if ( $row instanceof stdClass && isset( $row->{$this->revisionIdField} )
583 && isset( $this->revisions[$row->{$this->revisionIdField}] )
585 return $this->revisions[$row->{$this->revisionIdField}];
588 if (
589 $this->isArchive &&
590 $this->revisionStore->isRevisionRow( $row, 'archive' )
592 return $this->revisionStore->newRevisionFromArchiveRow( $row, 0, $title );
595 if (
596 !$this->isArchive &&
597 $this->revisionStore->isRevisionRow( $row )
599 return $this->revisionStore->newRevisionFromRow( $row, 0, $title );
602 return null;
606 * Create a revision record from a $row that models a revision.
608 * @param mixed $row
609 * @param Title|null $title
610 * @return RevisionRecord
612 public function createRevisionRecord( $row, $title = null ) {
613 if ( $this->isArchive ) {
614 return $this->revisionStore->newRevisionFromArchiveRow( $row, 0, $title );
617 return $this->revisionStore->newRevisionFromRow( $row, 0, $title );
621 * Populate the HTML attributes.
623 * @param mixed $row
624 * @param string[] &$attributes
626 protected function populateAttributes( $row, &$attributes ) {
627 $attributes['data-mw-revid'] = $this->currentRevRecord->getId();
631 * Format a link to an article.
633 * @param mixed $row
634 * @return string
636 protected function formatArticleLink( $row ) {
637 if ( !$this->currentPage ) {
638 return '';
640 $dir = $this->getLanguage()->getDir();
641 return Html::rawElement( 'bdi', [ 'dir' => $dir ], $this->getLinkRenderer()->makeLink(
642 $this->currentPage,
643 $this->currentPage->getPrefixedText(),
644 [ 'class' => 'mw-contributions-title' ],
645 $this->currentPage->isRedirect() ? [ 'redirect' => 'no' ] : []
646 ) );
650 * Format diff and history links.
652 * @param mixed $row
653 * @return string
655 protected function formatDiffHistLinks( $row ) {
656 if ( !$this->currentPage || !$this->currentRevRecord ) {
657 return '';
659 if ( $this->isArchive ) {
660 // Add the same links as DeletedContribsPager::formatRevisionRow
661 $undelete = SpecialPage::getTitleFor( 'Undelete' );
662 if ( $this->getAuthority()->isAllowed( 'deletedtext' ) ) {
663 $last = $this->getLinkRenderer()->makeKnownLink(
664 $undelete,
665 new HtmlArmor( $this->messages['diff'] ),
668 'target' => $this->currentPage->getPrefixedText(),
669 'timestamp' => $this->currentRevRecord->getTimestamp(),
670 'diff' => 'prev'
673 } else {
674 $last = $this->messages['diff'];
677 $logs = SpecialPage::getTitleFor( 'Log' );
678 $dellog = $this->getLinkRenderer()->makeKnownLink(
679 $logs,
680 new HtmlArmor( $this->messages['deletionlog'] ),
683 'type' => 'delete',
684 'page' => $this->currentPage->getPrefixedText()
688 $reviewlink = $this->getLinkRenderer()->makeKnownLink(
689 SpecialPage::getTitleFor( 'Undelete', $this->currentPage->getPrefixedDBkey() ),
690 new HtmlArmor( $this->messages['undeleteviewlink'] )
693 return Html::rawElement(
694 'span',
695 [ 'class' => 'mw-deletedcontribs-tools' ],
696 $this->msg( 'parentheses' )->rawParams( $this->getLanguage()->pipeList(
697 [ $last, $dellog, $reviewlink ] ) )->escaped()
699 } else {
700 # Is there a visible previous revision?
701 if ( $this->currentRevRecord->getParentId() !== 0 &&
702 $this->currentRevRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
704 $difftext = $this->getLinkRenderer()->makeKnownLink(
705 $this->currentPage,
706 new HtmlArmor( $this->messages['diff'] ),
707 [ 'class' => 'mw-changeslist-diff' ],
709 'diff' => 'prev',
710 'oldid' => $row->{$this->revisionIdField},
713 } else {
714 $difftext = $this->messages['diff'];
716 $histlink = $this->getLinkRenderer()->makeKnownLink(
717 $this->currentPage,
718 new HtmlArmor( $this->messages['hist'] ),
719 [ 'class' => 'mw-changeslist-history' ],
720 [ 'action' => 'history' ]
723 // While it might be tempting to use a list here
724 // this would result in clutter and slows down navigating the content
725 // in assistive technology.
726 // See https://phabricator.wikimedia.org/T205581#4734812
727 return Html::rawElement( 'span',
728 [ 'class' => 'mw-changeslist-links' ],
729 // The spans are needed to ensure the dividing '|' elements are not
730 // themselves styled as links.
731 Html::rawElement( 'span', [], $difftext ) .
732 ' ' . // Space needed for separating two words.
733 Html::rawElement( 'span', [], $histlink )
739 * Format a date link.
741 * @param mixed $row
742 * @return string
744 protected function formatDateLink( $row ) {
745 if ( !$this->currentPage || !$this->currentRevRecord ) {
746 return '';
748 if ( $this->isArchive ) {
749 $date = $this->getLanguage()->userTimeAndDate(
750 $this->currentRevRecord->getTimestamp(),
751 $this->getUser()
754 if ( $this->getAuthority()->isAllowed( 'undelete' ) &&
755 $this->currentRevRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
757 $dateLink = $this->getLinkRenderer()->makeKnownLink(
758 SpecialPage::getTitleFor( 'Undelete' ),
759 $date,
760 [ 'class' => 'mw-changeslist-date' ],
762 'target' => $this->currentPage->getPrefixedText(),
763 'timestamp' => $this->currentRevRecord->getTimestamp()
766 } else {
767 $dateLink = htmlspecialchars( $date );
769 if ( $this->currentRevRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
770 $class = Linker::getRevisionDeletedClass( $this->currentRevRecord );
771 $dateLink = Html::rawElement(
772 'span',
773 [ 'class' => $class ],
774 $dateLink
777 } else {
778 $dateLink = ChangesList::revDateLink(
779 $this->currentRevRecord,
780 $this->getAuthority(),
781 $this->getLanguage(),
782 $this->currentPage
785 return $dateLink;
789 * Format annotation and add extra class if a row represents a latest revision.
791 * @param mixed $row
792 * @param string[] &$classes
793 * @return string
795 protected function formatTopMarkText( $row, &$classes ) {
796 if ( !$this->currentPage || !$this->currentRevRecord ) {
797 return '';
799 $topmarktext = '';
800 if ( !$this->isArchive ) {
801 $pagerTools = new PagerTools(
802 $this->currentRevRecord,
803 null,
804 $row->{$this->revisionIdField} === $row->page_latest && !$row->page_is_new,
805 $this->hookRunner,
806 $this->currentPage,
807 $this->getContext(),
808 $this->getLinkRenderer()
810 if ( $row->{$this->revisionIdField} === $row->page_latest ) {
811 $topmarktext .= '<span class="mw-uctop">' . $this->messages['uctop'] . '</span>';
812 $classes[] = 'mw-contributions-current';
814 if ( $pagerTools->shouldPreventClickjacking() ) {
815 $this->setPreventClickjacking( true );
817 $topmarktext .= $pagerTools->toHTML();
819 return $topmarktext;
823 * Format annotation to show the size of a diff.
825 * @param mixed $row
826 * @return string
828 protected function formatCharDiff( $row ) {
829 if ( $row->{$this->revisionParentIdField} === null ) {
830 // For some reason rev_parent_id isn't populated for this row.
831 // Its rumoured this is true on wikipedia for some revisions (T36922).
832 // Next best thing is to have the total number of bytes.
833 $chardiff = ' <span class="mw-changeslist-separator"></span> ';
834 $chardiff .= Linker::formatRevisionSize( $row->{$this->revisionLengthField} );
835 $chardiff .= ' <span class="mw-changeslist-separator"></span> ';
836 } else {
837 $parentLen = 0;
838 if ( isset( $this->mParentLens[$row->{$this->revisionParentIdField}] ) ) {
839 $parentLen = $this->mParentLens[$row->{$this->revisionParentIdField}];
842 $chardiff = ' <span class="mw-changeslist-separator"></span> ';
843 $chardiff .= ChangesList::showCharacterDifference(
844 $parentLen,
845 $row->{$this->revisionLengthField},
846 $this->getContext()
848 $chardiff .= ' <span class="mw-changeslist-separator"></span> ';
850 return $chardiff;
854 * Format a comment for a revision.
856 * @param mixed $row
857 * @return string
859 protected function formatComment( $row ) {
860 $comment = $this->formattedComments[$row->{$this->revisionIdField}];
862 if ( $comment === '' ) {
863 $defaultComment = $this->messages['changeslist-nocomment'];
864 $comment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
867 // Don't wrap result of this with <bdi> or any other element, see T377555
868 return $comment;
872 * Format a user link.
874 * @param mixed $row
875 * @return string
877 protected function formatUserLink( $row ) {
878 if ( !$this->currentRevRecord ) {
879 return '';
881 $dir = $this->getLanguage()->getDir();
883 // When the author is different from the target, always show user and user talk links
884 $userlink = '';
885 $revUser = $this->currentRevRecord->getUser();
886 $revUserId = $revUser ? $revUser->getId() : 0;
887 $revUserText = $revUser ? $revUser->getName() : '';
888 if ( $this->target !== $revUserText ) {
889 $userlink = ' <span class="mw-changeslist-separator"></span> '
890 . Html::rawElement( 'bdi', [ 'dir' => $dir ],
891 Linker::userLink( $revUserId, $revUserText ) );
892 $userlink .= ' ' . $this->msg( 'parentheses' )->rawParams(
893 Linker::userTalkLink( $revUserId, $revUserText ) )->escaped() . ' ';
895 return $userlink;
899 * @param mixed $row
900 * @return string[]
902 protected function formatFlags( $row ) {
903 if ( !$this->currentRevRecord ) {
904 return [];
906 $flags = [];
907 if ( $this->currentRevRecord->getParentId() === 0 ) {
908 $flags[] = ChangesList::flag( 'newpage' );
911 if ( $this->currentRevRecord->isMinor() ) {
912 $flags[] = ChangesList::flag( 'minor' );
914 return $flags;
918 * Format link for changing visibility.
920 * @param mixed $row
921 * @return string
923 protected function formatVisibilityLink( $row ) {
924 if ( !$this->currentPage || !$this->currentRevRecord ) {
925 return '';
927 $del = Linker::getRevDeleteLink(
928 $this->getAuthority(),
929 $this->currentRevRecord,
930 $this->currentPage
932 if ( $del !== '' ) {
933 $del .= ' ';
935 return $del;
939 * @param mixed $row
940 * @param string[] &$classes
941 * @return string
943 protected function formatTags( $row, &$classes ) {
944 # Tags, if any. Save some time using a cache.
945 [ $tagSummary, $newClasses ] = $this->tagsCache->getWithSetCallback(
946 $this->tagsCache->makeKey(
947 $row->ts_tags ?? '',
948 $this->getUser()->getName(),
949 $this->getLanguage()->getCode()
951 fn () => ChangeTags::formatSummaryRow(
952 $row->ts_tags,
953 null,
954 $this->getContext()
957 $classes = array_merge( $classes, $newClasses );
958 return $tagSummary;
962 * Check whether the revision author is deleted
964 * @param mixed $row
965 * @return bool
967 public function revisionUserIsDeleted( $row ) {
968 return $this->currentRevRecord->isDeleted( RevisionRecord::DELETED_USER );
972 * Generates each row in the contributions list.
974 * Contributions which are marked "top" are currently on top of the history.
975 * For these contributions, a [rollback] link is shown for users with roll-
976 * back privileges. The rollback link restores the most recent version that
977 * was not written by the target user.
979 * @todo This would probably look a lot nicer in a table.
980 * @param stdClass|mixed $row
981 * @return string
983 public function formatRow( $row ) {
984 $ret = '';
985 $classes = [];
986 $attribs = [];
988 $this->currentPage = null;
989 $this->currentRevRecord = null;
991 // Create a title for the revision if possible
992 // Rows from the hook may not include title information
993 if ( isset( $row->{$this->pageNamespaceField} ) && isset( $row->{$this->pageTitleField} ) ) {
994 $this->currentPage = Title::makeTitle( $row->{$this->pageNamespaceField}, $row->{$this->pageTitleField} );
997 // Flow overrides the ContribsPager::reallyDoQuery hook, causing this
998 // function to be called with a special object for $row. It expects us
999 // skip formatting so that the row can be formatted by the
1000 // ContributionsLineEnding hook below.
1001 // FIXME: have some better way for extensions to provide formatted rows.
1002 $this->currentRevRecord = $this->tryCreatingRevisionRecord( $row, $this->currentPage );
1003 if ( $this->revisionsOnly || ( $this->currentRevRecord && $this->currentPage ) ) {
1004 $this->populateAttributes( $row, $attribs );
1006 $templateParams = $this->getTemplateParams( $row, $classes );
1007 $ret = $this->getProcessedTemplate( $templateParams );
1010 // Let extensions add data
1011 $lineEndingsHook = $this->isArchive ?
1012 'onDeletedContributionsLineEnding' :
1013 'onContributionsLineEnding';
1014 $this->hookRunner->$lineEndingsHook( $this, $ret, $row, $classes, $attribs );
1015 $attribs = array_filter( $attribs,
1016 [ Sanitizer::class, 'isReservedDataAttribute' ],
1017 ARRAY_FILTER_USE_KEY
1020 // TODO: Handle exceptions in the catch block above. Do any extensions rely on
1021 // receiving empty rows?
1023 if ( $classes === [] && $attribs === [] && $ret === '' ) {
1024 wfDebug( "Dropping ContributionsSpecialPage row that could not be formatted" );
1025 return "<!-- Could not format ContributionsSpecialPage row. -->\n";
1027 $attribs['class'] = $classes;
1029 // FIXME: The signature of the ContributionsLineEnding hook makes it
1030 // very awkward to move this LI wrapper into the template.
1031 return Html::rawElement( 'li', $attribs, $ret ) . "\n";
1035 * Generate array of template parameters to pass to the template for rendering.
1036 * Function can be overriden by classes to add/remove their own parameters.
1038 * @since 1.43
1040 * @param stdClass|mixed $row
1041 * @param string[] &$classes
1042 * @return mixed[]
1044 public function getTemplateParams( $row, &$classes ) {
1045 $link = $this->formatArticleLink( $row );
1046 $topmarktext = $this->formatTopMarkText( $row, $classes );
1047 $diffHistLinks = $this->formatDiffHistLinks( $row );
1048 $dateLink = $this->formatDateLink( $row );
1049 $chardiff = $this->formatCharDiff( $row );
1050 $comment = $this->formatComment( $row );
1051 $userlink = $this->formatUserLink( $row );
1052 $flags = $this->formatFlags( $row );
1053 $del = $this->formatVisibilityLink( $row );
1054 $tagSummary = $this->formatTags( $row, $classes );
1056 if ( !$this->isArchive ) {
1057 $this->hookRunner->onSpecialContributions__formatRow__flags(
1058 $this->getContext(), $row, $flags );
1061 $templateParams = [
1062 'del' => $del,
1063 'timestamp' => $dateLink,
1064 'diffHistLinks' => $diffHistLinks,
1065 'charDifference' => $chardiff,
1066 'flags' => $flags,
1067 'articleLink' => $link,
1068 'userlink' => $userlink,
1069 'logText' => $comment,
1070 'topmarktext' => $topmarktext,
1071 'tagSummary' => $tagSummary,
1074 # Denote if username is redacted for this edit
1075 if ( $this->revisionUserIsDeleted( $row ) ) {
1076 $templateParams['rev-deleted-user-contribs'] =
1077 $this->msg( 'rev-deleted-user-contribs' )->escaped();
1080 return $templateParams;
1084 * Return the processed template. Function can be overriden by classes
1085 * to provide their own template parser.
1087 * @since 1.43
1089 * @param string[] $templateParams
1090 * @return string
1092 public function getProcessedTemplate( $templateParams ) {
1093 return $this->templateParser->processTemplate(
1094 'SpecialContributionsLine',
1095 $templateParams
1100 * Overwrite Pager function and return a helpful comment
1101 * @return string
1103 protected function getSqlComment() {
1104 if ( $this->namespace || $this->deletedOnly ) {
1105 // potentially slow, see CR r58153
1106 return 'contributions page filtered for namespace or RevisionDeleted edits';
1107 } else {
1108 return 'contributions page unfiltered';
1113 * @deprecated since 1.38, use ::setPreventClickjacking() instead
1115 protected function preventClickjacking() {
1116 $this->setPreventClickjacking( true );
1120 * @param bool $enable
1121 * @since 1.38
1123 protected function setPreventClickjacking( bool $enable ) {
1124 $this->preventClickjacking = $enable;
1128 * @return bool
1130 public function getPreventClickjacking() {
1131 return $this->preventClickjacking;