Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / specialpage / ChangesListSpecialPage.php
blob6aa1d11182577a58dd74c7159a601d5ccace353c
1 <?php
2 /**
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
18 * @file
21 namespace MediaWiki\SpecialPage;
23 use ChangesListBooleanFilter;
24 use ChangesListBooleanFilterGroup;
25 use ChangesListFilterGroup;
26 use ChangesListStringOptionsFilterGroup;
27 use ChangeTags;
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;
41 use OOUI\IconWidget;
42 use RecentChange;
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;
51 /**
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 {
61 /** @var string */
62 protected $rcSubpage;
64 /** @var FormOptions */
65 protected $rcOptions;
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.
74 /**
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.
84 * @var array
86 private $filterGroupDefinitions;
88 /**
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(
115 $name,
116 $restriction,
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,
133 'filters' => [
135 'name' => 'hideliu',
136 // rcshowhideliu-show, rcshowhideliu-hide,
137 // wlshowhideliu
138 'showHideSuffix' => 'showhideliu',
139 'default' => false,
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,
152 // wlshowhideanons
153 'showHideSuffix' => 'showhideanons',
154 'default' => false,
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,
171 'filters' => [
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()
201 ->getUserFactory()
202 ->newFromUserIdentity( $performer )
203 ->getExperienceLevel() === 'newcomer';
207 'name' => 'learner',
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()
215 ->getUserFactory()
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()
229 ->getUserFactory()
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,
243 'filters' => [
245 'name' => 'hidemyself',
246 'label' => 'rcfilters-filter-editsbyself-label',
247 'description' => 'rcfilters-filter-editsbyself-description',
248 // rcshowhidemine-show, rcshowhidemine-hide,
249 // wlshowhidemine
250 'showHideSuffix' => 'showhidemine',
251 'default' => false,
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',
268 'default' => false,
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();
275 } else {
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,
292 'filters' => [
294 'name' => 'hidebots',
295 'label' => 'rcfilters-filter-bots-label',
296 'description' => 'rcfilters-filter-bots-description',
297 // rcshowhidebots-show, rcshowhidebots-hide,
298 // wlshowhidebots
299 'showHideSuffix' => 'showhidebots',
300 'default' => false,
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',
315 'default' => false,
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,
335 'priority' => -6,
336 'filters' => [
338 'name' => 'hideminor',
339 'label' => 'rcfilters-filter-minor-label',
340 'description' => 'rcfilters-filter-minor-description',
341 // rcshowhideminor-show, rcshowhideminor-hide,
342 // wlshowhideminor
343 'showHideSuffix' => 'showhideminor',
344 'default' => false,
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',
359 'default' => false,
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,
377 'priority' => -7,
378 'filters' => [
380 'name' => 'hidelastrevision',
381 'label' => 'rcfilters-filter-lastrevision-label',
382 'description' => 'rcfilters-filter-lastrevision-description',
383 'default' => false,
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',
399 'default' => false,
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,
419 'priority' => -8,
420 'filters' => [
422 'name' => 'hidepageedits',
423 'label' => 'rcfilters-filter-pageedits-label',
424 'description' => 'rcfilters-filter-pageedits-description',
425 'default' => false,
426 'priority' => -2,
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',
441 'default' => false,
442 'priority' => -3,
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
457 'name' => 'hidelog',
458 'label' => 'rcfilters-filter-logactions-label',
459 'description' => 'rcfilters-filter-logactions-description',
460 'default' => false,
461 'priority' => -5,
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',
476 'default' => false,
477 'priority' => -6,
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,
499 'filters' => [
501 'name' => 'hidepatrolled',
502 // rcshowhidepatr-show, rcshowhidepatr-hide
503 // wlshowhidepatr
504 'showHideSuffix' => 'showhidepatr',
505 'default' => false,
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',
515 'default' => false,
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,
533 'priority' => -5,
534 'filters' => [
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;
545 'name' => 'manual',
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;
554 'name' => 'auto',
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 === [] ) {
568 return;
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 ];
578 }, $selected );
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',
590 'default' => false,
591 'priority' => -4,
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
606 * @return void
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.
622 * @return bool
624 protected function areFiltersInConflict() {
625 $opts = $this->getOptions();
626 foreach ( $this->getFilterGroups() as $group ) {
627 if ( $group->getConflictingGroups() ) {
628 wfLogWarning(
629 $group->getName() .
630 " specifies conflicts with other groups but these are not supported yet."
634 foreach ( $group->getConflictingFilters() as $conflictingFilter ) {
635 if ( $conflictingFilter->activelyInConflictWithGroup( $group, $opts ) ) {
636 return true;
640 foreach ( $group->getFilters() as $filter ) {
641 foreach ( $filter->getConflictingFilters() as $conflictingFilter ) {
642 if (
643 $conflictingFilter->activelyInConflictWithFilter( $filter, $opts ) &&
644 $filter->activelyInConflictWithFilter( $conflictingFilter, $opts )
646 return true;
654 return false;
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).
665 return;
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();
675 try {
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 );
698 return;
701 $services = MediaWikiServices::getInstance();
702 $logFormatterFactory = $services->getLogFormatterFactory();
703 $linkBatchFactory = $services->getLinkBatchFactory();
704 $batch = $linkBatchFactory->newLinkBatch();
705 $userNames = [];
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();
721 $batch->execute();
722 foreach ( UserArray::newFromNames( $userNames ) as $_ ) {
723 // Trigger UserEditTracker::setCachedUserEditCount via User::loadFromRow
724 // Preloads edit count for User::getExperienceLevel() and Linker::userToolLinks()
727 $this->setHeaders();
728 $this->outputHeader();
729 $this->addModules();
730 $this->webOutput( $rows, $opts );
732 $rows->free();
733 } catch ( DBQueryTimeoutError $timeoutException ) {
734 MWExceptionHandler::logException( $timeoutException );
736 $this->setHeaders();
737 $this->outputHeader();
738 $this->addModules();
740 $this->getOutput()->setStatusCode( 500 );
741 $this->webOutputHeader( 0, $opts );
742 $this->outputTimeout();
745 $this->includeRcFiltersApp();
749 * Set the temp user config.
751 * @internal
752 * @param TempUserConfig $tempUserConfig
753 * @since 1.42
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() ) {
769 return false;
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 );
782 if (
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' ],
807 'urlversion' => '2',
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).
818 return true;
819 } else {
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',
826 true
829 // Add the class that tells the frontend it is still loading
830 // another query
831 $this->getOutput()->addBodyClasses( 'mw-rcfilters-ui-loading' );
836 return false;
840 * @see \MediaWiki\MainConfigSchema::RCLinkDays and \MediaWiki\MainConfigSchema::RCFilterByAge.
841 * @return int[]
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.
851 sort( $linkDays );
853 $maxAgeDays = $maxAge / ( 3600 * 24 );
854 foreach ( $linkDays as $i => $days ) {
855 if ( $days >= $maxAgeDays ) {
856 array_splice( $linkDays, $i + 1 );
857 break;
862 return $linkDays;
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();
875 $messages = [];
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() );
883 if ( $collapsed ) {
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()
920 } else {
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
931 * @return array
933 public static function getRcFiltersConfigSummary( RL\Context $context ) {
934 $lang = MediaWikiServices::getInstance()->getLanguageFactory()
935 ->getLanguage( $context->getLanguage() );
936 return [
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
949 * @return array
951 public static function getRcFiltersConfigVars( RL\Context $context ) {
952 $lang = MediaWikiServices::getInstance()->getLanguageFactory()
953 ->getLanguage( $context->getLanguage() );
954 return [
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(
966 Html::rawElement(
967 'div',
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() .
981 '</div>'
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();
993 $tables = [];
994 $fields = [];
995 $conds = [];
996 $query_options = [];
997 $join_conds = [];
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
1023 * setDefault).
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(
1078 $categoryFilter,
1079 'rcfilters-hideminor-conflicts-typeofchange-global',
1080 'rcfilters-hideminor-conflicts-typeofchange',
1081 'rcfilters-typeofchange-conflicts-hideminor'
1084 $hideMinorFilter->conflictsWith(
1085 $logactionsFilter,
1086 'rcfilters-hideminor-conflicts-typeofchange-global',
1087 'rcfilters-hideminor-conflicts-typeofchange',
1088 'rcfilters-typeofchange-conflicts-hideminor'
1090 $hideMinorFilter->conflictsWith(
1091 $lognewuserFilter,
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;
1132 } else {
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() {
1154 $filters = [];
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;
1164 return $filters;
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 );
1189 return $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.
1197 * not here.
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', '' );
1225 return $opts;
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() {
1273 $output = [
1274 'groups' => [],
1275 'messageKeys' => [],
1278 usort( $this->filterGroups, static function ( ChangesListFilterGroup $a, ChangesListFilterGroup $b ) {
1279 return $b->getPriority() <=> $a->getPriority();
1280 } );
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;
1295 return $output;
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() );
1309 return $opts;
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 ) {
1337 $m = [];
1338 if ( isset( $hideParameterNameSet[$bit] ) ) {
1339 // hidefoo => hidefoo=true
1340 $opts[$bit] = 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
1386 continue;
1389 $allInGroupEnabled = array_reduce(
1390 $filters,
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;
1402 $fixed = true;
1407 return $fixed;
1411 * Fix a special case (hideanons=1 and hideliu=1) in a special way, for backwards
1412 * compatibility.
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;
1427 return true;
1430 return false;
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() ) {
1441 return false;
1444 $changed = false;
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';
1451 $changed = true;
1454 if ( $opts[ 'hideliu' ] ) {
1455 $opts->reset( 'hideliu' );
1456 $opts[ 'userExpLevel' ] = 'unregistered';
1457 $changed = true;
1460 if ( $this->getFilterGroup( 'legacyReviewStatus' ) ) {
1461 if ( $opts[ 'hidepatrolled' ] ) {
1462 $opts->reset( 'hidepatrolled' );
1463 $opts[ 'reviewStatus' ] = 'unpatrolled';
1464 $changed = true;
1467 if ( $opts[ 'hideunpatrolled' ] ) {
1468 $opts->reset( 'hideunpatrolled' );
1469 $opts[ 'reviewStatus' ] = implode(
1470 ChangesListStringOptionsFilterGroup::SEPARATOR,
1471 [ 'manual', 'auto' ]
1473 $changed = true;
1477 return $changed;
1481 * Convert parameters values from true/false to 1/0
1482 * so they are not omitted by wfArrayToCgi()
1483 * T38524
1485 * @param array $params
1486 * @return array
1488 protected function convertParamsForLink( $params ) {
1489 foreach ( $params as &$value ) {
1490 if ( $value === false ) {
1491 $value = '0';
1494 unset( $value );
1495 return $params;
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 );
1548 // Calculate cutoff
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'] );
1555 } else {
1556 $opts->reset( 'from' );
1559 $conds[] = $dbr->expr( 'rc_timestamp', '>=', $cutoff );
1563 * Process the query
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(
1582 $tables,
1583 $fields,
1584 $conds,
1585 $join_conds,
1586 $query_options,
1588 $opts[ 'inverttags' ]
1591 if (
1592 !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds, $opts )
1594 return false;
1597 $dbr = $this->getDB();
1599 return $dbr->newSelectQueryBuilder()
1600 ->tables( $tables )
1601 ->fields( $fields )
1602 ->conds( $conds )
1603 ->caller( __METHOD__ )
1604 ->options( $query_options )
1605 ->joinConds( $join_conds )
1606 ->fetchResultSet();
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
1705 * @return array
1707 public function getExtraOptions( $opts ) {
1708 return [];
1712 * Return the legend displayed within the fieldset
1714 * @return string
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()
1733 ) . "\n" .
1734 Html::rawElement( 'dd',
1735 [ 'class' => Sanitizer::escapeClass( 'mw-changeslist-legend-' . $key ) ],
1736 $context->msg( $label )->parse()
1737 ) . "\n";
1739 # (+-123)
1740 $legend .= Html::rawElement( 'dt',
1741 [ 'class' => 'mw-plusminus-pos' ],
1742 $context->msg( 'recentchanges-legend-plusminus' )->parse()
1743 ) . "\n";
1744 $legend .= Html::element(
1745 'dd',
1746 [ 'class' => 'mw-changeslist-legend-plusminus' ],
1747 $context->msg( 'recentchanges-label-plusminus' )->text()
1748 ) . "\n";
1749 // Watchlist expiry clock icon.
1750 if ( $context->getConfig()->get( MainConfigNames::WatchlistExpiry ) && !$this->including() ) {
1751 $widget = new IconWidget( [
1752 'icon' => 'clock',
1753 'classes' => [ 'mw-changesList-watchlistExpiry' ],
1754 ] );
1755 // Link the image to its label for assistive technologies.
1756 $watchlistLabelId = 'mw-changeslist-watchlistExpiry-label';
1757 $widget->getIconElement()->setAttributes( [
1758 'role' => 'img',
1759 'aria-labelledby' => $watchlistLabelId,
1760 ] );
1761 $legend .= Html::rawElement(
1762 'dt',
1763 [ 'class' => 'mw-changeslist-legend-watchlistexpiry' ],
1764 $widget
1766 $legend .= Html::element(
1767 'dd',
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();
1778 # Collapsible
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 ) .
1786 $legend
1789 return $legend;
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',
1802 ] );
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() {
1812 return 'changes';
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() ) {
1824 return $expr;
1826 if ( $isRegistered ) {
1827 return $expr->andExpr( $this->tempUserConfig->getMatchCondition( $dbr,
1828 'actor_name', IExpression::NOT_LIKE ) );
1829 } else {
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();
1846 $configSince = [
1847 'learner' => $config->get( MainConfigNames::LearnerMemberSince ),
1848 'experienced' => $config->get( MainConfigNames::ExperiencedUserMemberSince ),
1849 ][$level];
1850 if ( $now === 0 ) {
1851 $now = ConvertibleTimestamp::time();
1853 $secondsPerDay = 86400;
1854 $timeCutoff = $now - $configSince * $secondsPerDay;
1856 $editCutoff = [
1857 'learner' => $config->get( MainConfigNames::LearnerEdits ),
1858 'experienced' => $config->get( MainConfigNames::ExperiencedUserEdits ),
1859 ][$level];
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
1874 * selected.
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.
1905 $columnConds = [
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".
1920 if (
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.
1933 if ( !$selected ) {
1934 return;
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'] ) ) {
1942 $tables[] = 'user';
1943 $join_conds['user'] = [ 'LEFT JOIN', 'actor_user=user_id' ];
1948 * Check whether the structured filter UI is enabled
1950 * @return bool
1952 public function isStructuredFilterUiEnabled() {
1953 if ( $this->getRequest()->getBool( 'rcfilters' ) ) {
1954 return true;
1957 return static::checkStructuredFilterUiEnabled( $this->getUser() );
1961 * Static method to check whether StructuredFilter UI is enabled for the given user
1963 * @since 1.31
1964 * @param UserIdentity $user
1965 * @return bool
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
1975 * the result set.
1977 * @since 1.30
1978 * @return int
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
1988 * the result set.
1989 * Supports fractional values, and should be cast to a float.
1991 * @since 1.30
1992 * @return 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'.
2003 * @since 1.37
2004 * @return string
2006 abstract protected function getLimitPreferenceName(): string;
2009 * Preference name for saved queries.
2011 * @since 1.38
2012 * @return string
2014 abstract protected function getSavedQueriesPreferenceName(): string;
2017 * Preference name for 'days'.
2019 * @since 1.38
2020 * @return string
2022 abstract protected function getDefaultDaysPreferenceName(): string;
2025 * Preference name for collapsing the active filter display.
2027 * @since 1.38
2028 * @return string
2030 abstract protected function getCollapsedPreferenceName(): string;
2033 * @param array $namespaces
2034 * @return array
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' );