wikimedia/mediawiki-core

View on GitHub
includes/pager/ContributionsPager.php

Summary

Maintainability
F
5 days
Test Coverage
<?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\SpecialPage\SpecialPage;
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 {

    /** @inheritDoc */
    public $mGroupByDate = true;

    /**
     * @var string[] Local cache for escaped messages
     */
    private $messages;

    /**
     * @var bool Get revisions from the archive table (if true) or the revision table (if false)
     */
    protected $isArchive;

    /**
     * @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;

    /** @var bool */
    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;

    /**
     * Field names for various attributes. These may be overridden in a subclass,
     * for example for getting revisions from the archive table.
     */
    protected string $revisionIdField = 'rev_id';
    protected string $revisionParentIdField = 'rev_parent_id';
    protected string $revisionTimestampField = 'rev_timestamp';
    protected string $revisionLengthField = 'rev_len';
    protected string $revisionDeletedField = 'rev_deleted';
    protected string $revisionMinorField = 'rev_minor_edit';
    protected string $userNameField = 'rev_user_text';
    protected string $pageNamespaceField = 'page_namespace';
    protected string $pageTitleField = 'page_title';

    /**
     * @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
    ) {
        $this->isArchive = $options['isArchive'] ?? false;

        // 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',
            'undeleteviewlink',
            'undeleteviewlink',
            'deletionlog',
        ];

        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 ) {
            // These hooks were moved from ContribsPager and DeletedContribsPager. For backwards
            // compatability, they keep the same names. But they should be run for any contributions
            // pager, otherwise the entries from extensions would be missing.
            $reallyDoQueryHook = $this->isArchive ?
                'onDeletedContribsPager__reallyDoQuery' :
                'onContribsPager__reallyDoQuery';
            // 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->$reallyDoQueryHook( $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'][] = $this->revisionDeletedField . ' != 0';
        }

        if ( !$this->isArchive && $this->topOnly ) {
            $queryInfo['conds'][] = $this->revisionIdField . ' = page_latest';
        }

        if ( $this->newOnly ) {
            $queryInfo['conds'][] = $this->revisionParentIdField . ' = 0';
        }

        if ( $this->hideMinor ) {
            $queryInfo['conds'][] = $this->revisionMinorField . ' = 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(
                $this->revisionDeletedField, RevisionRecord::DELETED_USER
                ) . ' = 0';
        } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
            $queryInfo['conds'][] = $dbr->bitAnd(
                $this->revisionDeletedField, 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 !== $this->revisionTimestampField ) {
            $queryInfo['fields'][] = $indexField;
        }

        ChangeTags::modifyDisplayQuery(
            $queryInfo['tables'],
            $queryInfo['fields'],
            $queryInfo['conds'],
            $queryInfo['join_conds'],
            $queryInfo['options'],
            $this->tagFilter,
            $this->tagInvert,
        );

        if ( !$this->isArchive ) {
            $this->hookRunner->onContribsPager__getQueryInfo( $this, $queryInfo );
        }

        return $queryInfo;
    }

    protected function getNamespaceCond() {
        if ( $this->namespace !== '' ) {
            $dbr = $this->getDatabase();
            $namespaces = [ $this->namespace ];
            $eq_op = $this->nsInvert ? '!=' : '=';
            if ( $this->associated ) {
                $namespaces[] = $this->namespaceInfo->getAssociated( $this->namespace );
            }
            return [ $dbr->expr( $this->pageNamespaceField, $eq_op, $namespaces ) ];
        }

        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->{$this->revisionParentIdField} ) && $row->{$this->revisionParentIdField} ) {
                $parentRevIds[] = (int)$row->{$this->revisionParentIdField};
            }
            if ( $this->revisionStore->isRevisionRow( $row, $this->isArchive ? 'archive' : 'revision' ) ) {
                $this->mParentLens[(int)$row->{$this->revisionIdField}] = $row->{$this->revisionLengthField};
                if ( $this->target !== $row->{$this->userNameField} ) {
                    // If the target does not match the author, batch the author's talk page
                    $linkBatch->add( NS_USER_TALK, $row->{$this->userNameField} );
                }
                $linkBatch->add( $row->{$this->pageNamespaceField}, $row->{$this->pageTitleField} );
                $revisions[$row->{$this->revisionIdField}] = $this->createRevisionRecord( $row );
            }
        }
        // Fetch rev_len/ar_len for revisions not already scanned above
        // TODO: is it possible to make this fully abstract?
        if ( $this->isArchive ) {
            $parentRevIds = array_diff( $parentRevIds, array_keys( $this->mParentLens ) );
            if ( $parentRevIds ) {
                $result = $this->revisionStore
                    ->newArchiveSelectQueryBuilder( $this->getDatabase() )
                    ->clearFields()
                    ->fields( [ $this->revisionIdField, $this->revisionLengthField ] )
                    ->where( [ $this->revisionIdField => $parentRevIds ] )
                    ->caller( __METHOD__ )
                    ->fetchResultSet();
                foreach ( $result as $row ) {
                    $this->mParentLens[(int)$row->{$this->revisionIdField}] = $row->{$this->revisionLengthField};
                }
            }
        }
        $this->mParentLens += $this->revisionStore->getRevisionSizes(
            array_diff( $parentRevIds, array_keys( $this->mParentLens ) )
        );
        $linkBatch->execute();

        $revisionBatch = $this->commentFormatter->createRevisionBatch()
            ->authority( $this->getAuthority() )
            ->revisions( $revisions );

        if ( !$this->isArchive ) {
            // Only show public comments, because this page might be public
            $revisionBatch = $revisionBatch->hideIfDeleted();
        }

        $this->formattedComments = $revisionBatch->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->{$this->revisionIdField} )
            && isset( $this->revisions[$row->{$this->revisionIdField}] )
        ) {
            return $this->revisions[$row->{$this->revisionIdField}];
        }

        if (
            $this->isArchive &&
            $this->revisionStore->isRevisionRow( $row, 'archive' )
        ) {
            return $this->revisionStore->newRevisionFromArchiveRow( $row, 0, $title );
        }

        if (
            !$this->isArchive &&
            $this->revisionStore->isRevisionRow( $row )
        ) {
            return $this->revisionStore->newRevisionFromRow( $row, 0, $title );
        }

        return null;
    }

    /**
     * Create a revision record from a $row that models a revision.
     *
     * @param mixed $row
     * @param Title|null $title
     * @return RevisionRecord
     */
    public function createRevisionRecord( $row, $title = null ) {
        if ( $this->isArchive ) {
            return $this->revisionStore->newRevisionFromArchiveRow( $row, 0, $title );
        }

        return $this->revisionStore->newRevisionFromRow( $row, 0, $title );
    }

    /**
     * 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 = [];
        $authority = $this->getAuthority();
        $language = $this->getLanguage();

        $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->{$this->pageNamespaceField} ) && isset( $row->{$this->pageTitleField} ) ) {
            $page = Title::makeTitle( $row->{$this->pageNamespaceField}, $row->{$this->pageTitleField} );
        }

        // 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->createRevisionRecord( $row, $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 = '';

            // Add links for seeing history, diff, etc.
            if ( $this->isArchive ) {
                // Add the same links as DeletedContribsPager::formatRevisionRow
                $undelete = SpecialPage::getTitleFor( 'Undelete' );
                if ( $authority->isAllowed( 'deletedtext' ) ) {
                    $last = $linkRenderer->makeKnownLink(
                        $undelete,
                        new HtmlArmor( $this->messages['diff'] ),
                        [],
                        [
                            'target' => $page->getPrefixedText(),
                            'timestamp' => $revRecord->getTimestamp(),
                            'diff' => 'prev'
                        ]
                    );
                } else {
                    $last = $this->messages['diff'];
                }

                $logs = SpecialPage::getTitleFor( 'Log' );
                $dellog = $linkRenderer->makeKnownLink(
                    $logs,
                    new HtmlArmor( $this->messages['deletionlog'] ),
                    [],
                    [
                        'type' => 'delete',
                        'page' => $page->getPrefixedText()
                    ]
                );

                $reviewlink = $linkRenderer->makeKnownLink(
                    SpecialPage::getTitleFor( 'Undelete', $page->getPrefixedDBkey() ),
                    new HtmlArmor( $this->messages['undeleteviewlink'] )
                );

                $diffHistLinks = Html::rawElement(
                    'span',
                    [ 'class' => 'mw-deletedcontribs-tools' ],
                    $this->msg( 'parentheses' )->rawParams( $language->pipeList(
                        [ $last, $dellog, $reviewlink ] ) )->escaped()
                );

                $date = $language->userTimeAndDate(
                    $revRecord->getTimestamp(),
                    $this->getUser()
                );

                if ( $authority->isAllowed( 'undelete' ) &&
                    $revRecord->userCan( RevisionRecord::DELETED_TEXT, $authority )
                ) {
                    $dateLink = $linkRenderer->makeKnownLink(
                        SpecialPage::getTitleFor( 'Undelete' ),
                        $date,
                        [ 'class' => 'mw-changeslist-date' ],
                        [
                            'target' => $page->getPrefixedText(),
                            'timestamp' => $revRecord->getTimestamp()
                        ]
                    );
                } else {
                    $dateLink = htmlspecialchars( $date );
                }
                if ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
                    $class = Linker::getRevisionDeletedClass( $revRecord );
                    $dateLink = Html::rawElement(
                        'span',
                        [ 'class' => $class ],
                        $dateLink
                    );
                }

            } else {
                $pagerTools = new PagerTools(
                    $revRecord,
                    null,
                    $row->{$this->revisionIdField} === $row->page_latest && !$row->page_is_new,
                    $this->hookRunner,
                    $page,
                    $this->getContext(),
                    $this->getLinkRenderer()
                );
                if ( $row->{$this->revisionIdField} === $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, $authority )
                ) {
                    $difftext = $linkRenderer->makeKnownLink(
                        $page,
                        new HtmlArmor( $this->messages['diff'] ),
                        [ 'class' => 'mw-changeslist-diff' ],
                        [
                            'diff' => 'prev',
                            'oldid' => $row->{$this->revisionIdField},
                        ]
                    );
                } else {
                    $difftext = $this->messages['diff'];
                }
                $histlink = $linkRenderer->makeKnownLink(
                    $page,
                    new HtmlArmor( $this->messages['hist'] ),
                    [ 'class' => 'mw-changeslist-history' ],
                    [ 'action' => 'history' ]
                );

                // 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 )
                );

                $dateLink = ChangesList::revDateLink( $revRecord, $authority, $language, $page );
            }

            if ( $row->{$this->revisionParentIdField} === 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->{$this->revisionLengthField} );
                $chardiff .= ' <span class="mw-changeslist-separator"></span> ';
            } else {
                $parentLen = 0;
                if ( isset( $this->mParentLens[$row->{$this->revisionParentIdField}] ) ) {
                    $parentLen = $this->mParentLens[$row->{$this->revisionParentIdField}];
                }

                $chardiff = ' <span class="mw-changeslist-separator"></span> ';
                $chardiff .= ChangesList::showCharacterDifference(
                    $parentLen,
                    $row->{$this->revisionLengthField},
                    $this->getContext()
                );
                $chardiff .= ' <span class="mw-changeslist-separator"></span> ';
            }

            $comment = $this->formattedComments[$row->{$this->revisionIdField}];

            if ( $comment === '' ) {
                $defaultComment = $this->messages['changeslist-nocomment'];
                $comment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
            }

            $comment = $language->getDirMark() . $comment;

            // 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> '
                    . $language->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 .= ' ';
            }

            # Tags, if any. Save some time using a cache.
            [ $tagSummary, $newClasses ] = $this->tagsCache->getWithSetCallback(
                $this->tagsCache->makeKey(
                    $row->ts_tags ?? '',
                    $this->getUser()->getName(),
                    $language->getCode()
                ),
                fn () => ChangeTags::formatSummaryRow(
                    $row->ts_tags,
                    null,
                    $this->getContext()
                )
            );
            $classes = array_merge( $classes, $newClasses );

            if ( !$this->isArchive ) {
                $this->hookRunner->onSpecialContributions__formatRow__flags(
                    $this->getContext(), $row, $flags );
            }

            $templateParams = [
                'del' => $del,
                'timestamp' => $dateLink,
                '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
        $lineEndingsHook = $this->isArchive ?
            'onDeletedContributionsLineEnding' :
            'onContributionsLineEnding';
        $this->hookRunner->$lineEndingsHook( $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 ContributionsSpecialPage row that could not be formatted" );
            return "<!-- Could not format ContributionsSpecialPage 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;
    }

}