3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
21 namespace MediaWiki\SpecialPage
;
23 use ChangesListBooleanFilter
;
24 use ChangesListBooleanFilterGroup
;
25 use ChangesListFilterGroup
;
26 use ChangesListStringOptionsFilterGroup
;
28 use MediaWiki\Context\IContextSource
;
29 use MediaWiki\Html\FormOptions
;
30 use MediaWiki\Html\Html
;
31 use MediaWiki\Json\FormatJson
;
32 use MediaWiki\MainConfigNames
;
33 use MediaWiki\MediaWikiServices
;
34 use MediaWiki\Parser\Sanitizer
;
35 use MediaWiki\ResourceLoader
as RL
;
36 use MediaWiki\User\TempUser\TempUserConfig
;
37 use MediaWiki\User\UserArray
;
38 use MediaWiki\User\UserIdentity
;
39 use MediaWiki\User\UserIdentityUtils
;
40 use MWExceptionHandler
;
43 use Wikimedia\Rdbms\DBQueryTimeoutError
;
44 use Wikimedia\Rdbms\FakeResultWrapper
;
45 use Wikimedia\Rdbms\IExpression
;
46 use Wikimedia\Rdbms\IReadableDatabase
;
47 use Wikimedia\Rdbms\IResultWrapper
;
48 use Wikimedia\Rdbms\RawSQLValue
;
49 use Wikimedia\Timestamp\ConvertibleTimestamp
;
52 * Special page which uses a ChangesList to show query results.
54 * @todo Most of the functions here should be protected instead of public.
56 * @ingroup RecentChanges
57 * @ingroup SpecialPage
59 abstract class ChangesListSpecialPage
extends SpecialPage
{
64 /** @var FormOptions */
67 protected UserIdentityUtils
$userIdentityUtils;
68 protected TempUserConfig
$tempUserConfig;
70 // Order of both groups and filters is significant; first is top-most priority,
71 // descending from there.
72 // 'showHideSuffix' is a shortcut to and avoid spelling out
73 // details specific to subclasses here.
75 * Definition information for the filters and their groups
77 * The value is $groupDefinition, a parameter to the ChangesListFilterGroup constructor.
78 * However, priority is dynamically added for the core groups, to ease maintenance.
80 * Groups are displayed to the user in the structured UI. However, if necessary,
81 * all of the filters in a group can be configured to only display on the
82 * unstuctured UI, in which case you don't need a group title.
86 private $filterGroupDefinitions;
89 * @var array Same format as filterGroupDefinitions, but for a single group (reviewStatus)
90 * that is registered conditionally.
92 private $legacyReviewStatusFilterGroupDefinition;
94 /** @var array Single filter group registered conditionally */
95 private $reviewStatusFilterGroupDefinition;
97 /** @var array Single filter group registered conditionally */
98 private $hideCategorizationFilterDefinition;
101 * Filter groups, and their contained filters
102 * This is an associative array (with group name as key) of ChangesListFilterGroup objects.
104 * @var ChangesListFilterGroup[]
106 protected $filterGroups = [];
109 * @param string $name
110 * @param string $restriction
111 * @param UserIdentityUtils $userIdentityUtils
112 * @param TempUserConfig $tempUserConfig
114 public function __construct(
117 UserIdentityUtils
$userIdentityUtils,
118 TempUserConfig
$tempUserConfig
120 parent
::__construct( $name, $restriction );
122 $this->userIdentityUtils
= $userIdentityUtils;
123 $this->tempUserConfig
= $tempUserConfig;
125 $nonRevisionTypes = [ RC_LOG
];
126 $this->getHookRunner()->onSpecialWatchlistGetNonRevisionTypes( $nonRevisionTypes );
128 $this->filterGroupDefinitions
= [
130 'name' => 'registration',
131 'title' => 'rcfilters-filtergroup-registration',
132 'class' => ChangesListBooleanFilterGroup
::class,
136 // rcshowhideliu-show, rcshowhideliu-hide,
138 'showHideSuffix' => 'showhideliu',
140 'queryCallable' => function ( string $specialClassName, IContextSource
$ctx,
141 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
143 $conds[] = $this->getRegisteredExpr( false, $dbr );
144 $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
146 'isReplacedInStructuredUi' => true,
150 'name' => 'hideanons',
151 // rcshowhideanons-show, rcshowhideanons-hide,
153 'showHideSuffix' => 'showhideanons',
155 'queryCallable' => function ( string $specialClassName, IContextSource
$ctx,
156 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
158 $conds[] = $this->getRegisteredExpr( true, $dbr );
159 $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
161 'isReplacedInStructuredUi' => true,
167 'name' => 'userExpLevel',
168 'title' => 'rcfilters-filtergroup-user-experience-level',
169 'class' => ChangesListStringOptionsFilterGroup
::class,
170 'isFullCoverage' => true,
173 'name' => 'unregistered',
174 'label' => 'rcfilters-filter-user-experience-level-unregistered-label',
175 'description' => $this->tempUserConfig
->isKnown() ?
176 'rcfilters-filter-user-experience-level-unregistered-description-temp' :
177 'rcfilters-filter-user-experience-level-unregistered-description',
178 'cssClassSuffix' => 'user-unregistered',
179 'isRowApplicableCallable' => function ( IContextSource
$ctx, RecentChange
$rc ) {
180 return !$this->userIdentityUtils
->isNamed( $rc->getPerformerIdentity() );
184 'name' => 'registered',
185 'label' => 'rcfilters-filter-user-experience-level-registered-label',
186 'description' => 'rcfilters-filter-user-experience-level-registered-description',
187 'cssClassSuffix' => 'user-registered',
188 'isRowApplicableCallable' => function ( IContextSource
$ctx, RecentChange
$rc ) {
189 return $this->userIdentityUtils
->isNamed( $rc->getPerformerIdentity() );
193 'name' => 'newcomer',
194 'label' => 'rcfilters-filter-user-experience-level-newcomer-label',
195 'description' => 'rcfilters-filter-user-experience-level-newcomer-description',
196 'cssClassSuffix' => 'user-newcomer',
197 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
198 $performer = $rc->getPerformerIdentity();
199 return $performer->isRegistered() &&
200 MediaWikiServices
::getInstance()
202 ->newFromUserIdentity( $performer )
203 ->getExperienceLevel() === 'newcomer';
208 'label' => 'rcfilters-filter-user-experience-level-learner-label',
209 'description' => 'rcfilters-filter-user-experience-level-learner-description',
210 'cssClassSuffix' => 'user-learner',
211 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
212 $performer = $rc->getPerformerIdentity();
213 return $performer->isRegistered() &&
214 MediaWikiServices
::getInstance()
216 ->newFromUserIdentity( $performer )
217 ->getExperienceLevel() === 'learner';
221 'name' => 'experienced',
222 'label' => 'rcfilters-filter-user-experience-level-experienced-label',
223 'description' => 'rcfilters-filter-user-experience-level-experienced-description',
224 'cssClassSuffix' => 'user-experienced',
225 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
226 $performer = $rc->getPerformerIdentity();
227 return $performer->isRegistered() &&
228 MediaWikiServices
::getInstance()
230 ->newFromUserIdentity( $performer )
231 ->getExperienceLevel() === 'experienced';
235 'default' => ChangesListStringOptionsFilterGroup
::NONE
,
236 'queryCallable' => [ $this, 'filterOnUserExperienceLevel' ],
240 'name' => 'authorship',
241 'title' => 'rcfilters-filtergroup-authorship',
242 'class' => ChangesListBooleanFilterGroup
::class,
245 'name' => 'hidemyself',
246 'label' => 'rcfilters-filter-editsbyself-label',
247 'description' => 'rcfilters-filter-editsbyself-description',
248 // rcshowhidemine-show, rcshowhidemine-hide,
250 'showHideSuffix' => 'showhidemine',
252 'queryCallable' => static function ( string $specialClassName, IContextSource
$ctx,
253 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
255 $user = $ctx->getUser();
256 $conds[] = $dbr->expr( 'actor_name', '!=', $user->getName() );
257 $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
259 'cssClassSuffix' => 'self',
260 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
261 return $ctx->getUser()->equals( $rc->getPerformerIdentity() );
265 'name' => 'hidebyothers',
266 'label' => 'rcfilters-filter-editsbyother-label',
267 'description' => 'rcfilters-filter-editsbyother-description',
269 'queryCallable' => static function ( string $specialClassName, IContextSource
$ctx,
270 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
272 $user = $ctx->getUser();
273 if ( $user->isAnon() ) {
274 $conds['actor_name'] = $user->getName();
276 $conds['actor_user'] = $user->getId();
278 $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
280 'cssClassSuffix' => 'others',
281 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
282 return !$ctx->getUser()->equals( $rc->getPerformerIdentity() );
289 'name' => 'automated',
290 'title' => 'rcfilters-filtergroup-automated',
291 'class' => ChangesListBooleanFilterGroup
::class,
294 'name' => 'hidebots',
295 'label' => 'rcfilters-filter-bots-label',
296 'description' => 'rcfilters-filter-bots-description',
297 // rcshowhidebots-show, rcshowhidebots-hide,
299 'showHideSuffix' => 'showhidebots',
301 'queryCallable' => static function ( string $specialClassName, IContextSource
$ctx,
302 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
304 $conds['rc_bot'] = 0;
306 'cssClassSuffix' => 'bot',
307 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
308 return $rc->getAttribute( 'rc_bot' );
312 'name' => 'hidehumans',
313 'label' => 'rcfilters-filter-humans-label',
314 'description' => 'rcfilters-filter-humans-description',
316 'queryCallable' => static function ( string $specialClassName, IContextSource
$ctx,
317 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
319 $conds['rc_bot'] = 1;
321 'cssClassSuffix' => 'human',
322 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
323 return !$rc->getAttribute( 'rc_bot' );
329 // significance (conditional)
332 'name' => 'significance',
333 'title' => 'rcfilters-filtergroup-significance',
334 'class' => ChangesListBooleanFilterGroup
::class,
338 'name' => 'hideminor',
339 'label' => 'rcfilters-filter-minor-label',
340 'description' => 'rcfilters-filter-minor-description',
341 // rcshowhideminor-show, rcshowhideminor-hide,
343 'showHideSuffix' => 'showhideminor',
345 'queryCallable' => static function ( string $specialClassName, IContextSource
$ctx,
346 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
348 $conds[] = $dbr->expr( 'rc_minor', '=', 0 );
350 'cssClassSuffix' => 'minor',
351 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
352 return $rc->getAttribute( 'rc_minor' );
356 'name' => 'hidemajor',
357 'label' => 'rcfilters-filter-major-label',
358 'description' => 'rcfilters-filter-major-description',
360 'queryCallable' => static function ( string $specialClassName, IContextSource
$ctx,
361 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
363 $conds[] = $dbr->expr( 'rc_minor', '=', 1 );
365 'cssClassSuffix' => 'major',
366 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
367 return !$rc->getAttribute( 'rc_minor' );
374 'name' => 'lastRevision',
375 'title' => 'rcfilters-filtergroup-lastrevision',
376 'class' => ChangesListBooleanFilterGroup
::class,
380 'name' => 'hidelastrevision',
381 'label' => 'rcfilters-filter-lastrevision-label',
382 'description' => 'rcfilters-filter-lastrevision-description',
384 'queryCallable' => static function ( string $specialClassName, IContextSource
$ctx,
385 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
386 ) use ( $nonRevisionTypes ) {
387 $conds[] = $dbr->expr( 'rc_this_oldid', '!=', new RawSQLValue( 'page_latest' ) )
388 ->or( 'rc_type', '=', $nonRevisionTypes );
390 'cssClassSuffix' => 'last',
391 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
392 return $rc->getAttribute( 'rc_this_oldid' ) === $rc->getAttribute( 'page_latest' );
396 'name' => 'hidepreviousrevisions',
397 'label' => 'rcfilters-filter-previousrevision-label',
398 'description' => 'rcfilters-filter-previousrevision-description',
400 'queryCallable' => static function ( string $specialClassName, IContextSource
$ctx,
401 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
402 ) use ( $nonRevisionTypes ) {
403 $conds[] = $dbr->expr( 'rc_this_oldid', '=', new RawSQLValue( 'page_latest' ) )
404 ->or( 'rc_type', '=', $nonRevisionTypes );
406 'cssClassSuffix' => 'previous',
407 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
408 return $rc->getAttribute( 'rc_this_oldid' ) !== $rc->getAttribute( 'page_latest' );
414 // With extensions, there can be change types that will not be hidden by any of these.
416 'name' => 'changeType',
417 'title' => 'rcfilters-filtergroup-changetype',
418 'class' => ChangesListBooleanFilterGroup
::class,
422 'name' => 'hidepageedits',
423 'label' => 'rcfilters-filter-pageedits-label',
424 'description' => 'rcfilters-filter-pageedits-description',
427 'queryCallable' => static function ( string $specialClassName, IContextSource
$ctx,
428 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
430 $conds[] = $dbr->expr( 'rc_type', '!=', RC_EDIT
);
432 'cssClassSuffix' => 'src-mw-edit',
433 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
434 return $rc->getAttribute( 'rc_source' ) === RecentChange
::SRC_EDIT
;
438 'name' => 'hidenewpages',
439 'label' => 'rcfilters-filter-newpages-label',
440 'description' => 'rcfilters-filter-newpages-description',
443 'queryCallable' => static function ( string $specialClassName, IContextSource
$ctx,
444 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
446 $conds[] = $dbr->expr( 'rc_type', '!=', RC_NEW
);
448 'cssClassSuffix' => 'src-mw-new',
449 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
450 return $rc->getAttribute( 'rc_source' ) === RecentChange
::SRC_NEW
;
454 // hidecategorization
458 'label' => 'rcfilters-filter-logactions-label',
459 'description' => 'rcfilters-filter-logactions-description',
462 'queryCallable' => static function ( string $specialClassName, IContextSource
$ctx,
463 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
465 $conds[] = $dbr->expr( 'rc_type', '!=', RC_LOG
);
467 'cssClassSuffix' => 'src-mw-log',
468 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
469 return $rc->getAttribute( 'rc_source' ) === RecentChange
::SRC_LOG
;
473 'name' => 'hidenewuserlog',
474 'label' => 'rcfilters-filter-accountcreations-label',
475 'description' => 'rcfilters-filter-accountcreations-description',
478 'queryCallable' => static function ( string $specialClassName, IContextSource
$ctx,
479 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
481 $conds[] = $dbr->expr( 'rc_log_type', '!=', 'newusers' )
482 ->or( 'rc_log_type', '=', null );
484 'cssClassSuffix' => 'src-mw-newuserlog',
485 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
486 return $rc->getAttribute( 'rc_log_type' ) === "newusers";
494 $this->legacyReviewStatusFilterGroupDefinition
= [
496 'name' => 'legacyReviewStatus',
497 'title' => 'rcfilters-filtergroup-reviewstatus',
498 'class' => ChangesListBooleanFilterGroup
::class,
501 'name' => 'hidepatrolled',
502 // rcshowhidepatr-show, rcshowhidepatr-hide
504 'showHideSuffix' => 'showhidepatr',
506 'queryCallable' => static function ( string $specialClassName, IContextSource
$ctx,
507 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
509 $conds['rc_patrolled'] = RecentChange
::PRC_UNPATROLLED
;
511 'isReplacedInStructuredUi' => true,
514 'name' => 'hideunpatrolled',
516 'queryCallable' => static function ( string $specialClassName, IContextSource
$ctx,
517 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
519 $conds[] = $dbr->expr( 'rc_patrolled', '!=', RecentChange
::PRC_UNPATROLLED
);
521 'isReplacedInStructuredUi' => true,
527 $this->reviewStatusFilterGroupDefinition
= [
529 'name' => 'reviewStatus',
530 'title' => 'rcfilters-filtergroup-reviewstatus',
531 'class' => ChangesListStringOptionsFilterGroup
::class,
532 'isFullCoverage' => true,
536 'name' => 'unpatrolled',
537 'label' => 'rcfilters-filter-reviewstatus-unpatrolled-label',
538 'description' => 'rcfilters-filter-reviewstatus-unpatrolled-description',
539 'cssClassSuffix' => 'reviewstatus-unpatrolled',
540 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
541 return $rc->getAttribute( 'rc_patrolled' ) == RecentChange
::PRC_UNPATROLLED
;
546 'label' => 'rcfilters-filter-reviewstatus-manual-label',
547 'description' => 'rcfilters-filter-reviewstatus-manual-description',
548 'cssClassSuffix' => 'reviewstatus-manual',
549 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
550 return $rc->getAttribute( 'rc_patrolled' ) == RecentChange
::PRC_PATROLLED
;
555 'label' => 'rcfilters-filter-reviewstatus-auto-label',
556 'description' => 'rcfilters-filter-reviewstatus-auto-description',
557 'cssClassSuffix' => 'reviewstatus-auto',
558 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
559 return $rc->getAttribute( 'rc_patrolled' ) == RecentChange
::PRC_AUTOPATROLLED
;
563 'default' => ChangesListStringOptionsFilterGroup
::NONE
,
564 'queryCallable' => static function ( string $specialClassName, IContextSource
$ctx,
565 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selected
567 if ( $selected === [] ) {
570 $rcPatrolledValues = [
571 'unpatrolled' => RecentChange
::PRC_UNPATROLLED
,
572 'manual' => RecentChange
::PRC_PATROLLED
,
573 'auto' => RecentChange
::PRC_AUTOPATROLLED
,
575 // e.g. rc_patrolled IN (0, 2)
576 $conds['rc_patrolled'] = array_map( static function ( $s ) use ( $rcPatrolledValues ) {
577 return $rcPatrolledValues[ $s ];
583 $this->hideCategorizationFilterDefinition
= [
584 'name' => 'hidecategorization',
585 'label' => 'rcfilters-filter-categorization-label',
586 'description' => 'rcfilters-filter-categorization-description',
587 // rcshowhidecategorization-show, rcshowhidecategorization-hide.
588 // wlshowhidecategorization
589 'showHideSuffix' => 'showhidecategorization',
592 'queryCallable' => static function ( string $specialClassName, IContextSource
$ctx,
593 IReadableDatabase
$dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
595 $conds[] = $dbr->expr( 'rc_type', '!=', RC_CATEGORIZE
);
597 'cssClassSuffix' => 'src-mw-categorize',
598 'isRowApplicableCallable' => static function ( IContextSource
$ctx, RecentChange
$rc ) {
599 return $rc->getAttribute( 'rc_source' ) === RecentChange
::SRC_CATEGORIZE
;
605 * Removes registration filters from filterGroupDefinitions
608 private function removeRegistrationFilterDefinitions(): void
{
609 foreach ( $this->filterGroupDefinitions
as $key => $value ) {
610 if ( $value['name'] == "userExpLevel" ) {
611 $this->filterGroupDefinitions
[ $key ][ 'filters' ] = array_filter(
612 $this->filterGroupDefinitions
[ $key ][ 'filters' ],
613 fn ( $val, $key ) => $val[ 'name' ] != 'registered'
614 && $val[ 'name' ] != 'unregistered', ARRAY_FILTER_USE_BOTH
);
620 * Check if filters are in conflict and guaranteed to return no results.
624 protected function areFiltersInConflict() {
625 $opts = $this->getOptions();
626 foreach ( $this->getFilterGroups() as $group ) {
627 if ( $group->getConflictingGroups() ) {
630 " specifies conflicts with other groups but these are not supported yet."
634 foreach ( $group->getConflictingFilters() as $conflictingFilter ) {
635 if ( $conflictingFilter->activelyInConflictWithGroup( $group, $opts ) ) {
640 foreach ( $group->getFilters() as $filter ) {
641 foreach ( $filter->getConflictingFilters() as $conflictingFilter ) {
643 $conflictingFilter->activelyInConflictWithFilter( $filter, $opts ) &&
644 $filter->activelyInConflictWithFilter( $conflictingFilter, $opts )
658 * @param string|null $subpage
660 public function execute( $subpage ) {
661 $this->rcSubpage
= $subpage;
663 if ( $this->considerActionsForDefaultSavedQuery( $subpage ) ) {
664 // Don't bother rendering the page if we'll be performing a redirect (T330100).
668 // Enable OOUI and module for the clock icon.
669 if ( $this->getConfig()->get( MainConfigNames
::WatchlistExpiry
) && !$this->including() ) {
670 $this->getOutput()->enableOOUI();
671 $this->getOutput()->addModules( 'mediawiki.special.changeslist.watchlistexpiry' );
674 $opts = $this->getOptions();
676 $rows = $this->getRows();
677 if ( $rows === false ) {
678 $rows = new FakeResultWrapper( [] );
681 // Used by Structured UI app to get results without MW chrome
682 if ( $this->getRequest()->getRawVal( 'action' ) === 'render' ) {
683 $this->getOutput()->setArticleBodyOnly( true );
686 // Used by "live update" and "view newest" to check
687 // if there's new changes with minimal data transfer
688 if ( $this->getRequest()->getBool( 'peek' ) ) {
689 $code = $rows->numRows() > 0 ?
200 : 204;
690 $this->getOutput()->setStatusCode( $code );
692 if ( $this->getUser()->isAnon() !==
693 $this->getRequest()->getFuzzyBool( 'isAnon' )
695 $this->getOutput()->setStatusCode( 205 );
701 $services = MediaWikiServices
::getInstance();
702 $logFormatterFactory = $services->getLogFormatterFactory();
703 $linkBatchFactory = $services->getLinkBatchFactory();
704 $batch = $linkBatchFactory->newLinkBatch();
706 foreach ( $rows as $row ) {
707 $batch->add( NS_USER
, $row->rc_user_text
);
708 $batch->add( NS_USER_TALK
, $row->rc_user_text
);
709 $userNames[] = $row->rc_user_text
;
710 $batch->add( $row->rc_namespace
, $row->rc_title
);
711 if ( $row->rc_source
=== RecentChange
::SRC_LOG
) {
712 $formatter = $logFormatterFactory->newFromRow( $row );
713 foreach ( $formatter->getPreloadTitles() as $title ) {
714 $batch->addObj( $title );
715 if ( $title->inNamespace( NS_USER
) ||
$title->inNamespace( NS_USER_TALK
) ) {
716 $userNames[] = $title->getText();
722 foreach ( UserArray
::newFromNames( $userNames ) as $_ ) {
723 // Trigger UserEditTracker::setCachedUserEditCount via User::loadFromRow
724 // Preloads edit count for User::getExperienceLevel() and Linker::userToolLinks()
728 $this->outputHeader();
730 $this->webOutput( $rows, $opts );
733 } catch ( DBQueryTimeoutError
$timeoutException ) {
734 MWExceptionHandler
::logException( $timeoutException );
737 $this->outputHeader();
740 $this->getOutput()->setStatusCode( 500 );
741 $this->webOutputHeader( 0, $opts );
742 $this->outputTimeout();
745 $this->includeRcFiltersApp();
749 * Set the temp user config.
752 * @param TempUserConfig $tempUserConfig
755 public function setTempUserConfig( TempUserConfig
$tempUserConfig ) {
756 $this->tempUserConfig
= $tempUserConfig;
760 * Check whether or not the page should load defaults, and if so, whether
761 * a default saved query is relevant to be redirected to. If it is relevant,
762 * redirect properly with all necessary query parameters.
764 * @param string $subpage
765 * @return bool Whether a redirect will be performed.
767 protected function considerActionsForDefaultSavedQuery( $subpage ) {
768 if ( !$this->isStructuredFilterUiEnabled() ||
$this->including() ) {
772 $knownParams = $this->getRequest()->getValues(
773 ...array_keys( $this->getOptions()->getAllValues() )
776 // HACK: Temporarily until we can properly define "sticky" filters and parameters,
777 // we need to exclude several parameters we know should not be counted towards preventing
778 // the loading of defaults.
779 $excludedParams = [ 'limit' => '', 'days' => '', 'enhanced' => '', 'from' => '' ];
780 $knownParams = array_diff_key( $knownParams, $excludedParams );
783 // If there are NO known parameters in the URL request
784 // (that are not excluded) then we need to check into loading
785 // the default saved query
786 count( $knownParams ) === 0
788 $prefJson = MediaWikiServices
::getInstance()
789 ->getUserOptionsLookup()
790 ->getOption( $this->getUser(), $this->getSavedQueriesPreferenceName() );
792 // Get the saved queries data and parse it
793 $savedQueries = $prefJson ? FormatJson
::decode( $prefJson, true ) : false;
795 if ( $savedQueries && isset( $savedQueries[ 'default' ] ) ) {
796 // Only load queries that are 'version' 2, since those
797 // have parameter representation
798 if ( isset( $savedQueries[ 'version' ] ) && $savedQueries[ 'version' ] === '2' ) {
799 $savedQueryDefaultID = $savedQueries[ 'default' ];
800 $defaultQuery = $savedQueries[ 'queries' ][ $savedQueryDefaultID ][ 'data' ];
802 // Build the entire parameter list
803 $query = array_merge(
804 $defaultQuery[ 'params' ],
805 $defaultQuery[ 'highlights' ],
810 // Add to the query any parameters that we may have ignored before
811 // but are still valid and requested in the URL
812 $query = array_merge( $this->getRequest()->getQueryValues(), $query );
813 unset( $query[ 'title' ] );
814 $this->getOutput()->redirect( $this->getPageTitle( $subpage )->getCanonicalURL( $query ) );
816 // Signal that we only need to redirect to the full URL
817 // and can skip rendering the actual page (T330100).
820 // There's a default, but the version is not 2, and the server can't
821 // actually recognize the query itself. This happens if it is before
822 // the conversion, so we need to tell the UI to reload saved query as
823 // it does the conversion to version 2
824 $this->getOutput()->addJsConfigVars(
825 'wgStructuredChangeFiltersDefaultSavedQueryExists',
829 // Add the class that tells the frontend it is still loading
831 $this->getOutput()->addBodyClasses( 'mw-rcfilters-ui-loading' );
840 * @see \MediaWiki\MainConfigSchema::RCLinkDays and \MediaWiki\MainConfigSchema::RCFilterByAge.
843 protected function getLinkDays() {
844 $linkDays = $this->getConfig()->get( MainConfigNames
::RCLinkDays
);
845 $filterByAge = $this->getConfig()->get( MainConfigNames
::RCFilterByAge
);
846 $maxAge = $this->getConfig()->get( MainConfigNames
::RCMaxAge
);
847 if ( $filterByAge ) {
848 // Trim it to only links which are within $wgRCMaxAge.
849 // Note that we allow one link higher than the max for things like
850 // "age 56 days" being accessible through the "60 days" link.
853 $maxAgeDays = $maxAge / ( 3600 * 24 );
854 foreach ( $linkDays as $i => $days ) {
855 if ( $days >= $maxAgeDays ) {
856 array_splice( $linkDays, $i +
1 );
866 * Include the modules and configuration for the RCFilters app.
867 * Conditional on the user having the feature enabled.
869 * If it is disabled, add a <body> class marking that
871 protected function includeRcFiltersApp() {
872 $out = $this->getOutput();
873 if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
874 $jsData = $this->getStructuredFilterJsData();
876 foreach ( $jsData['messageKeys'] as $key ) {
877 $messages[$key] = $this->msg( $key )->plain();
880 $out->addBodyClasses( 'mw-rcfilters-enabled' );
881 $collapsed = MediaWikiServices
::getInstance()->getUserOptionsLookup()
882 ->getBoolOption( $this->getUser(), $this->getCollapsedPreferenceName() );
884 $out->addBodyClasses( 'mw-rcfilters-collapsed' );
887 // These config and message exports should be moved into a ResourceLoader data module (T201574)
888 $out->addJsConfigVars( 'wgStructuredChangeFilters', $jsData['groups'] );
889 $out->addJsConfigVars( 'wgStructuredChangeFiltersMessages', $messages );
890 $out->addJsConfigVars( 'wgStructuredChangeFiltersCollapsedState', $collapsed );
892 $out->addJsConfigVars(
893 'StructuredChangeFiltersDisplayConfig',
895 'maxDays' => // Translate to days
896 (int)$this->getConfig()->get( MainConfigNames
::RCMaxAge
) / ( 24 * 3600 ),
897 'limitArray' => $this->getConfig()->get( MainConfigNames
::RCLinkLimits
),
898 'limitDefault' => $this->getDefaultLimit(),
899 'daysArray' => $this->getLinkDays(),
900 'daysDefault' => $this->getDefaultDays(),
904 $out->addJsConfigVars(
905 'wgStructuredChangeFiltersSavedQueriesPreferenceName',
906 $this->getSavedQueriesPreferenceName()
908 $out->addJsConfigVars(
909 'wgStructuredChangeFiltersLimitPreferenceName',
910 $this->getLimitPreferenceName()
912 $out->addJsConfigVars(
913 'wgStructuredChangeFiltersDaysPreferenceName',
914 $this->getDefaultDaysPreferenceName()
916 $out->addJsConfigVars(
917 'wgStructuredChangeFiltersCollapsedPreferenceName',
918 $this->getCollapsedPreferenceName()
921 $out->addBodyClasses( 'mw-rcfilters-disabled' );
926 * Get essential data about getRcFiltersConfigVars() for change detection.
928 * @internal For use by Resources.php only.
929 * @see Module::getDefinitionSummary() and Module::getVersionHash()
930 * @param RL\Context $context
933 public static function getRcFiltersConfigSummary( RL\Context
$context ) {
934 $lang = MediaWikiServices
::getInstance()->getLanguageFactory()
935 ->getLanguage( $context->getLanguage() );
937 // Reduce version computation by avoiding Message parsing
938 'RCFiltersChangeTags' => ChangeTags
::getChangeTagListSummary( $context, $lang ),
939 'StructuredChangeFiltersEditWatchlistUrl' =>
940 SpecialPage
::getTitleFor( 'EditWatchlist' )->getLocalURL()
945 * Get config vars to export with the mediawiki.rcfilters.filters.ui module.
947 * @internal For use by Resources.php only.
948 * @param RL\Context $context
951 public static function getRcFiltersConfigVars( RL\Context
$context ) {
952 $lang = MediaWikiServices
::getInstance()->getLanguageFactory()
953 ->getLanguage( $context->getLanguage() );
955 'RCFiltersChangeTags' => ChangeTags
::getChangeTagList( $context, $lang ),
956 'StructuredChangeFiltersEditWatchlistUrl' =>
957 SpecialPage
::getTitleFor( 'EditWatchlist' )->getLocalURL()
962 * Add the "no results" message to the output
964 protected function outputNoResults() {
965 $this->getOutput()->addHTML(
968 [ 'class' => 'mw-changeslist-empty' ],
969 $this->msg( 'recentchanges-noresult' )->parse()
975 * Add the "timeout" message to the output
977 protected function outputTimeout() {
978 $this->getOutput()->addHTML(
979 '<div class="mw-changeslist-empty mw-changeslist-timeout">' .
980 $this->msg( 'recentchanges-timeout' )->parse() .
986 * Get the database result for this special page instance. Used by ApiFeedRecentChanges.
988 * @return IResultWrapper|false
990 public function getRows() {
991 $opts = $this->getOptions();
998 $this->buildQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
1000 return $this->doMainQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
1004 * Get the current FormOptions for this request
1006 * @return FormOptions
1008 public function getOptions() {
1009 if ( $this->rcOptions
=== null ) {
1010 $this->rcOptions
= $this->setup( $this->rcSubpage
);
1013 return $this->rcOptions
;
1017 * Register all filters and their groups (including those from hooks), plus handle
1018 * conflicts and defaults.
1020 * You might want to customize these in the same method, in subclasses. You can
1021 * call getFilterGroup to access a group, and (on the group) getFilter to access a
1022 * filter, then make necessary modfications to the filter or group (e.g. with
1025 protected function registerFilters() {
1026 $isRegistrationRequiredToEdit = !MediaWikiServices
::getInstance()
1027 ->getPermissionManager()
1028 ->isEveryoneAllowed( "edit" );
1029 if ( $isRegistrationRequiredToEdit ) {
1030 $this->removeRegistrationFilterDefinitions();
1032 $this->registerFiltersFromDefinitions( $this->filterGroupDefinitions
);
1034 // Make sure this is not being transcluded (we don't want to show this
1035 // information to all users just because the user that saves the edit can
1036 // patrol or is logged in)
1037 if ( !$this->including() && $this->getUser()->useRCPatrol() ) {
1038 $this->registerFiltersFromDefinitions( $this->legacyReviewStatusFilterGroupDefinition
);
1039 $this->registerFiltersFromDefinitions( $this->reviewStatusFilterGroupDefinition
);
1042 $changeTypeGroup = $this->getFilterGroup( 'changeType' );
1044 $categoryFilter = null;
1045 if ( $this->getConfig()->get( MainConfigNames
::RCWatchCategoryMembership
) ) {
1046 $transformedHideCategorizationDef = $this->transformFilterDefinition(
1047 $this->hideCategorizationFilterDefinition
1050 $transformedHideCategorizationDef['group'] = $changeTypeGroup;
1052 $categoryFilter = new ChangesListBooleanFilter(
1053 $transformedHideCategorizationDef
1057 $this->getHookRunner()->onChangesListSpecialPageStructuredFilters( $this );
1059 $this->registerFiltersFromDefinitions( [] );
1061 $userExperienceLevel = $this->getFilterGroup( 'userExpLevel' );
1062 if ( !$isRegistrationRequiredToEdit ) {
1063 $registered = $userExperienceLevel->getFilter( 'registered' );
1064 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'newcomer' ) );
1065 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'learner' ) );
1066 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'experienced' ) );
1069 $logactionsFilter = $changeTypeGroup->getFilter( 'hidelog' );
1070 $lognewuserFilter = $changeTypeGroup->getFilter( 'hidenewuserlog' );
1071 $pagecreationFilter = $changeTypeGroup->getFilter( 'hidenewpages' );
1073 $significanceTypeGroup = $this->getFilterGroup( 'significance' );
1074 $hideMinorFilter = $significanceTypeGroup->getFilter( 'hideminor' );
1076 if ( $categoryFilter !== null ) {
1077 $hideMinorFilter->conflictsWith(
1079 'rcfilters-hideminor-conflicts-typeofchange-global',
1080 'rcfilters-hideminor-conflicts-typeofchange',
1081 'rcfilters-typeofchange-conflicts-hideminor'
1084 $hideMinorFilter->conflictsWith(
1086 'rcfilters-hideminor-conflicts-typeofchange-global',
1087 'rcfilters-hideminor-conflicts-typeofchange',
1088 'rcfilters-typeofchange-conflicts-hideminor'
1090 $hideMinorFilter->conflictsWith(
1092 'rcfilters-hideminor-conflicts-typeofchange-global',
1093 'rcfilters-hideminor-conflicts-typeofchange',
1094 'rcfilters-typeofchange-conflicts-hideminor'
1096 $hideMinorFilter->conflictsWith(
1097 $pagecreationFilter,
1098 'rcfilters-hideminor-conflicts-typeofchange-global',
1099 'rcfilters-hideminor-conflicts-typeofchange',
1100 'rcfilters-typeofchange-conflicts-hideminor'
1105 * Transforms filter definition to prepare it for constructor.
1107 * See overrides of this method as well.
1109 * @param array $filterDefinition Original filter definition
1111 * @return array Transformed definition
1113 protected function transformFilterDefinition( array $filterDefinition ) {
1114 return $filterDefinition;
1118 * Register filters from a definition object
1120 * Array specifying groups and their filters; see Filter and
1121 * ChangesListFilterGroup constructors.
1123 * There is light processing to simplify core maintenance.
1124 * @param array $definition
1125 * @phan-param array<int,array{class:class-string<ChangesListFilterGroup>,filters:array}> $definition
1127 protected function registerFiltersFromDefinitions( array $definition ) {
1128 $autoFillPriority = -1;
1129 foreach ( $definition as $groupDefinition ) {
1130 if ( !isset( $groupDefinition['priority'] ) ) {
1131 $groupDefinition['priority'] = $autoFillPriority;
1133 // If it's explicitly specified, start over the auto-fill
1134 $autoFillPriority = $groupDefinition['priority'];
1137 $autoFillPriority--;
1139 $className = $groupDefinition['class'];
1140 unset( $groupDefinition['class'] );
1142 foreach ( $groupDefinition['filters'] as &$filterDefinition ) {
1143 $filterDefinition = $this->transformFilterDefinition( $filterDefinition );
1146 $this->registerFilterGroup( new $className( $groupDefinition ) );
1151 * @return ChangesListBooleanFilter[] The legacy show/hide toggle filters
1153 protected function getLegacyShowHideFilters() {
1155 foreach ( $this->filterGroups
as $group ) {
1156 if ( $group instanceof ChangesListBooleanFilterGroup
) {
1157 foreach ( $group->getFilters() as $key => $filter ) {
1158 if ( $filter->displaysOnUnstructuredUi() ) {
1159 $filters[ $key ] = $filter;
1168 * Register all the filters, including legacy hook-driven ones.
1169 * Then create a FormOptions object with options as specified by the user
1171 * @param string $parameters
1173 * @return FormOptions
1175 public function setup( $parameters ) {
1176 $this->registerFilters();
1178 $opts = $this->getDefaultOptions();
1180 $opts = $this->fetchOptionsFromRequest( $opts );
1182 // Give precedence to subpage syntax
1183 if ( $parameters !== null ) {
1184 $this->parseParameters( $parameters, $opts );
1187 $this->validateOptions( $opts );
1193 * Get a FormOptions object containing the default options. By default, returns
1194 * some basic options. The filters listed explicitly here are overridden in this
1195 * method, in subclasses, but most filters (e.g. hideminor, userExpLevel filters,
1196 * and more) are structured. Structured filters are overridden in registerFilters.
1199 * @return FormOptions
1201 public function getDefaultOptions() {
1202 $opts = new FormOptions();
1203 $structuredUI = $this->isStructuredFilterUiEnabled();
1204 // If urlversion=2 is set, ignore the filter defaults and set them all to false/empty
1205 $useDefaults = $this->getRequest()->getInt( 'urlversion' ) !== 2;
1207 /** @var ChangesListFilterGroup $filterGroup */
1208 foreach ( $this->filterGroups
as $filterGroup ) {
1209 $filterGroup->addOptions( $opts, $useDefaults, $structuredUI );
1212 $opts->add( 'namespace', '', FormOptions
::STRING );
1213 // TODO: Rename this option to 'invertnamespaces'?
1214 $opts->add( 'invert', false );
1215 $opts->add( 'associated', false );
1216 $opts->add( 'urlversion', 1 );
1217 $opts->add( 'tagfilter', '' );
1218 $opts->add( 'inverttags', false );
1220 $opts->add( 'days', $this->getDefaultDays(), FormOptions
::FLOAT );
1221 $opts->add( 'limit', $this->getDefaultLimit(), FormOptions
::INT );
1223 $opts->add( 'from', '' );
1229 * Register a structured changes list filter group
1231 * @param ChangesListFilterGroup $group
1233 public function registerFilterGroup( ChangesListFilterGroup
$group ) {
1234 $groupName = $group->getName();
1236 $this->filterGroups
[$groupName] = $group;
1240 * Gets the currently registered filters groups
1242 * @return ChangesListFilterGroup[] Associative array of ChangesListFilterGroup objects, with group name as key
1244 protected function getFilterGroups() {
1245 return $this->filterGroups
;
1249 * Gets a specified ChangesListFilterGroup by name
1251 * @param string $groupName Name of group
1253 * @return ChangesListFilterGroup|null Group, or null if not registered
1255 public function getFilterGroup( $groupName ) {
1256 return $this->filterGroups
[$groupName] ??
null;
1259 // Currently, this intentionally only includes filters that display
1260 // in the structured UI. This can be changed easily, though, if we want
1261 // to include data on filters that use the unstructured UI. messageKeys is a
1262 // special top-level value, with the value being an array of the message keys to
1263 // send to the client.
1266 * Gets structured filter information needed by JS
1268 * @return array Associative array
1269 * * array $return['groups'] Group data
1270 * * array $return['messageKeys'] Array of message keys
1272 public function getStructuredFilterJsData() {
1275 'messageKeys' => [],
1278 usort( $this->filterGroups
, static function ( ChangesListFilterGroup
$a, ChangesListFilterGroup
$b ) {
1279 return $b->getPriority() <=> $a->getPriority();
1282 foreach ( $this->filterGroups
as $group ) {
1283 $groupOutput = $group->getJsData();
1284 if ( $groupOutput !== null ) {
1285 $output['messageKeys'] = array_merge(
1286 $output['messageKeys'],
1287 $groupOutput['messageKeys']
1290 unset( $groupOutput['messageKeys'] );
1291 $output['groups'][] = $groupOutput;
1299 * Fetch values for a FormOptions object from the WebRequest associated with this instance.
1301 * Intended for subclassing, e.g. to add a backwards-compatibility layer.
1303 * @param FormOptions $opts
1304 * @return FormOptions
1306 protected function fetchOptionsFromRequest( $opts ) {
1307 $opts->fetchValuesFromRequest( $this->getRequest() );
1313 * Process $par and put options found in $opts. Used when including the page.
1315 * @param string $par
1316 * @param FormOptions $opts
1318 public function parseParameters( $par, FormOptions
$opts ) {
1319 $stringParameterNameSet = [];
1320 $hideParameterNameSet = [];
1322 // URL parameters can be per-group, like 'userExpLevel',
1323 // or per-filter, like 'hideminor'.
1325 foreach ( $this->filterGroups
as $filterGroup ) {
1326 if ( $filterGroup instanceof ChangesListStringOptionsFilterGroup
) {
1327 $stringParameterNameSet[$filterGroup->getName()] = true;
1328 } elseif ( $filterGroup instanceof ChangesListBooleanFilterGroup
) {
1329 foreach ( $filterGroup->getFilters() as $filter ) {
1330 $hideParameterNameSet[$filter->getName()] = true;
1335 $bits = preg_split( '/\s*,\s*/', trim( $par ) );
1336 foreach ( $bits as $bit ) {
1338 if ( isset( $hideParameterNameSet[$bit] ) ) {
1339 // hidefoo => hidefoo=true
1341 } elseif ( isset( $hideParameterNameSet["hide$bit"] ) ) {
1342 // foo => hidefoo=false
1343 $opts["hide$bit"] = false;
1344 } elseif ( preg_match( '/^(.*)=(.*)$/', $bit, $m ) ) {
1345 if ( isset( $stringParameterNameSet[$m[1]] ) ) {
1346 $opts[$m[1]] = $m[2];
1353 * Validate a FormOptions object generated by getDefaultOptions() with values already populated.
1355 * @param FormOptions $opts
1357 public function validateOptions( FormOptions
$opts ) {
1358 $isContradictory = $this->fixContradictoryOptions( $opts );
1359 $isReplaced = $this->replaceOldOptions( $opts );
1361 if ( $isContradictory ||
$isReplaced ) {
1362 $query = wfArrayToCgi( $this->convertParamsForLink( $opts->getChangedValues() ) );
1363 $this->getOutput()->redirect( $this->getPageTitle()->getCanonicalURL( $query ) );
1366 $opts->validateIntBounds( 'limit', 0, 5000 );
1367 $opts->validateBounds( 'days', 0,
1368 $this->getConfig()->get( MainConfigNames
::RCMaxAge
) / ( 3600 * 24 ) );
1372 * Fix invalid options by resetting pairs that should never appear together.
1374 * @param FormOptions $opts
1375 * @return bool True if any option was reset
1377 private function fixContradictoryOptions( FormOptions
$opts ) {
1378 $fixed = $this->fixBackwardsCompatibilityOptions( $opts );
1380 foreach ( $this->filterGroups
as $filterGroup ) {
1381 if ( $filterGroup instanceof ChangesListBooleanFilterGroup
) {
1382 $filters = $filterGroup->getFilters();
1384 if ( count( $filters ) === 1 ) {
1385 // legacy boolean filters should not be considered
1389 $allInGroupEnabled = array_reduce(
1391 static function ( bool $carry, ChangesListBooleanFilter
$filter ) use ( $opts ) {
1392 return $carry && $opts[ $filter->getName() ];
1394 /* initialValue */ count( $filters ) > 0
1397 if ( $allInGroupEnabled ) {
1398 foreach ( $filters as $filter ) {
1399 $opts[ $filter->getName() ] = false;
1411 * Fix a special case (hideanons=1 and hideliu=1) in a special way, for backwards
1414 * This is deprecated and may be removed.
1416 * @param FormOptions $opts
1417 * @return bool True if this change was mode
1419 private function fixBackwardsCompatibilityOptions( FormOptions
$opts ) {
1420 if ( $opts['hideanons'] && $opts['hideliu'] ) {
1421 $opts->reset( 'hideanons' );
1422 if ( !$opts['hidebots'] ) {
1423 $opts->reset( 'hideliu' );
1424 $opts['hidehumans'] = 1;
1434 * Replace old options with their structured UI equivalents
1436 * @param FormOptions $opts
1437 * @return bool True if the change was made
1439 public function replaceOldOptions( FormOptions
$opts ) {
1440 if ( !$this->isStructuredFilterUiEnabled() ) {
1446 // At this point 'hideanons' and 'hideliu' cannot be both true,
1447 // because fixBackwardsCompatibilityOptions resets (at least) 'hideanons' in such case
1448 if ( $opts[ 'hideanons' ] ) {
1449 $opts->reset( 'hideanons' );
1450 $opts[ 'userExpLevel' ] = 'registered';
1454 if ( $opts[ 'hideliu' ] ) {
1455 $opts->reset( 'hideliu' );
1456 $opts[ 'userExpLevel' ] = 'unregistered';
1460 if ( $this->getFilterGroup( 'legacyReviewStatus' ) ) {
1461 if ( $opts[ 'hidepatrolled' ] ) {
1462 $opts->reset( 'hidepatrolled' );
1463 $opts[ 'reviewStatus' ] = 'unpatrolled';
1467 if ( $opts[ 'hideunpatrolled' ] ) {
1468 $opts->reset( 'hideunpatrolled' );
1469 $opts[ 'reviewStatus' ] = implode(
1470 ChangesListStringOptionsFilterGroup
::SEPARATOR
,
1471 [ 'manual', 'auto' ]
1481 * Convert parameters values from true/false to 1/0
1482 * so they are not omitted by wfArrayToCgi()
1485 * @param array $params
1488 protected function convertParamsForLink( $params ) {
1489 foreach ( $params as &$value ) {
1490 if ( $value === false ) {
1499 * Sets appropriate tables, fields, conditions, etc. depending on which filters
1500 * the user requested.
1502 * @param array &$tables Array of tables; see IReadableDatabase::select $table
1503 * @param array &$fields Array of fields; see IReadableDatabase::select $vars
1504 * @param array &$conds Array of conditions; see IReadableDatabase::select $conds
1505 * @param array &$query_options Array of query options; see IReadableDatabase::select $options
1506 * @param array &$join_conds Array of join conditions; see IReadableDatabase::select $join_conds
1507 * @param FormOptions $opts
1509 protected function buildQuery( &$tables, &$fields, &$conds, &$query_options,
1510 &$join_conds, FormOptions
$opts
1512 $dbr = $this->getDB();
1513 $isStructuredUI = $this->isStructuredFilterUiEnabled();
1515 /** @var ChangesListFilterGroup $filterGroup */
1516 foreach ( $this->filterGroups
as $filterGroup ) {
1517 $filterGroup->modifyQuery( $dbr, $this, $tables, $fields, $conds,
1518 $query_options, $join_conds, $opts, $isStructuredUI );
1521 // Namespace filtering
1522 if ( $opts[ 'namespace' ] !== '' ) {
1523 $namespaces = explode( ';', $opts[ 'namespace' ] );
1525 $namespaces = $this->expandSymbolicNamespaceFilters( $namespaces );
1527 $namespaceInfo = MediaWikiServices
::getInstance()->getNamespaceInfo();
1528 $namespaces = array_filter( $namespaces, [ $namespaceInfo, 'exists' ] );
1530 if ( $namespaces !== [] ) {
1531 // Namespaces are just ints, use them as int when acting with the database
1532 $namespaces = array_map( 'intval', $namespaces );
1534 if ( $opts[ 'associated' ] ) {
1535 $associatedNamespaces = array_map(
1536 [ $namespaceInfo, 'getAssociated' ],
1537 array_filter( $namespaces, [ $namespaceInfo, 'hasTalkNamespace' ] )
1539 $namespaces = array_unique( array_merge( $namespaces, $associatedNamespaces ) );
1542 $operator = $opts[ 'invert' ] ?
'!=' : '=';
1543 sort( $namespaces );
1544 $conds[] = $dbr->expr( 'rc_namespace', $operator, $namespaces );
1549 $cutoff_unixtime = ConvertibleTimestamp
::time() - $opts['days'] * 3600 * 24;
1550 $cutoff = $dbr->timestamp( $cutoff_unixtime );
1552 $fromValid = preg_match( '/^[0-9]{14}$/', $opts['from'] );
1553 if ( $fromValid && $opts['from'] > wfTimestamp( TS_MW
, $cutoff ) ) {
1554 $cutoff = $dbr->timestamp( $opts['from'] );
1556 $opts->reset( 'from' );
1559 $conds[] = $dbr->expr( 'rc_timestamp', '>=', $cutoff );
1565 * @param array $tables Array of tables; see IReadableDatabase::select $table
1566 * @param array $fields Array of fields; see IReadableDatabase::select $vars
1567 * @param array $conds Array of conditions; see IReadableDatabase::select $conds
1568 * @param array $query_options Array of query options; see IReadableDatabase::select $options
1569 * @param array $join_conds Array of join conditions; see IReadableDatabase::select $join_conds
1570 * @param FormOptions $opts
1571 * @return bool|IResultWrapper Result or false
1573 protected function doMainQuery( $tables, $fields, $conds,
1574 $query_options, $join_conds, FormOptions
$opts
1576 $rcQuery = RecentChange
::getQueryInfo();
1577 $tables = array_merge( $tables, $rcQuery['tables'] );
1578 $fields = array_merge( $rcQuery['fields'], $fields );
1579 $join_conds = array_merge( $join_conds, $rcQuery['joins'] );
1581 MediaWikiServices
::getInstance()->getChangeTagsStore()->modifyDisplayQuery(
1588 $opts[ 'inverttags' ]
1592 !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds, $opts )
1597 $dbr = $this->getDB();
1599 return $dbr->newSelectQueryBuilder()
1603 ->caller( __METHOD__
)
1604 ->options( $query_options )
1605 ->joinConds( $join_conds )
1609 protected function runMainQueryHook( &$tables, &$fields, &$conds,
1610 &$query_options, &$join_conds, $opts
1612 return $this->getHookRunner()->onChangesListSpecialPageQuery(
1613 $this->getName(), $tables, $fields, $conds, $query_options, $join_conds, $opts );
1617 * Which database to use for read queries
1619 * @return IReadableDatabase
1621 protected function getDB(): IReadableDatabase
{
1622 return MediaWikiServices
::getInstance()->getConnectionProvider()->getReplicaDatabase();
1626 * Send header output to the OutputPage object, only called if not using feeds
1628 * @param int $rowCount Number of database rows
1629 * @param FormOptions $opts
1631 private function webOutputHeader( $rowCount, $opts ) {
1632 if ( !$this->including() ) {
1633 $this->outputFeedLinks();
1634 $this->doHeader( $opts, $rowCount );
1639 * Send output to the OutputPage object, only called if not used feeds
1641 * @param IResultWrapper $rows Database rows
1642 * @param FormOptions $opts
1644 public function webOutput( $rows, $opts ) {
1645 $this->webOutputHeader( $rows->numRows(), $opts );
1647 $this->outputChangesList( $rows, $opts );
1650 public function outputFeedLinks() {
1651 // nothing by default
1655 * Build and output the actual changes list.
1657 * @param IResultWrapper $rows Database rows
1658 * @param FormOptions $opts
1660 abstract public function outputChangesList( $rows, $opts );
1663 * Set the text to be displayed above the changes
1665 * @param FormOptions $opts
1666 * @param int $numRows Number of rows in the result to show after this header
1668 public function doHeader( $opts, $numRows ) {
1669 $this->setTopText( $opts );
1671 // @todo Lots of stuff should be done here.
1673 $this->setBottomText( $opts );
1677 * Send the text to be displayed before the options.
1678 * Should use $this->getOutput()->addWikiTextAsInterface()
1679 * or similar methods to print the text.
1681 * @param FormOptions $opts
1683 public function setTopText( FormOptions
$opts ) {
1684 // nothing by default
1688 * Send the text to be displayed after the options.
1689 * Should use $this->getOutput()->addWikiTextAsInterface()
1690 * or similar methods to print the text.
1692 * @param FormOptions $opts
1694 public function setBottomText( FormOptions
$opts ) {
1695 // nothing by default
1699 * Get options to be displayed in a form
1700 * @todo This should handle options returned by getDefaultOptions().
1701 * @todo Not called by anything in this class (but is in subclasses), should be
1702 * called by something… doHeader() maybe?
1704 * @param FormOptions $opts
1707 public function getExtraOptions( $opts ) {
1712 * Return the legend displayed within the fieldset
1716 public function makeLegend() {
1717 $context = $this->getContext();
1718 $user = $context->getUser();
1719 # The legend showing what the letters and stuff mean
1720 $legend = Html
::openElement( 'dl' ) . "\n";
1721 # Iterates through them and gets the messages for both letter and tooltip
1722 $legendItems = $context->getConfig()->get( MainConfigNames
::RecentChangesFlags
);
1723 if ( !( $user->useRCPatrol() ||
$user->useNPPatrol() ) ) {
1724 unset( $legendItems['unpatrolled'] );
1726 foreach ( $legendItems as $key => $item ) { # generate items of the legend
1727 $label = $item['legend'] ??
$item['title'];
1728 $letter = $item['letter'];
1729 $cssClass = $item['class'] ??
$key;
1731 $legend .= Html
::element( 'dt',
1732 [ 'class' => $cssClass ], $context->msg( $letter )->text()
1734 Html
::rawElement( 'dd',
1735 [ 'class' => Sanitizer
::escapeClass( 'mw-changeslist-legend-' . $key ) ],
1736 $context->msg( $label )->parse()
1740 $legend .= Html
::rawElement( 'dt',
1741 [ 'class' => 'mw-plusminus-pos' ],
1742 $context->msg( 'recentchanges-legend-plusminus' )->parse()
1744 $legend .= Html
::element(
1746 [ 'class' => 'mw-changeslist-legend-plusminus' ],
1747 $context->msg( 'recentchanges-label-plusminus' )->text()
1749 // Watchlist expiry clock icon.
1750 if ( $context->getConfig()->get( MainConfigNames
::WatchlistExpiry
) && !$this->including() ) {
1751 $widget = new IconWidget( [
1753 'classes' => [ 'mw-changesList-watchlistExpiry' ],
1755 // Link the image to its label for assistive technologies.
1756 $watchlistLabelId = 'mw-changeslist-watchlistExpiry-label';
1757 $widget->getIconElement()->setAttributes( [
1759 'aria-labelledby' => $watchlistLabelId,
1761 $legend .= Html
::rawElement(
1763 [ 'class' => 'mw-changeslist-legend-watchlistexpiry' ],
1766 $legend .= Html
::element(
1768 [ 'class' => 'mw-changeslist-legend-watchlistexpiry', 'id' => $watchlistLabelId ],
1769 $context->msg( 'recentchanges-legend-watchlistexpiry' )->text()
1772 $legend .= Html
::closeElement( 'dl' ) . "\n";
1774 $legendHeading = $this->isStructuredFilterUiEnabled() ?
1775 $context->msg( 'rcfilters-legend-heading' )->parse() :
1776 $context->msg( 'recentchanges-legend-heading' )->parse();
1779 $collapsedState = $this->getRequest()->getCookie( 'changeslist-state' );
1781 $legend = Html
::rawElement( 'details', [
1782 'class' => 'mw-changeslist-legend',
1783 'open' => $collapsedState !== 'collapsed' ?
'open' : null,
1785 Html
::rawElement( 'summary', [], $legendHeading ) .
1793 * Add page-specific modules.
1795 protected function addModules() {
1796 $out = $this->getOutput();
1797 // Styles and behavior for the legend box (see makeLegend())
1798 $out->addModuleStyles( [
1799 'mediawiki.interface.helpers.styles',
1800 'mediawiki.special.changeslist.legend',
1801 'mediawiki.special.changeslist',
1803 $out->addModules( 'mediawiki.special.changeslist.legend.js' );
1805 if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
1806 $out->addModules( 'mediawiki.rcfilters.filters.ui' );
1807 $out->addModuleStyles( 'mediawiki.rcfilters.filters.base.styles' );
1811 protected function getGroupName() {
1816 * Return expression that is true when the user is or isn't registered.
1817 * @param bool $isRegistered
1818 * @param IReadableDatabase $dbr
1819 * @return IExpression
1821 private function getRegisteredExpr( $isRegistered, $dbr ): IExpression
{
1822 $expr = $dbr->expr( 'actor_user', $isRegistered ?
'!=' : '=', null );
1823 if ( !$this->tempUserConfig
->isKnown() ) {
1826 if ( $isRegistered ) {
1827 return $expr->andExpr( $this->tempUserConfig
->getMatchCondition( $dbr,
1828 'actor_name', IExpression
::NOT_LIKE
) );
1830 return $expr->orExpr( $this->tempUserConfig
->getMatchCondition( $dbr,
1831 'actor_name', IExpression
::LIKE
) );
1836 * Return expression that is true when the user has reached the given experience level.
1837 * @param string $level 'learner' or 'experienced'
1838 * @param int $now Current time as UNIX timestamp (if 0, uses actual time)
1839 * @param IReadableDatabase $dbr
1840 * @param bool $asNotCondition
1841 * @return IExpression
1843 private function getExperienceExpr( $level, $now, IReadableDatabase
$dbr, $asNotCondition = false ): IExpression
{
1844 $config = $this->getConfig();
1847 'learner' => $config->get( MainConfigNames
::LearnerMemberSince
),
1848 'experienced' => $config->get( MainConfigNames
::ExperiencedUserMemberSince
),
1851 $now = ConvertibleTimestamp
::time();
1853 $secondsPerDay = 86400;
1854 $timeCutoff = $now - $configSince * $secondsPerDay;
1857 'learner' => $config->get( MainConfigNames
::LearnerEdits
),
1858 'experienced' => $config->get( MainConfigNames
::ExperiencedUserEdits
),
1861 if ( $asNotCondition ) {
1862 return $dbr->expr( 'user_editcount', '<', intval( $editCutoff ) )
1863 ->or( 'user_registration', '>', $dbr->timestamp( $timeCutoff ) );
1865 return $dbr->expr( 'user_editcount', '>=', intval( $editCutoff ) )->andExpr(
1866 // Users who don't have user_registration set are very old, so we assume they're above any cutoff
1867 $dbr->expr( 'user_registration', '=', null )
1868 ->or( 'user_registration', '<=', $dbr->timestamp( $timeCutoff ) )
1873 * Filter on users' experience levels; this will not be called if nothing is
1876 * @param string $specialPageClassName Class name of current special page
1877 * @param IContextSource $context Context, for e.g. user
1878 * @param IReadableDatabase $dbr Database, for addQuotes, makeList, and similar
1879 * @param array &$tables Array of tables; see IReadableDatabase::select $table
1880 * @param array &$fields Array of fields; see IReadableDatabase::select $vars
1881 * @param array &$conds Array of conditions; see IReadableDatabase::select $conds
1882 * @param array &$query_options Array of query options; see IReadableDatabase::select $options
1883 * @param array &$join_conds Array of join conditions; see IReadableDatabase::select $join_conds
1884 * @param array $selectedExpLevels The allowed active values, sorted
1885 * @param int $now Current time as UNIX timestamp (if 0, uses actual time)
1887 public function filterOnUserExperienceLevel( $specialPageClassName, $context, $dbr,
1888 &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedExpLevels, $now = 0
1890 $selected = array_fill_keys( $selectedExpLevels, true );
1892 $isUnregistered = $this->getRegisteredExpr( false, $dbr );
1893 $isRegistered = $this->getRegisteredExpr( true, $dbr );
1894 $aboveNewcomer = $this->getExperienceExpr( 'learner', $now, $dbr );
1895 $notAboveNewcomer = $this->getExperienceExpr( 'learner', $now, $dbr, true );
1896 $aboveLearner = $this->getExperienceExpr( 'experienced', $now, $dbr );
1897 $notAboveLearner = $this->getExperienceExpr( 'experienced', $now, $dbr, true );
1899 // We need to select some range of user experience levels, from the following table:
1900 // | Unregistered | --------- Registered --------- |
1901 // | | Newcomers | Learners | Experienced |
1902 // |<------------>|<----------->|<---------->|<----------->|
1903 // We just need to define a condition for each of the columns, figure out which are selected,
1904 // and then OR them together.
1906 'unregistered' => $isUnregistered,
1907 'registered' => $isRegistered,
1908 'newcomer' => $dbr->andExpr( [ $isRegistered, $notAboveNewcomer ] ),
1909 'learner' => $dbr->andExpr( [ $isRegistered, $aboveNewcomer, $notAboveLearner ] ),
1910 'experienced' => $dbr->andExpr( [ $isRegistered, $aboveLearner ] ),
1913 // There are some cases where we can easily optimize away some queries:
1914 // | Unregistered | --------- Registered --------- |
1915 // | | Newcomers | Learners | Experienced |
1916 // | |<-------------------------------------->| (1)
1917 // |<----------------------------------------------------->| (2)
1919 // (1) Selecting all of "Newcomers", "Learners" and "Experienced users" is the same as "Registered".
1921 isset( $selected['registered'] ) ||
1922 ( isset( $selected['newcomer'] ) && isset( $selected['learner'] ) && isset( $selected['experienced'] ) )
1924 unset( $selected['newcomer'], $selected['learner'], $selected['experienced'] );
1925 $selected['registered'] = true;
1927 // (2) Selecting "Unregistered" and "Registered" covers all users.
1928 if ( isset( $selected['registered'] ) && isset( $selected['unregistered'] ) ) {
1929 unset( $selected['registered'], $selected['unregistered'] );
1932 // Combine the conditions for the selected columns.
1936 $selectedColumnConds = array_values( array_intersect_key( $columnConds, $selected ) );
1937 $conds[] = $dbr->orExpr( $selectedColumnConds );
1939 // Add necessary tables to the queries.
1940 $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
1941 if ( isset( $selected['newcomer'] ) ||
isset( $selected['learner'] ) ||
isset( $selected['experienced'] ) ) {
1943 $join_conds['user'] = [ 'LEFT JOIN', 'actor_user=user_id' ];
1948 * Check whether the structured filter UI is enabled
1952 public function isStructuredFilterUiEnabled() {
1953 if ( $this->getRequest()->getBool( 'rcfilters' ) ) {
1957 return static::checkStructuredFilterUiEnabled( $this->getUser() );
1961 * Static method to check whether StructuredFilter UI is enabled for the given user
1964 * @param UserIdentity $user
1967 public static function checkStructuredFilterUiEnabled( UserIdentity
$user ) {
1968 return !MediaWikiServices
::getInstance()
1969 ->getUserOptionsLookup()
1970 ->getOption( $user, 'rcenhancedfilters-disable' );
1974 * Get the default value of the number of changes to display when loading
1980 public function getDefaultLimit() {
1981 return MediaWikiServices
::getInstance()
1982 ->getUserOptionsLookup()
1983 ->getIntOption( $this->getUser(), $this->getLimitPreferenceName() );
1987 * Get the default value of the number of days to display when loading
1989 * Supports fractional values, and should be cast to a float.
1994 public function getDefaultDays() {
1995 return floatval( MediaWikiServices
::getInstance()
1996 ->getUserOptionsLookup()
1997 ->getOption( $this->getUser(), $this->getDefaultDaysPreferenceName() ) );
2001 * Getting the preference name for 'limit'.
2006 abstract protected function getLimitPreferenceName(): string;
2009 * Preference name for saved queries.
2014 abstract protected function getSavedQueriesPreferenceName(): string;
2017 * Preference name for 'days'.
2022 abstract protected function getDefaultDaysPreferenceName(): string;
2025 * Preference name for collapsing the active filter display.
2030 abstract protected function getCollapsedPreferenceName(): string;
2033 * @param array $namespaces
2036 private function expandSymbolicNamespaceFilters( array $namespaces ) {
2037 $nsInfo = MediaWikiServices
::getInstance()->getNamespaceInfo();
2038 $symbolicFilters = [
2039 'all-contents' => $nsInfo->getSubjectNamespaces(),
2040 'all-discussions' => $nsInfo->getTalkNamespaces(),
2042 $additionalNamespaces = [];
2043 foreach ( $symbolicFilters as $name => $values ) {
2044 if ( in_array( $name, $namespaces ) ) {
2045 $additionalNamespaces = array_merge( $additionalNamespaces, $values );
2048 $namespaces = array_diff( $namespaces, array_keys( $symbolicFilters ) );
2049 $namespaces = array_merge( $namespaces, $additionalNamespaces );
2050 return array_unique( $namespaces );
2054 /** @deprecated class alias since 1.41 */
2055 class_alias( ChangesListSpecialPage
::class, 'ChangesListSpecialPage' );