27use InvalidArgumentException;
111 private $deletedOnly;
132 private $revisionsOnly;
135 private $preventClickjacking =
false;
143 private $mParentLens;
160 private $formattedComments = [];
163 private $revisions = [];
206 $this->isArchive = $options[
'isArchive'] ??
false;
207 $this->runHooks = $options[
'runHooks'] ??
true;
220 $this->target = $options[
'target'] ??
'';
223 $this->target, UserRigorOptions::RIGOR_NONE
225 if ( !$this->targetUser ) {
229 throw new InvalidArgumentException( __METHOD__ .
': the user name is too ' .
230 'broken to use even with validation disabled.' );
234 $this->
namespace = $options['namespace'] ?? '';
235 $this->tagFilter = $options[
'tagfilter'] ??
false;
236 $this->tagInvert = $options[
'tagInvert'] ??
false;
237 $this->nsInvert = $options[
'nsInvert'] ??
false;
238 $this->associated = $options[
'associated'] ??
false;
240 $this->deletedOnly = !empty( $options[
'deletedOnly'] );
241 $this->topOnly = !empty( $options[
'topOnly'] );
242 $this->newOnly = !empty( $options[
'newOnly'] );
243 $this->hideMinor = !empty( $options[
'hideMinor'] );
244 $this->revisionsOnly = !empty( $options[
'revisionsOnly'] );
246 parent::__construct( $context, $linkRenderer );
253 'changeslist-nocomment',
259 foreach ( $msgs as $msg ) {
260 $this->messages[$msg] = $this->
msg( $msg )->escaped();
264 $startTimestamp =
'';
266 if ( isset( $options[
'start'] ) && $options[
'start'] ) {
267 $startTimestamp = $options[
'start'] .
' 00:00:00';
269 if ( isset( $options[
'end'] ) && $options[
'end'] ) {
270 $endTimestamp = $options[
'end'] .
' 23:59:59';
275 $this->linkBatchFactory = $linkBatchFactory;
276 $this->hookRunner =
new HookRunner( $hookContainer );
279 $this->commentFormatter = $commentFormatter;
284 $query = parent::getDefaultQuery();
300 [ $tables, $fields, $conds, $fname, $options, $join_conds ] = $this->
buildQueryInfo(
306 $options[
'MAX_EXECUTION_TIME'] =
327 $data = [ $dbr->newSelectQueryBuilder()
328 ->tables( is_array( $tables ) ? $tables : [ $tables ] )
332 ->options( $options )
333 ->joinConds( $join_conds )
335 ->fetchResultSet() ];
336 if ( !$this->revisionsOnly && $this->runHooks ) {
340 $reallyDoQueryHook = $this->isArchive ?
341 'onDeletedContribsPager__reallyDoQuery' :
342 'onContribsPager__reallyDoQuery';
346 $this->hookRunner->$reallyDoQueryHook( $data, $this, $offset, $limit, $order );
352 foreach ( $data as $query ) {
353 foreach ( $query as $i => $row ) {
355 $index = $order === self::QUERY_ASCENDING ? $i : $limit - 1 - $i;
357 $index = str_pad( (
string)$index, strlen( (
string)$limit ),
'0', STR_PAD_LEFT );
359 $indexFieldValues = array_map(
360 static fn ( $fieldName ) => $row->$fieldName,
361 (array)$this->mIndexField
363 $result[implode(
'-', $indexFieldValues ) .
"-$index"] = $row;
368 if ( $order === self::QUERY_ASCENDING ) {
375 $result = array_slice( $result, 0, $limit );
378 $result = array_values( $result );
394 if ( $this->deletedOnly ) {
395 $queryInfo[
'conds'][] = $this->revisionDeletedField .
' != 0';
398 if ( !$this->isArchive && $this->topOnly ) {
399 $queryInfo[
'conds'][] = $this->revisionIdField .
' = page_latest';
402 if ( $this->newOnly ) {
403 $queryInfo[
'conds'][] = $this->revisionParentIdField .
' = 0';
406 if ( $this->hideMinor ) {
407 $queryInfo[
'conds'][] = $this->revisionMinorField .
' = 0';
410 $queryInfo[
'conds'] = array_merge( $queryInfo[
'conds'], $this->
getNamespaceCond() );
414 if ( !$this->
getAuthority()->isAllowed(
'deletedhistory' ) ) {
415 $queryInfo[
'conds'][] = $dbr->bitAnd(
416 $this->revisionDeletedField, RevisionRecord::DELETED_USER
418 } elseif ( !$this->
getAuthority()->isAllowedAny(
'suppressrevision',
'viewsuppressed' ) ) {
419 $queryInfo[
'conds'][] = $dbr->bitAnd(
420 $this->revisionDeletedField, RevisionRecord::SUPPRESSED_USER
421 ) .
' != ' . RevisionRecord::SUPPRESSED_USER;
425 $indexFields = array_diff(
426 (array)$this->mIndexField,
430 foreach ( $indexFields as $indexField ) {
432 if ( !array_key_exists( $indexField, $queryInfo[
'fields'] ) ) {
433 $queryInfo[
'fields'][] = $indexField;
438 $queryInfo[
'tables'],
439 $queryInfo[
'fields'],
441 $queryInfo[
'join_conds'],
442 $queryInfo[
'options'],
447 if ( !$this->isArchive && $this->runHooks ) {
448 $this->hookRunner->onContribsPager__getQueryInfo( $this, $queryInfo );
455 if ( $this->
namespace !==
'' ) {
457 $namespaces = [ $this->namespace ];
458 $eq_op = $this->nsInvert ?
'!=' :
'=';
459 if ( $this->associated ) {
460 $namespaces[] = $this->namespaceInfo->getAssociated( $this->
namespace );
462 return [ $dbr->expr( $this->pageNamespaceField, $eq_op, $namespaces ) ];
472 return $this->tagFilter;
479 return $this->tagInvert;
493 return $this->newOnly;
500 return $this->namespace;
504 # Do a link batch query
505 $this->mResult->seek( 0 );
507 $this->mParentLens = [];
509 $linkBatch = $this->linkBatchFactory->newLinkBatch();
510 # Give some pointers to make (last) links
511 foreach ( $this->mResult as $row ) {
513 if ( !$revisionRecord ) {
516 if ( isset( $row->{$this->revisionParentIdField} ) && $row->{$this->revisionParentIdField} ) {
517 $parentRevIds[] = (int)$row->{$this->revisionParentIdField};
519 $this->mParentLens[(int)$row->{$this->revisionIdField}] = $row->{$this->revisionLengthField};
520 if ( $this->target !== $row->{$this->userNameField} ) {
522 $linkBatch->add(
NS_USER_TALK, $row->{$this->userNameField} );
524 $linkBatch->add( $row->{$this->pageNamespaceField}, $row->{$this->pageTitleField} );
525 $revisions[$row->{$this->revisionIdField}] = $this->createRevisionRecord( $row );
529 if ( $this->isArchive ) {
530 $parentRevIds = array_diff( $parentRevIds, array_keys( $this->mParentLens ) );
531 if ( $parentRevIds ) {
532 $result = $this->revisionStore
533 ->newArchiveSelectQueryBuilder( $this->getDatabase() )
535 ->fields( [ $this->revisionIdField, $this->revisionLengthField ] )
536 ->where( [ $this->revisionIdField => $parentRevIds ] )
537 ->caller( __METHOD__ )
539 foreach ( $result as $row ) {
540 $this->mParentLens[(int)$row->{$this->revisionIdField}] = $row->{$this->revisionLengthField};
544 $this->mParentLens += $this->revisionStore->getRevisionSizes(
545 array_diff( $parentRevIds, array_keys( $this->mParentLens ) )
547 $linkBatch->execute();
549 $revisionBatch = $this->commentFormatter->createRevisionBatch()
551 ->revisions( $revisions );
553 if ( !$this->isArchive ) {
555 $revisionBatch = $revisionBatch->hideIfDeleted();
558 $this->formattedComments = $revisionBatch->execute();
560 # For performance, save the revision objects for later.
561 # The array is indexed by rev_id. doBatchLookups() may be called
562 # multiple times with different results, so merge the revisions array,
563 # ignoring any duplicates.
564 $this->revisions += $revisions;
571 return "<section class='mw-pager-body'>\n";
578 return "</section>\n";
585 return $this->msg(
'nocontribs' )->parse();
599 if ( $row instanceof stdClass && isset( $row->{$this->revisionIdField} )
600 && isset( $this->revisions[$row->{$this->revisionIdField}] )
602 return $this->revisions[$row->{$this->revisionIdField}];
607 $this->revisionStore->isRevisionRow( $row,
'archive' )
609 return $this->revisionStore->newRevisionFromArchiveRow( $row, 0, $title );
614 $this->revisionStore->isRevisionRow( $row )
616 return $this->revisionStore->newRevisionFromRow( $row, 0, $title );
630 if ( $this->isArchive ) {
631 return $this->revisionStore->newRevisionFromArchiveRow( $row, 0, $title );
634 return $this->revisionStore->newRevisionFromRow( $row, 0, $title );
644 $attributes[
'data-mw-revid'] = $this->currentRevRecord->getId();
654 if ( !$this->currentPage ) {
657 $dir = $this->getLanguage()->getDir();
658 return Html::rawElement(
'bdi', [
'dir' => $dir ], $this->getLinkRenderer()->makeLink(
660 $this->currentPage->getPrefixedText(),
661 [
'class' =>
'mw-contributions-title' ],
662 $this->currentPage->isRedirect() ? [
'redirect' =>
'no' ] : []
673 if ( !$this->currentPage || !$this->currentRevRecord ) {
676 if ( $this->isArchive ) {
678 $undelete = SpecialPage::getTitleFor(
'Undelete' );
679 if ( $this->getAuthority()->isAllowed(
'deletedtext' ) ) {
680 $last = $this->getLinkRenderer()->makeKnownLink(
682 new HtmlArmor( $this->messages[
'diff'] ),
685 'target' => $this->currentPage->getPrefixedText(),
686 'timestamp' => $this->currentRevRecord->getTimestamp(),
691 $last = $this->messages[
'diff'];
694 $logs = SpecialPage::getTitleFor(
'Log' );
695 $dellog = $this->getLinkRenderer()->makeKnownLink(
697 new HtmlArmor( $this->messages[
'deletionlog'] ),
701 'page' => $this->currentPage->getPrefixedText()
705 $reviewlink = $this->getLinkRenderer()->makeKnownLink(
706 SpecialPage::getTitleFor(
'Undelete', $this->currentPage->getPrefixedDBkey() ),
707 new HtmlArmor( $this->messages[
'undeleteviewlink'] )
710 return Html::rawElement(
712 [
'class' =>
'mw-deletedcontribs-tools' ],
713 $this->msg(
'parentheses' )->rawParams( $this->getLanguage()->pipeList(
714 [ $last, $dellog, $reviewlink ] ) )->escaped()
717 # Is there a visible previous revision?
718 if ( $this->currentRevRecord->getParentId() !== 0 &&
719 $this->currentRevRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
721 $difftext = $this->getLinkRenderer()->makeKnownLink(
723 new HtmlArmor( $this->messages[
'diff'] ),
724 [
'class' =>
'mw-changeslist-diff' ],
727 'oldid' => $row->{$this->revisionIdField},
731 $difftext = $this->messages[
'diff'];
733 $histlink = $this->getLinkRenderer()->makeKnownLink(
735 new HtmlArmor( $this->messages[
'hist'] ),
736 [
'class' =>
'mw-changeslist-history' ],
737 [
'action' =>
'history' ]
744 return Html::rawElement(
'span',
745 [
'class' =>
'mw-changeslist-links' ],
748 Html::rawElement(
'span', [], $difftext ) .
750 Html::rawElement(
'span', [], $histlink )
762 if ( !$this->currentPage || !$this->currentRevRecord ) {
765 if ( $this->isArchive ) {
766 $date = $this->getLanguage()->userTimeAndDate(
767 $this->currentRevRecord->getTimestamp(),
771 if ( $this->getAuthority()->isAllowed(
'undelete' ) &&
772 $this->currentRevRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
774 $dateLink = $this->getLinkRenderer()->makeKnownLink(
775 SpecialPage::getTitleFor(
'Undelete' ),
777 [
'class' =>
'mw-changeslist-date' ],
779 'target' => $this->currentPage->getPrefixedText(),
780 'timestamp' => $this->currentRevRecord->getTimestamp()
784 $dateLink = htmlspecialchars( $date );
786 if ( $this->currentRevRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
787 $class = Linker::getRevisionDeletedClass( $this->currentRevRecord );
788 $dateLink = Html::rawElement(
790 [
'class' => $class ],
795 $dateLink = ChangesList::revDateLink(
796 $this->currentRevRecord,
797 $this->getAuthority(),
798 $this->getLanguage(),
813 if ( !$this->currentPage || !$this->currentRevRecord ) {
817 if ( !$this->isArchive ) {
819 $this->currentRevRecord,
821 $row->{$this->revisionIdField} === $row->page_latest && !$row->page_is_new,
825 $this->getLinkRenderer()
827 if ( $row->{$this->revisionIdField} === $row->page_latest ) {
828 $topmarktext .=
'<span class="mw-uctop">' . $this->messages[
'uctop'] .
'</span>';
829 $classes[] =
'mw-contributions-current';
831 if ( $pagerTools->shouldPreventClickjacking() ) {
832 $this->setPreventClickjacking(
true );
834 $topmarktext .= $pagerTools->toHTML();
846 if ( $row->{$this->revisionParentIdField} === null ) {
850 $chardiff =
' <span class="mw-changeslist-separator"></span> ';
851 $chardiff .= Linker::formatRevisionSize( $row->{$this->revisionLengthField} );
852 $chardiff .=
' <span class="mw-changeslist-separator"></span> ';
855 if ( isset( $this->mParentLens[$row->{$this->revisionParentIdField}] ) ) {
856 $parentLen = $this->mParentLens[$row->{$this->revisionParentIdField}];
859 $chardiff =
' <span class="mw-changeslist-separator"></span> ';
862 $row->{$this->revisionLengthField},
865 $chardiff .=
' <span class="mw-changeslist-separator"></span> ';
877 $comment = $this->formattedComments[$row->{$this->revisionIdField}];
879 if ( $comment ===
'' ) {
880 $defaultComment = $this->messages[
'changeslist-nocomment'];
881 $comment =
"<span class=\"comment mw-comment-none\">$defaultComment</span>";
895 if ( !$this->currentRevRecord ) {
898 $dir = $this->getLanguage()->getDir();
902 $revUser = $this->currentRevRecord->getUser();
903 $revUserId = $revUser ? $revUser->getId() : 0;
904 $revUserText = $revUser ? $revUser->getName() :
'';
905 if ( $this->target !== $revUserText ) {
906 $userlink =
' <span class="mw-changeslist-separator"></span> '
907 . Html::rawElement(
'bdi', [
'dir' => $dir ],
908 Linker::userLink( $revUserId, $revUserText ) );
909 $userlink .=
' ' . $this->msg(
'parentheses' )->rawParams(
910 Linker::userTalkLink( $revUserId, $revUserText ) )->escaped() .
' ';
920 if ( !$this->currentRevRecord ) {
924 if ( $this->currentRevRecord->getParentId() === 0 ) {
925 $flags[] = ChangesList::flag(
'newpage' );
928 if ( $this->currentRevRecord->isMinor() ) {
929 $flags[] = ChangesList::flag(
'minor' );
941 if ( !$this->currentPage || !$this->currentRevRecord ) {
944 $del = Linker::getRevDeleteLink(
945 $this->getAuthority(),
946 $this->currentRevRecord,
961 # Tags, if any. Save some time using a cache.
962 [ $tagSummary, $newClasses ] = $this->tagsCache->getWithSetCallback(
963 $this->tagsCache->makeKey(
965 $this->getUser()->getName(),
966 $this->getLanguage()->getCode()
974 $classes = array_merge( $classes, $newClasses );
985 return $this->currentRevRecord->isDeleted( RevisionRecord::DELETED_USER );
1005 $this->currentPage =
null;
1006 $this->currentRevRecord =
null;
1010 if ( isset( $row->{$this->pageNamespaceField} ) && isset( $row->{$this->pageTitleField} ) ) {
1011 $this->currentPage = Title::makeTitle( $row->{$this->pageNamespaceField}, $row->{$this->pageTitleField} );
1019 $this->currentRevRecord = $this->tryCreatingRevisionRecord( $row, $this->currentPage );
1020 if ( $this->revisionsOnly || ( $this->currentRevRecord && $this->currentPage ) ) {
1021 $this->populateAttributes( $row, $attribs );
1023 $templateParams = $this->getTemplateParams( $row, $classes );
1024 $ret = $this->getProcessedTemplate( $templateParams );
1027 if ( $this->runHooks ) {
1029 $lineEndingsHook = $this->isArchive ?
1030 'onDeletedContributionsLineEnding' :
1031 'onContributionsLineEnding';
1032 $this->hookRunner->$lineEndingsHook( $this, $ret, $row, $classes, $attribs );
1035 $attribs = array_filter( $attribs,
1036 [ Sanitizer::class,
'isReservedDataAttribute' ],
1037 ARRAY_FILTER_USE_KEY
1043 if ( $classes === [] && $attribs === [] && $ret ===
'' ) {
1044 wfDebug(
"Dropping ContributionsSpecialPage row that could not be formatted" );
1045 return "<!-- Could not format ContributionsSpecialPage row. -->\n";
1047 $attribs[
'class'] = $classes;
1051 return Html::rawElement(
'li', $attribs, $ret ) .
"\n";
1065 $link = $this->formatArticleLink( $row );
1066 $topmarktext = $this->formatTopMarkText( $row, $classes );
1067 $diffHistLinks = $this->formatDiffHistLinks( $row );
1068 $dateLink = $this->formatDateLink( $row );
1069 $chardiff = $this->formatCharDiff( $row );
1070 $comment = $this->formatComment( $row );
1071 $userlink = $this->formatUserLink( $row );
1072 $flags = $this->formatFlags( $row );
1073 $del = $this->formatVisibilityLink( $row );
1074 $tagSummary = $this->formatTags( $row, $classes );
1076 if ( !$this->isArchive && $this->runHooks ) {
1077 $this->hookRunner->onSpecialContributions__formatRow__flags(
1078 $this->getContext(), $row, $flags );
1083 'timestamp' => $dateLink,
1084 'diffHistLinks' => $diffHistLinks,
1085 'charDifference' => $chardiff,
1087 'articleLink' => $link,
1088 'userlink' => $userlink,
1089 'logText' => $comment,
1090 'topmarktext' => $topmarktext,
1091 'tagSummary' => $tagSummary,
1094 # Denote if username is redacted for this edit
1095 if ( $this->revisionUserIsDeleted( $row ) ) {
1096 $templateParams[
'rev-deleted-user-contribs'] =
1097 $this->msg(
'rev-deleted-user-contribs' )->escaped();
1100 return $templateParams;
1113 return $this->templateParser->processTemplate(
1114 'SpecialContributionsLine',
1124 if ( $this->
namespace || $this->deletedOnly ) {
1126 return 'contributions page filtered for namespace or RevisionDeleted edits';
1128 return 'contributions page unfiltered';
1136 $this->setPreventClickjacking(
true );
1144 $this->preventClickjacking = $enable;
1151 return $this->preventClickjacking;
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
Base class for lists of recent changes shown on special pages.
static showCharacterDifference( $old, $new, ?IContextSource $context=null)
Show formatted char difference.
Marks HTML that shouldn't be escaped.
Store key-value entries in a size-limited in-memory LRU cache.
msg( $key,... $params)
Get a Message object with context set Parameters are the same as wfMessage()
A class containing constants representing the names of configuration variables.
const MaxExecutionTimeForExpensiveQueries
Name constant for the MaxExecutionTimeForExpensiveQueries setting, for use with Config::get()
Parent class for all special pages.
Interface for objects which can provide a MediaWiki context on request.