This needs to get $classes by reference Bug: T378132 Depends-On: I9d4507fc8e7b2e44c8ecbda9400c5af8103b7c81 Follow-Up: I95a5b88ec81583e16ccf8e58cdb8e12e00aae5bf Change-Id: I810f26e04113d5c9c7c51f88430063b0d07e6b25 (cherry picked from commit 83c5211931e1f53448d7dc69956a9584f87b7620)
1127 lines
33 KiB
PHP
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;
|
|
}
|
|
|
|
}
|