74 parent::__construct(
'Recentchanges',
'' );
76 $services = MediaWikiServices::getInstance();
79 $this->messageCache =
$messageCache ?? $services->getMessageCache();
80 $this->loadBalancer =
$loadBalancer ?? $services->getDBLoadBalancer();
83 $this->watchlistFilterGroupDefinition = [
84 'name' =>
'watchlist',
85 'title' =>
'rcfilters-filtergroup-watchlist',
86 'class' => ChangesListStringOptionsFilterGroup::class,
88 'isFullCoverage' =>
true,
92 'label' =>
'rcfilters-filter-watchlist-watched-label',
93 'description' =>
'rcfilters-filter-watchlist-watched-description',
94 'cssClassSuffix' =>
'watched',
95 'isRowApplicableCallable' =>
function ( $ctx, $rc ) {
96 return $rc->getAttribute(
'wl_user' );
100 'name' =>
'watchednew',
101 'label' =>
'rcfilters-filter-watchlist-watchednew-label',
102 'description' =>
'rcfilters-filter-watchlist-watchednew-description',
103 'cssClassSuffix' =>
'watchednew',
104 'isRowApplicableCallable' =>
function ( $ctx, $rc ) {
105 return $rc->getAttribute(
'wl_user' ) &&
106 $rc->getAttribute(
'rc_timestamp' ) &&
107 $rc->getAttribute(
'wl_notificationtimestamp' ) &&
108 $rc->getAttribute(
'rc_timestamp' ) >= $rc->getAttribute(
'wl_notificationtimestamp' );
112 'name' =>
'notwatched',
113 'label' =>
'rcfilters-filter-watchlist-notwatched-label',
114 'description' =>
'rcfilters-filter-watchlist-notwatched-description',
115 'cssClassSuffix' =>
'notwatched',
116 'isRowApplicableCallable' =>
function ( $ctx, $rc ) {
117 return $rc->getAttribute(
'wl_user' ) ===
null;
122 'queryCallable' =>
function ( $specialPageClassName, $context,
IDatabase $dbr,
123 &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedValues ) {
124 sort( $selectedValues );
125 $notwatchedCond =
'wl_user IS NULL';
126 $watchedCond =
'wl_user IS NOT NULL';
127 if ( $this->
getConfig()->
get(
'WatchlistExpiry' ) ) {
130 $quotedNow =
$dbr->addQuotes(
$dbr->timestamp() );
131 $notwatchedCond =
"wl_user IS NULL OR ( we_expiry IS NOT NULL AND we_expiry < $quotedNow )";
132 $watchedCond =
"wl_user IS NOT NULL AND ( we_expiry IS NULL OR we_expiry >= $quotedNow )";
134 $newCond =
'rc_timestamp >= wl_notificationtimestamp';
136 if ( $selectedValues === [
'notwatched' ] ) {
137 $conds[] = $notwatchedCond;
141 if ( $selectedValues === [
'watched' ] ) {
142 $conds[] = $watchedCond;
146 if ( $selectedValues === [
'watchednew' ] ) {
147 $conds[] =
$dbr->makeList( [
154 if ( $selectedValues === [
'notwatched',
'watched' ] ) {
159 if ( $selectedValues === [
'notwatched',
'watchednew' ] ) {
160 $conds[] =
$dbr->makeList( [
170 if ( $selectedValues === [
'watched',
'watchednew' ] ) {
171 $conds[] = $watchedCond;
175 if ( $selectedValues === [
'notwatched',
'watched',
'watchednew' ] ) {
188 $feedFormat = $this->
getRequest()->getVal(
'feed' );
189 if ( !$this->
including() && $feedFormat ) {
191 $query[
'feedformat'] = $feedFormat ===
'atom' ?
'atom' :
'rss';
199 $out->setCdnMaxage( 10 );
202 if ( $lastmod ===
false ) {
207 'https://meta.wikimedia.org/wiki/Special:MyLanguage/Help:Recent_changes',
210 parent::execute( $subpage );
217 if ( isset( $filterDefinition[
'showHideSuffix'] ) ) {
218 $filterDefinition[
'showHide'] =
'rc' . $filterDefinition[
'showHideSuffix'];
221 return $filterDefinition;
232 && $this->
getUser()->isRegistered()
233 && $this->permissionManager->userHasRight( $this->
getUser(),
'viewmywatchlist' );
240 parent::registerFilters();
245 $watchlistGroup->getFilter(
'watched' )->setAsSupersetOf(
246 $watchlistGroup->getFilter(
'watchednew' )
254 $hideMinor = $significance->getFilter(
'hideminor' );
255 '@phan-var ChangesListBooleanFilter $hideMinor';
256 $hideMinor->setDefault( $this->userOptionsLookup->getBoolOption( $user,
'hideminor' ) );
260 $hideBots = $automated->getFilter(
'hidebots' );
261 '@phan-var ChangesListBooleanFilter $hideBots';
262 $hideBots->setDefault(
true );
266 '@phan-var ChangesListStringOptionsFilterGroup|null $reviewStatus';
267 if ( $reviewStatus !==
null ) {
269 if ( $this->userOptionsLookup->getBoolOption( $user,
'hidepatrolled' ) ) {
270 $reviewStatus->setDefault(
'unpatrolled' );
271 $legacyReviewStatus = $this->
getFilterGroup(
'legacyReviewStatus' );
273 $legacyHidePatrolled = $legacyReviewStatus->getFilter(
'hidepatrolled' );
274 '@phan-var ChangesListBooleanFilter $legacyHidePatrolled';
275 $legacyHidePatrolled->setDefault(
true );
281 $hideCategorization = $changeType->getFilter(
'hidecategorization' );
282 '@phan-var ChangesListBooleanFilter $hideCategorization';
283 if ( $hideCategorization !==
null ) {
285 $hideCategorization->setDefault( $this->userOptionsLookup->getBoolOption( $user,
'hidecategorization' ) );
296 parent::parseParameters( $par, $opts );
298 $bits = preg_split(
'/\s*,\s*/', trim( $par ) );
299 foreach ( $bits as $bit ) {
300 if ( is_numeric( $bit ) ) {
301 $opts[
'limit'] = $bit;
305 if ( preg_match(
'/^limit=(\d+)$/', $bit, $m ) ) {
306 $opts[
'limit'] = $m[1];
308 if ( preg_match(
'/^days=(\d+(?:\.\d+)?)$/', $bit, $m ) ) {
309 $opts[
'days'] = $m[1];
311 if ( preg_match(
'/^namespace=(.*)$/', $bit, $m ) ) {
312 $opts[
'namespace'] = $m[1];
314 if ( preg_match(
'/^tagfilter=(.*)$/', $bit, $m ) ) {
315 $opts[
'tagfilter'] = $m[1];
336 $tables[] =
'watchlist';
337 $fields[] =
'wl_user';
338 $fields[] =
'wl_notificationtimestamp';
339 $joinConds[
'watchlist'] = [
'LEFT JOIN', [
340 'wl_user' => $this->
getUser()->getId(),
342 'wl_namespace=rc_namespace'
346 if ( $this->
getConfig()->
get(
'WatchlistExpiry' ) ) {
347 $tables[] =
'watchlist_expiry';
348 $fields[] =
'we_expiry';
349 $joinConds[
'watchlist_expiry'] = [
'LEFT JOIN',
'wl_id = we_item' ];
356 protected function doMainQuery( $tables, $fields, $conds, $query_options,
362 $tables = array_merge( $tables, $rcQuery[
'tables'] );
363 $fields = array_merge( $rcQuery[
'fields'], $fields );
364 $join_conds = array_merge( $join_conds, $rcQuery[
'joins'] );
371 $fields[] =
'page_latest';
372 $join_conds[
'page'] = [
'LEFT JOIN',
'rc_cur_id=page_id' ];
374 $tagFilter = $opts[
'tagfilter'] ? explode(
'|', $opts[
'tagfilter'] ) : [];
384 if ( !$this->
runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
395 'ORDER BY' =>
'rc_timestamp DESC',
396 'LIMIT' => $opts[
'limit']
398 if ( in_array(
'DISTINCT', $query_options ) ) {
404 $orderByAndLimit[
'ORDER BY'] =
'rc_timestamp DESC, rc_id DESC';
405 $orderByAndLimit[
'GROUP BY'] =
'rc_timestamp, rc_id';
411 $query_options = array_merge( $orderByAndLimit, $query_options );
412 $rows =
$dbr->select(
417 $conds + [
'rc_new' => [ 0, 1 ] ],
440 $query = array_filter( $this->
getOptions()->getAllValues(),
function ( $value ) {
442 return $value !==
'';
444 $query[
'action'] =
'feedrecentchanges';
445 $feedLimit = $this->
getConfig()->get(
'FeedLimit' );
446 if ( $query[
'limit'] > $feedLimit ) {
447 $query[
'limit'] = $feedLimit;
460 $limit = $opts[
'limit'];
462 $showWatcherCount = $this->
getConfig()->get(
'RCShowWatchingUsers' )
463 && $this->userOptionsLookup->getBoolOption( $this->
getUser(),
'shownumberswatching' );
468 $list->initChangesListRows( $rows );
470 $userShowHiddenCats = $this->userOptionsLookup->getBoolOption( $this->
getUser(),
'showhiddencats' );
471 $rclistOutput = $list->beginRecentChangesList();
476 foreach ( $rows as $obj ) {
482 # Skip CatWatch entries for hidden cats based on user preference
485 !$userShowHiddenCats &&
486 $rc->getParam(
'hidden-cat' )
491 $rc->counter = $counter++;
492 # Check if the page has been updated since the last visit
493 if ( $this->
getConfig()->
get(
'ShowUpdatedMarker' )
494 && !empty( $obj->wl_notificationtimestamp )
496 $rc->notificationtimestamp = ( $obj->rc_timestamp >= $obj->wl_notificationtimestamp );
498 $rc->notificationtimestamp =
false;
500 # Check the number of users watching the page
501 $rc->numberofWatchingusers = 0;
502 if ( $showWatcherCount && $obj->rc_namespace >= 0 ) {
503 if ( !isset( $watcherCache[$obj->rc_namespace][$obj->rc_title] ) ) {
504 $watcherCache[$obj->rc_namespace][$obj->rc_title] =
505 $this->watchedItemStore->countWatchers(
506 new TitleValue( (
int)$obj->rc_namespace, $obj->rc_title )
509 $rc->numberofWatchingusers = $watcherCache[$obj->rc_namespace][$obj->rc_title];
512 $watched = !empty( $obj->wl_user );
513 if ( $watched && $this->
getConfig()->
get(
'WatchlistExpiry' ) ) {
514 $notExpired = $obj->we_expiry ===
null
515 || MWTimestamp::convert( TS_UNIX, $obj->we_expiry ) >
wfTimestamp();
516 $watched = $watched && $notExpired;
518 $changeLine = $list->recentChangesLine( $rc, $watched, $counter );
519 if ( $changeLine !==
false ) {
520 $rclistOutput .= $changeLine;
524 $rclistOutput .= $list->endRecentChangesList();
526 if ( $rows->numRows() === 0 ) {
529 $this->
getOutput()->setStatusCode( 404 );
532 $this->
getOutput()->addHTML( $rclistOutput );
545 $defaults = $opts->getAllValues();
546 $nondefaults = $opts->getChangedValues();
552 $panel[] = $this->
optionsPanel( $defaults, $nondefaults, $numRows );
556 $extraOptsCount = count( $extraOpts );
560 $out =
Xml::openElement(
'table', [
'class' =>
'mw-recentchanges-table' ] );
561 foreach ( $extraOpts as $name => $optionRow ) {
562 # Add submit button to the last row only
564 $addSubmit = ( $count === $extraOptsCount ) ? $submit :
'';
567 if ( is_array( $optionRow ) ) {
570 [
'class' =>
'mw-label mw-' . $name .
'-label' ],
575 [
'class' =>
'mw-input' ],
576 $optionRow[1] . $addSubmit
581 [
'class' =>
'mw-input',
'colspan' => 2 ],
582 $optionRow . $addSubmit
589 $unconsumed = $opts->getUnconsumedValues();
590 foreach ( $unconsumed as $key => $value ) {
598 $panelString = implode(
"\n", $panel );
601 $this->
msg(
'recentchanges-legend' )->text(),
603 [
'class' =>
'rcoptions cloptions' ]
611 [
'class' =>
'rcfilters-container mw-rcfilters-container' ]
616 [
'class' =>
'mw-rcfilters-spinner' ],
619 [
'class' =>
'mw-rcfilters-spinner-bounce' ]
628 [
'class' =>
'rcfilters-head mw-rcfilters-head' ],
629 $rcfilterContainer . $rcoptions
634 $this->
getOutput()->addHTML( $loadingContainer );
636 $this->
getOutput()->addHTML( $rcoptions );
648 $message = $this->
msg(
'recentchangestext' )->inContentLanguage();
649 if ( !$message->isDisabled() ) {
654 $parserOutput = $this->messageCache->parse(
656 $this->getPageTitle(),
663 $content = $parserOutput->getText( [
664 'enableSectionEditLinks' =>
false,
667 $this->
getOutput()->addParserOutputMetadata( $parserOutput );
670 'lang' => $contLang->getHtmlCode(),
671 'dir' => $contLang->getDir(),
674 $topLinksAttributes = [
'class' =>
'mw-recentchanges-toplinks' ];
678 $collapsedState = $this->
getRequest()->getCookie(
'rcfilters-toplinks-collapsed-state' );
680 $topLinksAttributes[
'class' ] .= $collapsedState !==
'expanded' ?
681 ' mw-recentchanges-toplinks-collapsed' :
'';
684 $contentTitle =
new OOUI\ButtonWidget( [
685 'classes' => [
'mw-recentchanges-toplinks-title' ],
686 'label' =>
new OOUI\HtmlSnippet( $this->
msg(
'rcfilters-other-review-tools' )->parse() ),
688 'indicator' => $collapsedState !==
'expanded' ?
'down' :
'up',
689 'flags' => [
'progressive' ],
694 [
'class' =>
'mw-recentchanges-toplinks-content mw-collapsible-content' ],
699 $content = $contentTitle . $contentWrapper;
705 $topLinksAttributes = array_merge( $topLinksAttributes, $langAttributes );
721 $opts->consumeValues( [
722 'namespace',
'invert',
'associated',
'tagfilter'
729 $opts[
'tagfilter'],
false, $this->
getContext() );
730 if ( count( $tagFilter ) ) {
731 $extraOpts[
'tagfilter'] = $tagFilter;
735 if ( $this->
getName() ===
'Recentchanges' ) {
736 $this->
getHookRunner()->onSpecialRecentChangesPanel( $extraOpts, $opts );
746 parent::addModules();
748 $out->addModules(
'mediawiki.special.recentchanges' );
760 $lastmod =
$dbr->selectField(
'recentchanges',
'MAX(rc_timestamp)',
'', __METHOD__ );
773 [
'selected' => $opts[
'namespace'],
'all' =>
'',
'in-user-lang' =>
true ],
774 [
'name' =>
'namespace',
'id' =>
'namespace' ]
776 $nsLabel =
Xml::label( $this->
msg(
'namespace' )->text(),
'namespace' );
777 $attribs = [
'class' => [
'mw-input-with-label' ] ];
779 if ( $opts[
'namespace'] ===
'' ) {
780 $attribs[
'class'][] =
'mw-input-hidden';
783 $this->
msg(
'invert' )->text(),
'invert',
'nsinvert',
785 [
'title' => $this->
msg(
'tooltip-invert' )->text() ]
788 $this->
msg(
'namespace_association' )->text(),
'associated',
'nsassociated',
790 [
'title' => $this->
msg(
'tooltip-namespace_association' )->text() ]
793 return [ $nsLabel,
"$nsSelect $invert $associated" ];
815 'data-params' => json_encode( $override ),
816 'data-keys' => implode(
',', array_keys( $override ) ),
829 $options = $nondefaults + $defaults;
832 $msg = $this->
msg(
'rclegend' );
833 if ( !$msg->isDisabled() ) {
836 [
'class' =>
'mw-rclegend' ],
844 if ( $options[
'from'] ) {
846 [
'from' =>
'' ], $nondefaults );
848 $noteFromMsg = $this->
msg(
'rcnotefrom' )
849 ->numParams( $options[
'limit'] )
851 $lang->userTimeAndDate( $options[
'from'], $user ),
852 $lang->userDate( $options[
'from'], $user ),
853 $lang->userTime( $options[
'from'], $user )
855 ->numParams( $numRows );
858 [
'class' =>
'rcnotefrom' ],
859 $noteFromMsg->parse()
864 [
'class' =>
'rcoptions-listfromreset' ],
865 $this->
msg(
'parentheses' )->rawParams( $resetLink )->parse()
870 # Sort data for display and make sure it's unique after we've added user data.
871 $linkLimits = $config->get(
'RCLinkLimits' );
872 $linkLimits[] = $options[
'limit'];
874 $linkLimits = array_unique( $linkLimits );
877 $linkDays[] = $options[
'days'];
879 $linkDays = array_unique( $linkDays );
883 foreach ( $linkLimits as $value ) {
885 [
'limit' => $value ], $nondefaults, $value == $options[
'limit'] );
887 $cl =
$lang->pipeList( $cl );
891 foreach ( $linkDays as $value ) {
893 [
'days' => $value,
'from' =>
'' ], $nondefaults, $value == $options[
'days'] );
895 $dl =
$lang->pipeList( $dl );
897 $showhide = [
'show',
'hide' ];
902 $msg = $filter->getShowHide();
903 $linkMessage = $this->
msg( $msg .
'-' . $showhide[1 - $options[$key]] );
906 if ( !$linkMessage->exists() ) {
907 $linkMessage = $this->
msg( $showhide[1 - $options[$key]] );
911 [ $key => 1 - $options[$key] ], $nondefaults );
914 'class' =>
"$msg rcshowhideoption clshowhideoption",
915 'data-filter-name' => $filter->getName(),
918 if ( $filter->isFeatureAvailableOnStructuredUi() ) {
919 $attribs[
'data-feature-in-structured-ui'] =
true;
925 $this->
msg( $msg )->rawParams( $link )->parse()
931 $now =
$lang->userTimeAndDate( $timestamp, $user );
932 $timenow =
$lang->userTime( $timestamp, $user );
933 $datenow =
$lang->userDate( $timestamp, $user );
934 $pipedLinks =
'<span class="rcshowhide">' .
$lang->pipeList( $links ) .
'</span>';
938 [
'class' =>
'rclinks' ],
939 $this->
msg(
'rclinks' )->rawParams( $cl, $dl,
'' )->parse()
944 [
'class' =>
'rclistfrom' ],
946 $this->
msg(
'rclistfrom' )->plaintextParams( $now, $timenow, $datenow )->text(),
947 [
'from' => $timestamp,
'fromFormatted' => $now ],
952 return "{$note}$rclinks<br />$pipedLinks<br />$rclistfrom";
964 $systemPrefValue = $this->userOptionsLookup->getIntOption( $this->
getUser(),
'rclimit' );
967 return $this->userOptionsLookup->getIntOption(
968 $this->
getUser(), static::$limitPreferenceName, $systemPrefValue
973 return $systemPrefValue;