wiki.techinc.nl/includes/pager/ContributionsPager.php
Ammarpad 2f0a0370a6 ContributionsPager: Fix getTemplateParams() parameter
This needs to get $classes by reference

Bug: T378132
Depends-On: I9d4507fc8e7b2e44c8ecbda9400c5af8103b7c81
Follow-Up: I95a5b88ec81583e16ccf8e58cdb8e12e00aae5bf
Change-Id: I810f26e04113d5c9c7c51f88430063b0d07e6b25
(cherry picked from commit 83c5211931e1f53448d7dc69956a9584f87b7620)
2024-10-25 13:52:47 +00:00

1127 lines
33 KiB
PHP

<?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\MediaWikiServices;
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
*/
protected $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;
protected ?Title $currentPage;
protected ?RevisionRecord $currentRevRecord;
/**
* @var array
*/
private $mParentLens;
/** @var UserIdentity */
protected $targetUser;
/**
* Set to protected to allow subclasses access for overrides
*/
protected 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;
}
MediaWikiServices::getInstance()->getChangeTagsStore()->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 ) {
$revisionRecord = $this->tryCreatingRevisionRecord( $row );
if ( !$revisionRecord ) {
continue;
}
if ( isset( $row->{$this->revisionParentIdField} ) && $row->{$this->revisionParentIdField} ) {
$parentRevIds[] = (int)$row->{$this->revisionParentIdField};
}
$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 );
}
/**
* Populate the HTML attributes.
*
* @param mixed $row
* @param string[] &$attributes
*/
protected function populateAttributes( $row, &$attributes ) {
$attributes['data-mw-revid'] = $this->currentRevRecord->getId();
}
/**
* Format a link to an article.
*
* @param mixed $row
* @return string
*/
protected function formatArticleLink( $row ) {
if ( !$this->currentPage ) {
return '';
}
$dir = $this->getLanguage()->getDir();
return Html::rawElement( 'bdi', [ 'dir' => $dir ], $this->getLinkRenderer()->makeLink(
$this->currentPage,
$this->currentPage->getPrefixedText(),
[ 'class' => 'mw-contributions-title' ],
$this->currentPage->isRedirect() ? [ 'redirect' => 'no' ] : []
) );
}
/**
* Format diff and history links.
*
* @param mixed $row
* @return string
*/
protected function formatDiffHistLinks( $row ) {
if ( !$this->currentPage || !$this->currentRevRecord ) {
return '';
}
if ( $this->isArchive ) {
// Add the same links as DeletedContribsPager::formatRevisionRow
$undelete = SpecialPage::getTitleFor( 'Undelete' );
if ( $this->getAuthority()->isAllowed( 'deletedtext' ) ) {
$last = $this->getLinkRenderer()->makeKnownLink(
$undelete,
new HtmlArmor( $this->messages['diff'] ),
[],
[
'target' => $this->currentPage->getPrefixedText(),
'timestamp' => $this->currentRevRecord->getTimestamp(),
'diff' => 'prev'
]
);
} else {
$last = $this->messages['diff'];
}
$logs = SpecialPage::getTitleFor( 'Log' );
$dellog = $this->getLinkRenderer()->makeKnownLink(
$logs,
new HtmlArmor( $this->messages['deletionlog'] ),
[],
[
'type' => 'delete',
'page' => $this->currentPage->getPrefixedText()
]
);
$reviewlink = $this->getLinkRenderer()->makeKnownLink(
SpecialPage::getTitleFor( 'Undelete', $this->currentPage->getPrefixedDBkey() ),
new HtmlArmor( $this->messages['undeleteviewlink'] )
);
return Html::rawElement(
'span',
[ 'class' => 'mw-deletedcontribs-tools' ],
$this->msg( 'parentheses' )->rawParams( $this->getLanguage()->pipeList(
[ $last, $dellog, $reviewlink ] ) )->escaped()
);
} else {
# Is there a visible previous revision?
if ( $this->currentRevRecord->getParentId() !== 0 &&
$this->currentRevRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
) {
$difftext = $this->getLinkRenderer()->makeKnownLink(
$this->currentPage,
new HtmlArmor( $this->messages['diff'] ),
[ 'class' => 'mw-changeslist-diff' ],
[
'diff' => 'prev',
'oldid' => $row->{$this->revisionIdField},
]
);
} else {
$difftext = $this->messages['diff'];
}
$histlink = $this->getLinkRenderer()->makeKnownLink(
$this->currentPage,
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
return 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 )
);
}
}
/**
* Format a date link.
*
* @param mixed $row
* @return string
*/
protected function formatDateLink( $row ) {
if ( !$this->currentPage || !$this->currentRevRecord ) {
return '';
}
if ( $this->isArchive ) {
$date = $this->getLanguage()->userTimeAndDate(
$this->currentRevRecord->getTimestamp(),
$this->getUser()
);
if ( $this->getAuthority()->isAllowed( 'undelete' ) &&
$this->currentRevRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
) {
$dateLink = $this->getLinkRenderer()->makeKnownLink(
SpecialPage::getTitleFor( 'Undelete' ),
$date,
[ 'class' => 'mw-changeslist-date' ],
[
'target' => $this->currentPage->getPrefixedText(),
'timestamp' => $this->currentRevRecord->getTimestamp()
]
);
} else {
$dateLink = htmlspecialchars( $date );
}
if ( $this->currentRevRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
$class = Linker::getRevisionDeletedClass( $this->currentRevRecord );
$dateLink = Html::rawElement(
'span',
[ 'class' => $class ],
$dateLink
);
}
} else {
$dateLink = ChangesList::revDateLink(
$this->currentRevRecord,
$this->getAuthority(),
$this->getLanguage(),
$this->currentPage
);
}
return $dateLink;
}
/**
* Format annotation and add extra class if a row represents a latest revision.
*
* @param mixed $row
* @param string[] &$classes
* @return string
*/
protected function formatTopMarkText( $row, &$classes ) {
if ( !$this->currentPage || !$this->currentRevRecord ) {
return '';
}
$topmarktext = '';
if ( !$this->isArchive ) {
$pagerTools = new PagerTools(
$this->currentRevRecord,
null,
$row->{$this->revisionIdField} === $row->page_latest && !$row->page_is_new,
$this->hookRunner,
$this->currentPage,
$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();
}
return $topmarktext;
}
/**
* Format annotation to show the size of a diff.
*
* @param mixed $row
* @return string
*/
protected function formatCharDiff( $row ) {
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> ';
}
return $chardiff;
}
/**
* Format a comment for a revision.
*
* @param mixed $row
* @return string
*/
protected function formatComment( $row ) {
$comment = $this->formattedComments[$row->{$this->revisionIdField}];
if ( $comment === '' ) {
$defaultComment = $this->messages['changeslist-nocomment'];
$comment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
}
// Don't wrap result of this with <bdi> or any other element, see T377555
return $comment;
}
/**
* Format a user link.
*
* @param mixed $row
* @return string
*/
protected function formatUserLink( $row ) {
if ( !$this->currentRevRecord ) {
return '';
}
$dir = $this->getLanguage()->getDir();
// When the author is different from the target, always show user and user talk links
$userlink = '';
$revUser = $this->currentRevRecord->getUser();
$revUserId = $revUser ? $revUser->getId() : 0;
$revUserText = $revUser ? $revUser->getName() : '';
if ( $this->target !== $revUserText ) {
$userlink = ' <span class="mw-changeslist-separator"></span> '
. Html::rawElement( 'bdi', [ 'dir' => $dir ],
Linker::userLink( $revUserId, $revUserText ) );
$userlink .= ' ' . $this->msg( 'parentheses' )->rawParams(
Linker::userTalkLink( $revUserId, $revUserText ) )->escaped() . ' ';
}
return $userlink;
}
/**
* @param mixed $row
* @return string[]
*/
protected function formatFlags( $row ) {
if ( !$this->currentRevRecord ) {
return [];
}
$flags = [];
if ( $this->currentRevRecord->getParentId() === 0 ) {
$flags[] = ChangesList::flag( 'newpage' );
}
if ( $this->currentRevRecord->isMinor() ) {
$flags[] = ChangesList::flag( 'minor' );
}
return $flags;
}
/**
* Format link for changing visibility.
*
* @param mixed $row
* @return string
*/
protected function formatVisibilityLink( $row ) {
if ( !$this->currentPage || !$this->currentRevRecord ) {
return '';
}
$del = Linker::getRevDeleteLink(
$this->getAuthority(),
$this->currentRevRecord,
$this->currentPage
);
if ( $del !== '' ) {
$del .= ' ';
}
return $del;
}
/**
* @param mixed $row
* @param string[] &$classes
* @return string
*/
protected function formatTags( $row, &$classes ) {
# Tags, if any. Save some time using a cache.
[ $tagSummary, $newClasses ] = $this->tagsCache->getWithSetCallback(
$this->tagsCache->makeKey(
$row->ts_tags ?? '',
$this->getUser()->getName(),
$this->getLanguage()->getCode()
),
fn () => ChangeTags::formatSummaryRow(
$row->ts_tags,
null,
$this->getContext()
)
);
$classes = array_merge( $classes, $newClasses );
return $tagSummary;
}
/**
* Check whether the revision author is deleted
*
* @param mixed $row
* @return bool
*/
public function revisionUserIsDeleted( $row ) {
return $this->currentRevRecord->isDeleted( RevisionRecord::DELETED_USER );
}
/**
* 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 = [];
$this->currentPage = null;
$this->currentRevRecord = 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} ) ) {
$this->currentPage = 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.
$this->currentRevRecord = $this->tryCreatingRevisionRecord( $row, $this->currentPage );
if ( $this->revisionsOnly || ( $this->currentRevRecord && $this->currentPage ) ) {
$this->populateAttributes( $row, $attribs );
$templateParams = $this->getTemplateParams( $row, $classes );
$ret = $this->getProcessedTemplate( $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";
}
/**
* Generate array of template parameters to pass to the template for rendering.
* Function can be overriden by classes to add/remove their own parameters.
*
* @since 1.43
*
* @param stdClass|mixed $row
* @param string[] &$classes
* @return mixed[]
*/
public function getTemplateParams( $row, &$classes ) {
$link = $this->formatArticleLink( $row );
$topmarktext = $this->formatTopMarkText( $row, $classes );
$diffHistLinks = $this->formatDiffHistLinks( $row );
$dateLink = $this->formatDateLink( $row );
$chardiff = $this->formatCharDiff( $row );
$comment = $this->formatComment( $row );
$userlink = $this->formatUserLink( $row );
$flags = $this->formatFlags( $row );
$del = $this->formatVisibilityLink( $row );
$tagSummary = $this->formatTags( $row, $classes );
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 ( $this->revisionUserIsDeleted( $row ) ) {
$templateParams['rev-deleted-user-contribs'] =
$this->msg( 'rev-deleted-user-contribs' )->escaped();
}
return $templateParams;
}
/**
* Return the processed template. Function can be overriden by classes
* to provide their own template parser.
*
* @since 1.43
*
* @param string[] $templateParams
* @return string
*/
public function getProcessedTemplate( $templateParams ) {
return $this->templateParser->processTemplate(
'SpecialContributionsLine',
$templateParams
);
}
/**
* 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;
}
}