27use InvalidArgumentException;
106 private $deletedOnly;
127 private $revisionsOnly;
130 private $preventClickjacking =
false;
138 private $mParentLens;
155 private $formattedComments = [];
158 private $revisions = [];
201 $this->isArchive = $options[
'isArchive'] ??
false;
214 $this->target = $options[
'target'] ??
'';
217 $this->target, UserRigorOptions::RIGOR_NONE
219 if ( !$this->targetUser ) {
223 throw new InvalidArgumentException( __METHOD__ .
': the user name is too ' .
224 'broken to use even with validation disabled.' );
228 $this->
namespace = $options['namespace'] ?? '';
229 $this->tagFilter = $options[
'tagfilter'] ??
false;
230 $this->tagInvert = $options[
'tagInvert'] ??
false;
231 $this->nsInvert = $options[
'nsInvert'] ??
false;
232 $this->associated = $options[
'associated'] ??
false;
234 $this->deletedOnly = !empty( $options[
'deletedOnly'] );
235 $this->topOnly = !empty( $options[
'topOnly'] );
236 $this->newOnly = !empty( $options[
'newOnly'] );
237 $this->hideMinor = !empty( $options[
'hideMinor'] );
238 $this->revisionsOnly = !empty( $options[
'revisionsOnly'] );
240 parent::__construct( $context, $linkRenderer );
247 'changeslist-nocomment',
253 foreach ( $msgs as $msg ) {
254 $this->messages[$msg] = $this->
msg( $msg )->escaped();
258 $startTimestamp =
'';
260 if ( isset( $options[
'start'] ) && $options[
'start'] ) {
261 $startTimestamp = $options[
'start'] .
' 00:00:00';
263 if ( isset( $options[
'end'] ) && $options[
'end'] ) {
264 $endTimestamp = $options[
'end'] .
' 23:59:59';
269 $this->linkBatchFactory = $linkBatchFactory;
270 $this->hookRunner =
new HookRunner( $hookContainer );
272 $this->namespaceInfo = $namespaceInfo;
273 $this->commentFormatter = $commentFormatter;
278 $query = parent::getDefaultQuery();
294 [ $tables, $fields, $conds, $fname, $options, $join_conds ] = $this->
buildQueryInfo(
300 $options[
'MAX_EXECUTION_TIME'] =
321 $data = [ $dbr->newSelectQueryBuilder()
322 ->tables( is_array( $tables ) ? $tables : [ $tables ] )
326 ->options( $options )
327 ->joinConds( $join_conds )
329 ->fetchResultSet() ];
330 if ( !$this->revisionsOnly ) {
334 $reallyDoQueryHook = $this->isArchive ?
335 'onDeletedContribsPager__reallyDoQuery' :
336 'onContribsPager__reallyDoQuery';
340 $this->hookRunner->$reallyDoQueryHook( $data, $this, $offset, $limit, $order );
346 foreach ( $data as $query ) {
347 foreach ( $query as $i => $row ) {
349 $index = $order === self::QUERY_ASCENDING ? $i : $limit - 1 - $i;
351 $index = str_pad( (
string)$index, strlen( (
string)$limit ),
'0', STR_PAD_LEFT );
358 if ( $order === self::QUERY_ASCENDING ) {
365 $result = array_slice( $result, 0, $limit );
368 $result = array_values( $result );
370 return new FakeResultWrapper( $result );
382 $queryInfo = $this->getRevisionQuery();
384 if ( $this->deletedOnly ) {
385 $queryInfo[
'conds'][] = $this->revisionDeletedField .
' != 0';
388 if ( !$this->isArchive && $this->topOnly ) {
389 $queryInfo[
'conds'][] = $this->revisionIdField .
' = page_latest';
392 if ( $this->newOnly ) {
393 $queryInfo[
'conds'][] = $this->revisionParentIdField .
' = 0';
396 if ( $this->hideMinor ) {
397 $queryInfo[
'conds'][] = $this->revisionMinorField .
' = 0';
400 $queryInfo[
'conds'] = array_merge( $queryInfo[
'conds'], $this->getNamespaceCond() );
403 $dbr = $this->getDatabase();
404 if ( !$this->getAuthority()->isAllowed(
'deletedhistory' ) ) {
405 $queryInfo[
'conds'][] = $dbr->bitAnd(
406 $this->revisionDeletedField, RevisionRecord::DELETED_USER
408 } elseif ( !$this->getAuthority()->isAllowedAny(
'suppressrevision',
'viewsuppressed' ) ) {
409 $queryInfo[
'conds'][] = $dbr->bitAnd(
410 $this->revisionDeletedField, RevisionRecord::SUPPRESSED_USER
411 ) .
' != ' . RevisionRecord::SUPPRESSED_USER;
415 $indexField = $this->getIndexField();
416 if ( $indexField !== $this->revisionTimestampField ) {
417 $queryInfo[
'fields'][] = $indexField;
421 $queryInfo[
'tables'],
422 $queryInfo[
'fields'],
424 $queryInfo[
'join_conds'],
425 $queryInfo[
'options'],
430 if ( !$this->isArchive ) {
431 $this->hookRunner->onContribsPager__getQueryInfo( $this, $queryInfo );
438 if ( $this->
namespace !==
'' ) {
439 $dbr = $this->getDatabase();
440 $namespaces = [ $this->namespace ];
441 $eq_op = $this->nsInvert ?
'!=' :
'=';
442 if ( $this->associated ) {
443 $namespaces[] = $this->namespaceInfo->getAssociated( $this->
namespace );
445 return [ $dbr->expr( $this->pageNamespaceField, $eq_op, $namespaces ) ];
455 return $this->tagFilter;
462 return $this->tagInvert;
469 return $this->target;
476 return $this->newOnly;
483 return $this->namespace;
487 # Do a link batch query
488 $this->mResult->seek( 0 );
490 $this->mParentLens = [];
492 $linkBatch = $this->linkBatchFactory->newLinkBatch();
493 # Give some pointers to make (last) links
494 foreach ( $this->mResult as $row ) {
495 $revisionRecord = $this->tryCreatingRevisionRecord( $row );
496 if ( !$revisionRecord ) {
499 if ( isset( $row->{$this->revisionParentIdField} ) && $row->{$this->revisionParentIdField} ) {
500 $parentRevIds[] = (int)$row->{$this->revisionParentIdField};
502 $this->mParentLens[(int)$row->{$this->revisionIdField}] = $row->{$this->revisionLengthField};
503 if ( $this->target !== $row->{$this->userNameField} ) {
505 $linkBatch->add(
NS_USER_TALK, $row->{$this->userNameField} );
507 $linkBatch->add( $row->{$this->pageNamespaceField}, $row->{$this->pageTitleField} );
508 $revisions[$row->{$this->revisionIdField}] = $this->createRevisionRecord( $row );
512 if ( $this->isArchive ) {
513 $parentRevIds = array_diff( $parentRevIds, array_keys( $this->mParentLens ) );
514 if ( $parentRevIds ) {
515 $result = $this->revisionStore
516 ->newArchiveSelectQueryBuilder( $this->getDatabase() )
518 ->fields( [ $this->revisionIdField, $this->revisionLengthField ] )
519 ->where( [ $this->revisionIdField => $parentRevIds ] )
520 ->caller( __METHOD__ )
522 foreach ( $result as $row ) {
523 $this->mParentLens[(int)$row->{$this->revisionIdField}] = $row->{$this->revisionLengthField};
527 $this->mParentLens += $this->revisionStore->getRevisionSizes(
528 array_diff( $parentRevIds, array_keys( $this->mParentLens ) )
530 $linkBatch->execute();
532 $revisionBatch = $this->commentFormatter->createRevisionBatch()
534 ->revisions( $revisions );
536 if ( !$this->isArchive ) {
538 $revisionBatch = $revisionBatch->hideIfDeleted();
541 $this->formattedComments = $revisionBatch->execute();
543 # For performance, save the revision objects for later.
544 # The array is indexed by rev_id. doBatchLookups() may be called
545 # multiple times with different results, so merge the revisions array,
546 # ignoring any duplicates.
547 $this->revisions += $revisions;
554 return "<section class='mw-pager-body'>\n";
561 return "</section>\n";
575 if ( $row instanceof stdClass && isset( $row->{$this->revisionIdField} )
576 && isset( $this->revisions[$row->{$this->revisionIdField}] )
578 return $this->revisions[$row->{$this->revisionIdField}];
583 $this->revisionStore->isRevisionRow( $row,
'archive' )
585 return $this->revisionStore->newRevisionFromArchiveRow( $row, 0, $title );
590 $this->revisionStore->isRevisionRow( $row )
592 return $this->revisionStore->newRevisionFromRow( $row, 0, $title );
606 if ( $this->isArchive ) {
607 return $this->revisionStore->newRevisionFromArchiveRow( $row, 0, $title );
610 return $this->revisionStore->newRevisionFromRow( $row, 0, $title );
620 $attributes[
'data-mw-revid'] = $this->currentRevRecord->getId();
630 if ( !$this->currentPage ) {
633 $dir = $this->getLanguage()->getDir();
634 return Html::rawElement(
'bdi', [
'dir' => $dir ], $this->getLinkRenderer()->makeLink(
636 $this->currentPage->getPrefixedText(),
637 [
'class' =>
'mw-contributions-title' ],
638 $this->currentPage->isRedirect() ? [
'redirect' =>
'no' ] : []
649 if ( !$this->currentPage || !$this->currentRevRecord ) {
652 if ( $this->isArchive ) {
654 $undelete = SpecialPage::getTitleFor(
'Undelete' );
655 if ( $this->getAuthority()->isAllowed(
'deletedtext' ) ) {
656 $last = $this->getLinkRenderer()->makeKnownLink(
658 new HtmlArmor( $this->messages[
'diff'] ),
661 'target' => $this->currentPage->getPrefixedText(),
662 'timestamp' => $this->currentRevRecord->getTimestamp(),
667 $last = $this->messages[
'diff'];
670 $logs = SpecialPage::getTitleFor(
'Log' );
671 $dellog = $this->getLinkRenderer()->makeKnownLink(
673 new HtmlArmor( $this->messages[
'deletionlog'] ),
677 'page' => $this->currentPage->getPrefixedText()
681 $reviewlink = $this->getLinkRenderer()->makeKnownLink(
682 SpecialPage::getTitleFor(
'Undelete', $this->currentPage->getPrefixedDBkey() ),
683 new HtmlArmor( $this->messages[
'undeleteviewlink'] )
686 return Html::rawElement(
688 [
'class' =>
'mw-deletedcontribs-tools' ],
689 $this->msg(
'parentheses' )->rawParams( $this->getLanguage()->pipeList(
690 [ $last, $dellog, $reviewlink ] ) )->escaped()
693 # Is there a visible previous revision?
694 if ( $this->currentRevRecord->getParentId() !== 0 &&
695 $this->currentRevRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
697 $difftext = $this->getLinkRenderer()->makeKnownLink(
699 new HtmlArmor( $this->messages[
'diff'] ),
700 [
'class' =>
'mw-changeslist-diff' ],
703 'oldid' => $row->{$this->revisionIdField},
707 $difftext = $this->messages[
'diff'];
709 $histlink = $this->getLinkRenderer()->makeKnownLink(
711 new HtmlArmor( $this->messages[
'hist'] ),
712 [
'class' =>
'mw-changeslist-history' ],
713 [
'action' =>
'history' ]
720 return Html::rawElement(
'span',
721 [
'class' =>
'mw-changeslist-links' ],
724 Html::rawElement(
'span', [], $difftext ) .
726 Html::rawElement(
'span', [], $histlink )
738 if ( !$this->currentPage || !$this->currentRevRecord ) {
741 if ( $this->isArchive ) {
742 $date = $this->getLanguage()->userTimeAndDate(
743 $this->currentRevRecord->getTimestamp(),
747 if ( $this->getAuthority()->isAllowed(
'undelete' ) &&
748 $this->currentRevRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() )
750 $dateLink = $this->getLinkRenderer()->makeKnownLink(
751 SpecialPage::getTitleFor(
'Undelete' ),
753 [
'class' =>
'mw-changeslist-date' ],
755 'target' => $this->currentPage->getPrefixedText(),
756 'timestamp' => $this->currentRevRecord->getTimestamp()
760 $dateLink = htmlspecialchars( $date );
762 if ( $this->currentRevRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
763 $class = Linker::getRevisionDeletedClass( $this->currentRevRecord );
764 $dateLink = Html::rawElement(
766 [
'class' => $class ],
771 $dateLink = ChangesList::revDateLink(
772 $this->currentRevRecord,
773 $this->getAuthority(),
774 $this->getLanguage(),
789 if ( !$this->currentPage || !$this->currentRevRecord ) {
793 if ( !$this->isArchive ) {
795 $this->currentRevRecord,
797 $row->{$this->revisionIdField} === $row->page_latest && !$row->page_is_new,
801 $this->getLinkRenderer()
803 if ( $row->{$this->revisionIdField} === $row->page_latest ) {
804 $topmarktext .=
'<span class="mw-uctop">' . $this->messages[
'uctop'] .
'</span>';
805 $classes[] =
'mw-contributions-current';
807 if ( $pagerTools->shouldPreventClickjacking() ) {
808 $this->setPreventClickjacking(
true );
810 $topmarktext .= $pagerTools->toHTML();
822 if ( $row->{$this->revisionParentIdField} === null ) {
826 $chardiff =
' <span class="mw-changeslist-separator"></span> ';
827 $chardiff .= Linker::formatRevisionSize( $row->{$this->revisionLengthField} );
828 $chardiff .=
' <span class="mw-changeslist-separator"></span> ';
831 if ( isset( $this->mParentLens[$row->{$this->revisionParentIdField}] ) ) {
832 $parentLen = $this->mParentLens[$row->{$this->revisionParentIdField}];
835 $chardiff =
' <span class="mw-changeslist-separator"></span> ';
838 $row->{$this->revisionLengthField},
841 $chardiff .=
' <span class="mw-changeslist-separator"></span> ';
853 $comment = $this->formattedComments[$row->{$this->revisionIdField}];
855 if ( $comment ===
'' ) {
856 $defaultComment = $this->messages[
'changeslist-nocomment'];
857 $comment =
"<span class=\"comment mw-comment-none\">$defaultComment</span>";
871 if ( !$this->currentRevRecord ) {
874 $dir = $this->getLanguage()->getDir();
878 $revUser = $this->currentRevRecord->getUser();
879 $revUserId = $revUser ? $revUser->getId() : 0;
880 $revUserText = $revUser ? $revUser->getName() :
'';
881 if ( $this->target !== $revUserText ) {
882 $userlink =
' <span class="mw-changeslist-separator"></span> '
883 . Html::rawElement(
'bdi', [
'dir' => $dir ],
884 Linker::userLink( $revUserId, $revUserText ) );
885 $userlink .=
' ' . $this->msg(
'parentheses' )->rawParams(
886 Linker::userTalkLink( $revUserId, $revUserText ) )->escaped() .
' ';
896 if ( !$this->currentRevRecord ) {
900 if ( $this->currentRevRecord->getParentId() === 0 ) {
901 $flags[] = ChangesList::flag(
'newpage' );
904 if ( $this->currentRevRecord->isMinor() ) {
905 $flags[] = ChangesList::flag(
'minor' );
917 if ( !$this->currentPage || !$this->currentRevRecord ) {
920 $del = Linker::getRevDeleteLink(
921 $this->getAuthority(),
922 $this->currentRevRecord,
937 # Tags, if any. Save some time using a cache.
938 [ $tagSummary, $newClasses ] = $this->tagsCache->getWithSetCallback(
939 $this->tagsCache->makeKey(
941 $this->getUser()->getName(),
942 $this->getLanguage()->getCode()
950 $classes = array_merge( $classes, $newClasses );
961 return $this->currentRevRecord->isDeleted( RevisionRecord::DELETED_USER );
981 $this->currentPage =
null;
982 $this->currentRevRecord =
null;
986 if ( isset( $row->{$this->pageNamespaceField} ) && isset( $row->{$this->pageTitleField} ) ) {
987 $this->currentPage = Title::makeTitle( $row->{$this->pageNamespaceField}, $row->{$this->pageTitleField} );
995 $this->currentRevRecord = $this->tryCreatingRevisionRecord( $row, $this->currentPage );
996 if ( $this->revisionsOnly || ( $this->currentRevRecord && $this->currentPage ) ) {
997 $this->populateAttributes( $row, $attribs );
999 $templateParams = $this->getTemplateParams( $row, $classes );
1000 $ret = $this->getProcessedTemplate( $templateParams );
1004 $lineEndingsHook = $this->isArchive ?
1005 'onDeletedContributionsLineEnding' :
1006 'onContributionsLineEnding';
1007 $this->hookRunner->$lineEndingsHook( $this, $ret, $row, $classes, $attribs );
1008 $attribs = array_filter( $attribs,
1009 [ Sanitizer::class,
'isReservedDataAttribute' ],
1010 ARRAY_FILTER_USE_KEY
1016 if ( $classes === [] && $attribs === [] && $ret ===
'' ) {
1017 wfDebug(
"Dropping ContributionsSpecialPage row that could not be formatted" );
1018 return "<!-- Could not format ContributionsSpecialPage row. -->\n";
1020 $attribs[
'class'] = $classes;
1024 return Html::rawElement(
'li', $attribs, $ret ) .
"\n";
1038 $link = $this->formatArticleLink( $row );
1039 $topmarktext = $this->formatTopMarkText( $row, $classes );
1040 $diffHistLinks = $this->formatDiffHistLinks( $row );
1041 $dateLink = $this->formatDateLink( $row );
1042 $chardiff = $this->formatCharDiff( $row );
1043 $comment = $this->formatComment( $row );
1044 $userlink = $this->formatUserLink( $row );
1045 $flags = $this->formatFlags( $row );
1046 $del = $this->formatVisibilityLink( $row );
1047 $tagSummary = $this->formatTags( $row, $classes );
1049 if ( !$this->isArchive ) {
1050 $this->hookRunner->onSpecialContributions__formatRow__flags(
1051 $this->getContext(), $row, $flags );
1056 'timestamp' => $dateLink,
1057 'diffHistLinks' => $diffHistLinks,
1058 'charDifference' => $chardiff,
1060 'articleLink' => $link,
1061 'userlink' => $userlink,
1062 'logText' => $comment,
1063 'topmarktext' => $topmarktext,
1064 'tagSummary' => $tagSummary,
1067 # Denote if username is redacted for this edit
1068 if ( $this->revisionUserIsDeleted( $row ) ) {
1069 $templateParams[
'rev-deleted-user-contribs'] =
1070 $this->msg(
'rev-deleted-user-contribs' )->escaped();
1073 return $templateParams;
1086 return $this->templateParser->processTemplate(
1087 'SpecialContributionsLine',
1097 if ( $this->
namespace || $this->deletedOnly ) {
1099 return 'contributions page filtered for namespace or RevisionDeleted edits';
1101 return 'contributions page unfiltered';
1109 $this->setPreventClickjacking(
true );
1117 $this->preventClickjacking = $enable;
1124 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.