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
22 namespace MediaWiki\Pager
;
27 use InvalidArgumentException
;
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
;
50 use Wikimedia\Rdbms\FakeResultWrapper
;
51 use Wikimedia\Rdbms\IResultWrapper
;
54 * Pager for Special:Contributions
57 abstract class ContributionsPager
extends RangeChronologicalPager
{
60 public $mGroupByDate = true;
63 * @var string[] Local cache for escaped messages
68 * @var bool Get revisions from the archive table (if true) or the revision table (if false)
73 * @var string User name, or a string describing an IP address range
78 * @var string|int A single namespace number, or an empty string for all namespaces
83 * @var string[]|false Name of tag to filter, or false to ignore tags
88 * @var bool Set to true to invert the tag selection
93 * @var bool Set to true to invert the namespace selection
98 * @var bool Set to true to show both the subject and talk namespace, no matter which got
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
114 * @var bool Set to true to show only new pages
119 * @var bool Set to true to hide edits marked as minor by the user
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;
130 private $preventClickjacking = false;
132 protected ?Title
$currentPage;
133 protected ?RevisionRecord
$currentRevRecord;
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;
155 private $formattedComments = [];
157 /** @var RevisionRecord[] Cached revisions by ID */
158 private $revisions = [];
160 /** @var MapCacheLRU */
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,
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.
207 $this->target
= $options['target'] ??
$targetUser->getName();
208 $this->targetUser
= $targetUser;
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
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 );
247 'changeslist-nocomment',
253 foreach ( $msgs as $msg ) {
254 $this->messages
[$msg] = $this->msg( $msg )->escaped();
257 // Date filtering: use timestamp if available
258 $startTimestamp = '';
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
;
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(
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()
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 ] )
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 );
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;
358 if ( $order === self
::QUERY_ASCENDING
) {
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.
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
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'],
424 $queryInfo['join_conds'],
425 $queryInfo['options'],
430 if ( !$this->isArchive
) {
431 $this->hookRunner
->onContribsPager__getQueryInfo( $this, $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 ) ];
452 * @return false|string[]
454 public function getTagFilter() {
455 return $this->tagFilter
;
461 public function getTagInvert() {
462 return $this->tagInvert
;
468 public function getTarget() {
469 return $this->target
;
475 public function isNewOnly() {
476 return $this->newOnly
;
482 public function getNamespace() {
483 return $this->namespace;
486 protected function doBatchLookups() {
487 # Do a link batch query
488 $this->mResult
->seek( 0 );
490 $this->mParentLens
= [];
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 ) {
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() )
518 ->fields( [ $this->revisionIdField
, $this->revisionLengthField
] )
519 ->where( [ $this->revisionIdField
=> $parentRevIds ] )
520 ->caller( __METHOD__
)
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;
553 protected function getStartBody() {
554 return "<section class='mw-pager-body'>\n";
560 protected function getEndBody() {
561 return "</section>\n";
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.
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
}];
590 $this->revisionStore
->isRevisionRow( $row, 'archive' )
592 return $this->revisionStore
->newRevisionFromArchiveRow( $row, 0, $title );
597 $this->revisionStore
->isRevisionRow( $row )
599 return $this->revisionStore
->newRevisionFromRow( $row, 0, $title );
606 * Create a revision record from a $row that models a revision.
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.
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.
636 protected function formatArticleLink( $row ) {
637 if ( !$this->currentPage
) {
640 $dir = $this->getLanguage()->getDir();
641 return Html
::rawElement( 'bdi', [ 'dir' => $dir ], $this->getLinkRenderer()->makeLink(
643 $this->currentPage
->getPrefixedText(),
644 [ 'class' => 'mw-contributions-title' ],
645 $this->currentPage
->isRedirect() ?
[ 'redirect' => 'no' ] : []
650 * Format diff and history links.
655 protected function formatDiffHistLinks( $row ) {
656 if ( !$this->currentPage ||
!$this->currentRevRecord
) {
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(
665 new HtmlArmor( $this->messages
['diff'] ),
668 'target' => $this->currentPage
->getPrefixedText(),
669 'timestamp' => $this->currentRevRecord
->getTimestamp(),
674 $last = $this->messages
['diff'];
677 $logs = SpecialPage
::getTitleFor( 'Log' );
678 $dellog = $this->getLinkRenderer()->makeKnownLink(
680 new HtmlArmor( $this->messages
['deletionlog'] ),
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(
695 [ 'class' => 'mw-deletedcontribs-tools' ],
696 $this->msg( 'parentheses' )->rawParams( $this->getLanguage()->pipeList(
697 [ $last, $dellog, $reviewlink ] ) )->escaped()
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(
706 new HtmlArmor( $this->messages
['diff'] ),
707 [ 'class' => 'mw-changeslist-diff' ],
710 'oldid' => $row->{$this->revisionIdField
},
714 $difftext = $this->messages
['diff'];
716 $histlink = $this->getLinkRenderer()->makeKnownLink(
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.
744 protected function formatDateLink( $row ) {
745 if ( !$this->currentPage ||
!$this->currentRevRecord
) {
748 if ( $this->isArchive
) {
749 $date = $this->getLanguage()->userTimeAndDate(
750 $this->currentRevRecord
->getTimestamp(),
754 if ( $this->getAuthority()->isAllowed( 'undelete' ) &&
755 $this->currentRevRecord
->userCan( RevisionRecord
::DELETED_TEXT
, $this->getAuthority() )
757 $dateLink = $this->getLinkRenderer()->makeKnownLink(
758 SpecialPage
::getTitleFor( 'Undelete' ),
760 [ 'class' => 'mw-changeslist-date' ],
762 'target' => $this->currentPage
->getPrefixedText(),
763 'timestamp' => $this->currentRevRecord
->getTimestamp()
767 $dateLink = htmlspecialchars( $date );
769 if ( $this->currentRevRecord
->isDeleted( RevisionRecord
::DELETED_TEXT
) ) {
770 $class = Linker
::getRevisionDeletedClass( $this->currentRevRecord
);
771 $dateLink = Html
::rawElement(
773 [ 'class' => $class ],
778 $dateLink = ChangesList
::revDateLink(
779 $this->currentRevRecord
,
780 $this->getAuthority(),
781 $this->getLanguage(),
789 * Format annotation and add extra class if a row represents a latest revision.
792 * @param string[] &$classes
795 protected function formatTopMarkText( $row, &$classes ) {
796 if ( !$this->currentPage ||
!$this->currentRevRecord
) {
800 if ( !$this->isArchive
) {
801 $pagerTools = new PagerTools(
802 $this->currentRevRecord
,
804 $row->{$this->revisionIdField
} === $row->page_latest
&& !$row->page_is_new
,
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();
823 * Format annotation to show the size of a diff.
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> ';
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(
845 $row->{$this->revisionLengthField
},
848 $chardiff .= ' <span class="mw-changeslist-separator"></span> ';
854 * Format a comment for a revision.
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
872 * Format a user link.
877 protected function formatUserLink( $row ) {
878 if ( !$this->currentRevRecord
) {
881 $dir = $this->getLanguage()->getDir();
883 // When the author is different from the target, always show user and user talk links
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() . ' ';
902 protected function formatFlags( $row ) {
903 if ( !$this->currentRevRecord
) {
907 if ( $this->currentRevRecord
->getParentId() === 0 ) {
908 $flags[] = ChangesList
::flag( 'newpage' );
911 if ( $this->currentRevRecord
->isMinor() ) {
912 $flags[] = ChangesList
::flag( 'minor' );
918 * Format link for changing visibility.
923 protected function formatVisibilityLink( $row ) {
924 if ( !$this->currentPage ||
!$this->currentRevRecord
) {
927 $del = Linker
::getRevDeleteLink(
928 $this->getAuthority(),
929 $this->currentRevRecord
,
940 * @param string[] &$classes
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(
948 $this->getUser()->getName(),
949 $this->getLanguage()->getCode()
951 fn () => ChangeTags
::formatSummaryRow(
957 $classes = array_merge( $classes, $newClasses );
962 * Check whether the revision author is deleted
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
983 public function formatRow( $row ) {
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.
1040 * @param stdClass|mixed $row
1041 * @param string[] &$classes
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 );
1063 'timestamp' => $dateLink,
1064 'diffHistLinks' => $diffHistLinks,
1065 'charDifference' => $chardiff,
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.
1089 * @param string[] $templateParams
1092 public function getProcessedTemplate( $templateParams ) {
1093 return $this->templateParser
->processTemplate(
1094 'SpecialContributionsLine',
1100 * Overwrite Pager function and return a helpful comment
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';
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
1123 protected function setPreventClickjacking( bool $enable ) {
1124 $this->preventClickjacking
= $enable;
1130 public function getPreventClickjacking() {
1131 return $this->preventClickjacking
;