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