aboutsummaryrefslogtreecommitdiffstats
path: root/includes/pager
diff options
context:
space:
mode:
authorThalia <thalia.e.chan@googlemail.com>2024-05-13 15:31:40 +0100
committerThalia <thalia.e.chan@googlemail.com>2024-05-13 15:38:34 +0100
commit5362096f27df9d280e8e035891387b7161d4ec22 (patch)
tree9f71c1c0e1f303e956d07dca0ffccd30e4ed6449 /includes/pager
parent7020c8c9e3a78609c4ac42e4c08672542a66b5a6 (diff)
downloadmediawikicore-5362096f27df9d280e8e035891387b7161d4ec22.tar.gz
mediawikicore-5362096f27df9d280e8e035891387b7161d4ec22.zip
Revert "Revert "Add ContributionsPager, an abstract parent for ContribsPager""
This reverts commit e6fb3df2a639ca95afdec2255f1da63187ba71c6. This re-instates I08a5d39036047484e3b44fcd83989072006b88e2. Bug: T363358 Change-Id: I847c60a493d9973554ceb1232f3799c42321ee2b
Diffstat (limited to 'includes/pager')
-rw-r--r--includes/pager/ContributionsPager.php789
1 files changed, 789 insertions, 0 deletions
diff --git a/includes/pager/ContributionsPager.php b/includes/pager/ContributionsPager.php
new file mode 100644
index 000000000000..2b7192dae6ce
--- /dev/null
+++ b/includes/pager/ContributionsPager.php
@@ -0,0 +1,789 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Pager
+ */
+
+namespace MediaWiki\Pager;
+
+use ChangesList;
+use ChangeTags;
+use HtmlArmor;
+use InvalidArgumentException;
+use MapCacheLRU;
+use MediaWiki\Cache\LinkBatchFactory;
+use MediaWiki\CommentFormatter\CommentFormatter;
+use MediaWiki\Context\IContextSource;
+use MediaWiki\HookContainer\HookContainer;
+use MediaWiki\HookContainer\HookRunner;
+use MediaWiki\Html\Html;
+use MediaWiki\Html\TemplateParser;
+use MediaWiki\Linker\Linker;
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\MainConfigNames;
+use MediaWiki\Parser\Sanitizer;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Title\NamespaceInfo;
+use MediaWiki\Title\Title;
+use MediaWiki\User\UserFactory;
+use MediaWiki\User\UserIdentity;
+use MediaWiki\User\UserRigorOptions;
+use stdClass;
+use Wikimedia\Rdbms\FakeResultWrapper;
+use Wikimedia\Rdbms\IResultWrapper;
+
+/**
+ * Pager for Special:Contributions
+ * @ingroup Pager
+ */
+abstract class ContributionsPager extends RangeChronologicalPager {
+
+ public $mGroupByDate = true;
+
+ /**
+ * @var string[] Local cache for escaped messages
+ */
+ private $messages;
+
+ /**
+ * @var string User name, or a string describing an IP address range
+ */
+ protected $target;
+
+ /**
+ * @var string|int A single namespace number, or an empty string for all namespaces
+ */
+ private $namespace;
+
+ /**
+ * @var string[]|false Name of tag to filter, or false to ignore tags
+ */
+ private $tagFilter;
+
+ /**
+ * @var bool Set to true to invert the tag selection
+ */
+ private $tagInvert;
+
+ /**
+ * @var bool Set to true to invert the namespace selection
+ */
+ private $nsInvert;
+
+ /**
+ * @var bool Set to true to show both the subject and talk namespace, no matter which got
+ * selected
+ */
+ private $associated;
+
+ /**
+ * @var bool Set to true to show only deleted revisions
+ */
+ private $deletedOnly;
+
+ /**
+ * @var bool Set to true to show only latest (a.k.a. current) revisions
+ */
+ private $topOnly;
+
+ /**
+ * @var bool Set to true to show only new pages
+ */
+ private $newOnly;
+
+ /**
+ * @var bool Set to true to hide edits marked as minor by the user
+ */
+ private $hideMinor;
+
+ /**
+ * @var bool Set to true to only include mediawiki revisions.
+ * (restricts extensions from executing additional queries to include their own contributions)
+ */
+ private $revisionsOnly;
+
+ private $preventClickjacking = false;
+
+ /**
+ * @var array
+ */
+ private $mParentLens;
+
+ /** @var UserIdentity */
+ protected $targetUser;
+
+ private TemplateParser $templateParser;
+ private CommentFormatter $commentFormatter;
+ private HookRunner $hookRunner;
+ private LinkBatchFactory $linkBatchFactory;
+ private NamespaceInfo $namespaceInfo;
+ protected RevisionStore $revisionStore;
+
+ /** @var string[] */
+ private $formattedComments = [];
+
+ /** @var RevisionRecord[] Cached revisions by ID */
+ private $revisions = [];
+
+ /** @var MapCacheLRU */
+ private $tagsCache;
+
+ /**
+ * @param LinkRenderer $linkRenderer
+ * @param LinkBatchFactory $linkBatchFactory
+ * @param HookContainer $hookContainer
+ * @param RevisionStore $revisionStore
+ * @param NamespaceInfo $namespaceInfo
+ * @param CommentFormatter $commentFormatter
+ * @param UserFactory $userFactory
+ * @param IContextSource $context
+ * @param array $options
+ * @param UserIdentity|null $targetUser
+ */
+ public function __construct(
+ LinkRenderer $linkRenderer,
+ LinkBatchFactory $linkBatchFactory,
+ HookContainer $hookContainer,
+ RevisionStore $revisionStore,
+ NamespaceInfo $namespaceInfo,
+ CommentFormatter $commentFormatter,
+ UserFactory $userFactory,
+ IContextSource $context,
+ array $options,
+ ?UserIdentity $targetUser
+ ) {
+ // Set ->target before calling parent::__construct() so
+ // parent can call $this->getIndexField() and get the right result. Set
+ // the rest too just to keep things simple.
+ if ( $targetUser ) {
+ $this->target = $options['target'] ?? $targetUser->getName();
+ $this->targetUser = $targetUser;
+ } else {
+ // Use target option
+ // It's possible for the target to be empty. This is used by
+ // ContribsPagerTest and does not cause newFromName() to return
+ // false. It's probably not used by any production code.
+ $this->target = $options['target'] ?? '';
+ // @phan-suppress-next-line PhanPossiblyNullTypeMismatchProperty RIGOR_NONE never returns null
+ $this->targetUser = $userFactory->newFromName(
+ $this->target, UserRigorOptions::RIGOR_NONE
+ );
+ if ( !$this->targetUser ) {
+ // This can happen if the target contained "#". Callers
+ // typically pass user input through title normalization to
+ // avoid it.
+ throw new InvalidArgumentException( __METHOD__ . ': the user name is too ' .
+ 'broken to use even with validation disabled.' );
+ }
+ }
+
+ $this->namespace = $options['namespace'] ?? '';
+ $this->tagFilter = $options['tagfilter'] ?? false;
+ $this->tagInvert = $options['tagInvert'] ?? false;
+ $this->nsInvert = $options['nsInvert'] ?? false;
+ $this->associated = $options['associated'] ?? false;
+
+ $this->deletedOnly = !empty( $options['deletedOnly'] );
+ $this->topOnly = !empty( $options['topOnly'] );
+ $this->newOnly = !empty( $options['newOnly'] );
+ $this->hideMinor = !empty( $options['hideMinor'] );
+ $this->revisionsOnly = !empty( $options['revisionsOnly'] );
+
+ parent::__construct( $context, $linkRenderer );
+
+ $msgs = [
+ 'diff',
+ 'hist',
+ 'pipe-separator',
+ 'uctop',
+ 'changeslist-nocomment',
+ ];
+
+ foreach ( $msgs as $msg ) {
+ $this->messages[$msg] = $this->msg( $msg )->escaped();
+ }
+
+ // Date filtering: use timestamp if available
+ $startTimestamp = '';
+ $endTimestamp = '';
+ if ( isset( $options['start'] ) && $options['start'] ) {
+ $startTimestamp = $options['start'] . ' 00:00:00';
+ }
+ if ( isset( $options['end'] ) && $options['end'] ) {
+ $endTimestamp = $options['end'] . ' 23:59:59';
+ }
+ $this->getDateRangeCond( $startTimestamp, $endTimestamp );
+
+ $this->templateParser = new TemplateParser();
+ $this->linkBatchFactory = $linkBatchFactory;
+ $this->hookRunner = new HookRunner( $hookContainer );
+ $this->revisionStore = $revisionStore;
+ $this->namespaceInfo = $namespaceInfo;
+ $this->commentFormatter = $commentFormatter;
+ $this->tagsCache = new MapCacheLRU( 50 );
+ }
+
+ public function getDefaultQuery() {
+ $query = parent::getDefaultQuery();
+ $query['target'] = $this->target;
+
+ return $query;
+ }
+
+ /**
+ * This method basically executes the exact same code as the parent class, though with
+ * a hook added, to allow extensions to add additional queries.
+ *
+ * @param string $offset Index offset, inclusive
+ * @param int $limit Exact query limit
+ * @param bool $order IndexPager::QUERY_ASCENDING or IndexPager::QUERY_DESCENDING
+ * @return IResultWrapper
+ */
+ public function reallyDoQuery( $offset, $limit, $order ) {
+ [ $tables, $fields, $conds, $fname, $options, $join_conds ] = $this->buildQueryInfo(
+ $offset,
+ $limit,
+ $order
+ );
+
+ $options['MAX_EXECUTION_TIME'] =
+ $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries );
+ /*
+ * This hook will allow extensions to add in additional queries, so they can get their data
+ * in My Contributions as well. Extensions should append their results to the $data array.
+ *
+ * Extension queries have to implement the navbar requirement as well. They should
+ * - have a column aliased as $pager->getIndexField()
+ * - have LIMIT set
+ * - have a WHERE-clause that compares the $pager->getIndexField()-equivalent column to the offset
+ * - have the ORDER BY specified based upon the details provided by the navbar
+ *
+ * See includes/Pager.php buildQueryInfo() method on how to build LIMIT, WHERE & ORDER BY
+ *
+ * &$data: an array of results of all contribs queries
+ * $pager: the ContribsPager object hooked into
+ * $offset: see phpdoc above
+ * $limit: see phpdoc above
+ * $descending: see phpdoc above
+ */
+ $dbr = $this->getDatabase();
+ $data = [ $dbr->newSelectQueryBuilder()
+ ->tables( is_array( $tables ) ? $tables : [ $tables ] )
+ ->fields( $fields )
+ ->conds( $conds )
+ ->caller( $fname )
+ ->options( $options )
+ ->joinConds( $join_conds )
+ ->setMaxExecutionTime( $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries ) )
+ ->fetchResultSet() ];
+ if ( !$this->revisionsOnly ) {
+ // TODO: Range offsets are fairly important and all handlers should take care of it.
+ // If this hook will be replaced (e.g. unified with the DeletedContribsPager one),
+ // please consider passing [ $this->endOffset, $this->startOffset ] to it (T167577).
+ $this->hookRunner->onContribsPager__reallyDoQuery(
+ $data, $this, $offset, $limit, $order );
+ }
+
+ $result = [];
+
+ // loop all results and collect them in an array
+ foreach ( $data as $query ) {
+ foreach ( $query as $i => $row ) {
+ // If the query results are in descending order, the indexes must also be in descending order
+ $index = $order === self::QUERY_ASCENDING ? $i : $limit - 1 - $i;
+ // Left-pad with zeroes, because these values will be sorted as strings
+ $index = str_pad( (string)$index, strlen( (string)$limit ), '0', STR_PAD_LEFT );
+ // use index column as key, allowing us to easily sort in PHP
+ $result[$row->{$this->getIndexField()} . "-$index"] = $row;
+ }
+ }
+
+ // sort results
+ if ( $order === self::QUERY_ASCENDING ) {
+ ksort( $result );
+ } else {
+ krsort( $result );
+ }
+
+ // enforce limit
+ $result = array_slice( $result, 0, $limit );
+
+ // get rid of array keys
+ $result = array_values( $result );
+
+ return new FakeResultWrapper( $result );
+ }
+
+ /**
+ * Get queryInfo for the main query selecting revisions, not including
+ * filtering on namespace, date, etc.
+ *
+ * @return array
+ */
+ abstract protected function getRevisionQuery();
+
+ public function getQueryInfo() {
+ $queryInfo = $this->getRevisionQuery();
+
+ if ( $this->deletedOnly ) {
+ $queryInfo['conds'][] = 'rev_deleted != 0';
+ }
+
+ if ( $this->topOnly ) {
+ $queryInfo['conds'][] = 'rev_id = page_latest';
+ }
+
+ if ( $this->newOnly ) {
+ $queryInfo['conds'][] = 'rev_parent_id = 0';
+ }
+
+ if ( $this->hideMinor ) {
+ $queryInfo['conds'][] = 'rev_minor_edit = 0';
+ }
+
+ $queryInfo['conds'] = array_merge( $queryInfo['conds'], $this->getNamespaceCond() );
+
+ // Paranoia: avoid brute force searches (T19342)
+ $dbr = $this->getDatabase();
+ if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
+ $queryInfo['conds'][] = $dbr->bitAnd(
+ 'rev_deleted', RevisionRecord::DELETED_USER
+ ) . ' = 0';
+ } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
+ $queryInfo['conds'][] = $dbr->bitAnd(
+ 'rev_deleted', RevisionRecord::SUPPRESSED_USER
+ ) . ' != ' . RevisionRecord::SUPPRESSED_USER;
+ }
+
+ // $this->getIndexField() must be in the result rows, as reallyDoQuery() tries to access it.
+ $indexField = $this->getIndexField();
+ if ( $indexField !== 'rev_timestamp' ) {
+ $queryInfo['fields'][] = $indexField;
+ }
+
+ ChangeTags::modifyDisplayQuery(
+ $queryInfo['tables'],
+ $queryInfo['fields'],
+ $queryInfo['conds'],
+ $queryInfo['join_conds'],
+ $queryInfo['options'],
+ $this->tagFilter,
+ $this->tagInvert,
+ );
+
+ $this->hookRunner->onContribsPager__getQueryInfo( $this, $queryInfo );
+
+ return $queryInfo;
+ }
+
+ protected function getNamespaceCond() {
+ if ( $this->namespace !== '' ) {
+ $dbr = $this->getDatabase();
+ $selectedNS = $dbr->addQuotes( $this->namespace );
+ $eq_op = $this->nsInvert ? '!=' : '=';
+ $bool_op = $this->nsInvert ? 'AND' : 'OR';
+
+ if ( !$this->associated ) {
+ return [ "page_namespace $eq_op $selectedNS" ];
+ }
+
+ $associatedNS = $dbr->addQuotes( $this->namespaceInfo->getAssociated( $this->namespace ) );
+
+ return [
+ "page_namespace $eq_op $selectedNS " .
+ $bool_op .
+ " page_namespace $eq_op $associatedNS"
+ ];
+ }
+
+ return [];
+ }
+
+ /**
+ * @return false|string[]
+ */
+ public function getTagFilter() {
+ return $this->tagFilter;
+ }
+
+ /**
+ * @return bool
+ */
+ public function getTagInvert() {
+ return $this->tagInvert;
+ }
+
+ /**
+ * @return string
+ */
+ public function getTarget() {
+ return $this->target;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isNewOnly() {
+ return $this->newOnly;
+ }
+
+ /**
+ * @return int|string
+ */
+ public function getNamespace() {
+ return $this->namespace;
+ }
+
+ protected function doBatchLookups() {
+ # Do a link batch query
+ $this->mResult->seek( 0 );
+ $parentRevIds = [];
+ $this->mParentLens = [];
+ $revisions = [];
+ $linkBatch = $this->linkBatchFactory->newLinkBatch();
+ # Give some pointers to make (last) links
+ foreach ( $this->mResult as $row ) {
+ if ( isset( $row->rev_parent_id ) && $row->rev_parent_id ) {
+ $parentRevIds[] = (int)$row->rev_parent_id;
+ }
+ if ( $this->revisionStore->isRevisionRow( $row ) ) {
+ $this->mParentLens[(int)$row->rev_id] = $row->rev_len;
+ if ( $this->target !== $row->rev_user_text ) {
+ // If the target does not match the author, batch the author's talk page
+ $linkBatch->add( NS_USER_TALK, $row->rev_user_text );
+ }
+ $linkBatch->add( $row->page_namespace, $row->page_title );
+ $revisions[$row->rev_id] = $this->revisionStore->newRevisionFromRow( $row );
+ }
+ }
+ # Fetch rev_len for revisions not already scanned above
+ $this->mParentLens += $this->revisionStore->getRevisionSizes(
+ array_diff( $parentRevIds, array_keys( $this->mParentLens ) )
+ );
+ $linkBatch->execute();
+
+ $this->formattedComments = $this->commentFormatter->createRevisionBatch()
+ ->authority( $this->getAuthority() )
+ ->revisions( $revisions )
+ ->hideIfDeleted()
+ ->execute();
+
+ # For performance, save the revision objects for later.
+ # The array is indexed by rev_id. doBatchLookups() may be called
+ # multiple times with different results, so merge the revisions array,
+ # ignoring any duplicates.
+ $this->revisions += $revisions;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getStartBody() {
+ return "<section class='mw-pager-body'>\n";
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function getEndBody() {
+ return "</section>\n";
+ }
+
+ /**
+ * If the object looks like a revision row, or corresponds to a previously
+ * cached revision, return the RevisionRecord. Otherwise, return null.
+ *
+ * @since 1.35
+ *
+ * @param mixed $row
+ * @param Title|null $title
+ * @return RevisionRecord|null
+ */
+ public function tryCreatingRevisionRecord( $row, $title = null ) {
+ if ( $row instanceof stdClass && isset( $row->rev_id )
+ && isset( $this->revisions[$row->rev_id] )
+ ) {
+ return $this->revisions[$row->rev_id];
+ } elseif ( $this->revisionStore->isRevisionRow( $row ) ) {
+ return $this->revisionStore->newRevisionFromRow( $row, 0, $title );
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Generates each row in the contributions list.
+ *
+ * Contributions which are marked "top" are currently on top of the history.
+ * For these contributions, a [rollback] link is shown for users with roll-
+ * back privileges. The rollback link restores the most recent version that
+ * was not written by the target user.
+ *
+ * @todo This would probably look a lot nicer in a table.
+ * @param stdClass|mixed $row
+ * @return string
+ */
+ public function formatRow( $row ) {
+ $ret = '';
+ $classes = [];
+ $attribs = [];
+
+ $linkRenderer = $this->getLinkRenderer();
+
+ $page = null;
+ // Create a title for the revision if possible
+ // Rows from the hook may not include title information
+ if ( isset( $row->page_namespace ) && isset( $row->page_title ) ) {
+ $page = Title::newFromRow( $row );
+ }
+ // Flow overrides the ContribsPager::reallyDoQuery hook, causing this
+ // function to be called with a special object for $row. It expects us
+ // skip formatting so that the row can be formatted by the
+ // ContributionsLineEnding hook below.
+ // FIXME: have some better way for extensions to provide formatted rows.
+ $revRecord = $this->tryCreatingRevisionRecord( $row, $page );
+ if ( $revRecord && $page ) {
+ $revRecord = $this->revisionStore->newRevisionFromRow( $row, 0, $page );
+ $attribs['data-mw-revid'] = $revRecord->getId();
+
+ $link = $linkRenderer->makeLink(
+ $page,
+ $page->getPrefixedText(),
+ [ 'class' => 'mw-contributions-title' ],
+ $page->isRedirect() ? [ 'redirect' => 'no' ] : []
+ );
+ # Mark current revisions
+ $topmarktext = '';
+
+ $pagerTools = new PagerTools(
+ $revRecord,
+ null,
+ $row->rev_id === $row->page_latest && !$row->page_is_new,
+ $this->hookRunner,
+ $page,
+ $this->getContext(),
+ $this->getLinkRenderer()
+ );
+ if ( $row->rev_id === $row->page_latest ) {
+ $topmarktext .= '<span class="mw-uctop">' . $this->messages['uctop'] . '</span>';
+ $classes[] = 'mw-contributions-current';
+ }
+ if ( $pagerTools->shouldPreventClickjacking() ) {
+ $this->setPreventClickjacking( true );
+ }
+ $topmarktext .= $pagerTools->toHTML();
+ # Is there a visible previous revision?
+ if ( $revRecord->getParentId() !== 0 &&
+ $revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
+ ) {
+ $difftext = $linkRenderer->makeKnownLink(
+ $page,
+ new HtmlArmor( $this->messages['diff'] ),
+ [ 'class' => 'mw-changeslist-diff' ],
+ [
+ 'diff' => 'prev',
+ 'oldid' => $row->rev_id
+ ]
+ );
+ } else {
+ $difftext = $this->messages['diff'];
+ }
+ $histlink = $linkRenderer->makeKnownLink(
+ $page,
+ new HtmlArmor( $this->messages['hist'] ),
+ [ 'class' => 'mw-changeslist-history' ],
+ [ 'action' => 'history' ]
+ );
+
+ if ( $row->rev_parent_id === null ) {
+ // For some reason rev_parent_id isn't populated for this row.
+ // Its rumoured this is true on wikipedia for some revisions (T36922).
+ // Next best thing is to have the total number of bytes.
+ $chardiff = ' <span class="mw-changeslist-separator"></span> ';
+ $chardiff .= Linker::formatRevisionSize( $row->rev_len );
+ $chardiff .= ' <span class="mw-changeslist-separator"></span> ';
+ } else {
+ $parentLen = 0;
+ if ( isset( $this->mParentLens[$row->rev_parent_id] ) ) {
+ $parentLen = $this->mParentLens[$row->rev_parent_id];
+ }
+
+ $chardiff = ' <span class="mw-changeslist-separator"></span> ';
+ $chardiff .= ChangesList::showCharacterDifference(
+ $parentLen,
+ $row->rev_len,
+ $this->getContext()
+ );
+ $chardiff .= ' <span class="mw-changeslist-separator"></span> ';
+ }
+
+ $lang = $this->getLanguage();
+
+ $comment = $this->formattedComments[$row->rev_id];
+
+ if ( $comment === '' ) {
+ $defaultComment = $this->messages['changeslist-nocomment'];
+ $comment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
+ }
+
+ $comment = $lang->getDirMark() . $comment;
+
+ $authority = $this->getAuthority();
+ $d = ChangesList::revDateLink( $revRecord, $authority, $lang, $page );
+
+ // When the author is different from the target, always show user and user talk links
+ $userlink = '';
+ $revUser = $revRecord->getUser();
+ $revUserId = $revUser ? $revUser->getId() : 0;
+ $revUserText = $revUser ? $revUser->getName() : '';
+ if ( $this->target !== $revUserText ) {
+ $userlink = ' <span class="mw-changeslist-separator"></span> '
+ . $lang->getDirMark()
+ . Linker::userLink( $revUserId, $revUserText );
+ $userlink .= ' ' . $this->msg( 'parentheses' )->rawParams(
+ Linker::userTalkLink( $revUserId, $revUserText ) )->escaped() . ' ';
+ }
+
+ $flags = [];
+ if ( $revRecord->getParentId() === 0 ) {
+ $flags[] = ChangesList::flag( 'newpage' );
+ }
+
+ if ( $revRecord->isMinor() ) {
+ $flags[] = ChangesList::flag( 'minor' );
+ }
+
+ $del = Linker::getRevDeleteLink( $authority, $revRecord, $page );
+ if ( $del !== '' ) {
+ $del .= ' ';
+ }
+
+ // While it might be tempting to use a list here
+ // this would result in clutter and slows down navigating the content
+ // in assistive technology.
+ // See https://phabricator.wikimedia.org/T205581#4734812
+ $diffHistLinks = Html::rawElement( 'span',
+ [ 'class' => 'mw-changeslist-links' ],
+ // The spans are needed to ensure the dividing '|' elements are not
+ // themselves styled as links.
+ Html::rawElement( 'span', [], $difftext ) .
+ ' ' . // Space needed for separating two words.
+ Html::rawElement( 'span', [], $histlink )
+ );
+
+ # Tags, if any. Save some time using a cache.
+ [ $tagSummary, $newClasses ] = $this->tagsCache->getWithSetCallback(
+ $this->tagsCache->makeKey(
+ $row->ts_tags ?? '',
+ $this->getUser()->getName(),
+ $lang->getCode()
+ ),
+ fn () => ChangeTags::formatSummaryRow(
+ $row->ts_tags,
+ null,
+ $this->getContext()
+ )
+ );
+ $classes = array_merge( $classes, $newClasses );
+
+ $this->hookRunner->onSpecialContributions__formatRow__flags(
+ $this->getContext(), $row, $flags );
+
+ $templateParams = [
+ 'del' => $del,
+ 'timestamp' => $d,
+ 'diffHistLinks' => $diffHistLinks,
+ 'charDifference' => $chardiff,
+ 'flags' => $flags,
+ 'articleLink' => $link,
+ 'userlink' => $userlink,
+ 'logText' => $comment,
+ 'topmarktext' => $topmarktext,
+ 'tagSummary' => $tagSummary,
+ ];
+
+ # Denote if username is redacted for this edit
+ if ( $revRecord->isDeleted( RevisionRecord::DELETED_USER ) ) {
+ $templateParams['rev-deleted-user-contribs'] =
+ $this->msg( 'rev-deleted-user-contribs' )->escaped();
+ }
+
+ $ret = $this->templateParser->processTemplate(
+ 'SpecialContributionsLine',
+ $templateParams
+ );
+ }
+
+ // Let extensions add data
+ $this->hookRunner->onContributionsLineEnding( $this, $ret, $row, $classes, $attribs );
+ $attribs = array_filter( $attribs,
+ [ Sanitizer::class, 'isReservedDataAttribute' ],
+ ARRAY_FILTER_USE_KEY
+ );
+
+ // TODO: Handle exceptions in the catch block above. Do any extensions rely on
+ // receiving empty rows?
+
+ if ( $classes === [] && $attribs === [] && $ret === '' ) {
+ wfDebug( "Dropping Special:Contribution row that could not be formatted" );
+ return "<!-- Could not format Special:Contribution row. -->\n";
+ }
+ $attribs['class'] = $classes;
+
+ // FIXME: The signature of the ContributionsLineEnding hook makes it
+ // very awkward to move this LI wrapper into the template.
+ return Html::rawElement( 'li', $attribs, $ret ) . "\n";
+ }
+
+ /**
+ * Overwrite Pager function and return a helpful comment
+ * @return string
+ */
+ protected function getSqlComment() {
+ if ( $this->namespace || $this->deletedOnly ) {
+ // potentially slow, see CR r58153
+ return 'contributions page filtered for namespace or RevisionDeleted edits';
+ } else {
+ return 'contributions page unfiltered';
+ }
+ }
+
+ /**
+ * @deprecated since 1.38, use ::setPreventClickjacking() instead
+ */
+ protected function preventClickjacking() {
+ $this->setPreventClickjacking( true );
+ }
+
+ /**
+ * @param bool $enable
+ * @since 1.38
+ */
+ protected function setPreventClickjacking( bool $enable ) {
+ $this->preventClickjacking = $enable;
+ }
+
+ /**
+ * @return bool
+ */
+ public function getPreventClickjacking() {
+ return $this->preventClickjacking;
+ }
+
+}