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
607 private function removeRegistrationFilterDefinitions(): void
{
608 foreach ( $this->filterGroupDefinitions
as $key => $value ) {
609 if ( $value['name'] == "userExpLevel" ) {
610 $this->filterGroupDefinitions
[ $key ][ 'filters' ] = array_filter(
611 $this->filterGroupDefinitions
[ $key ][ 'filters' ],
612 static fn ( $val, $key ) => $val[ 'name' ] != 'registered'
613 && $val[ 'name' ] != 'unregistered', ARRAY_FILTER_USE_BOTH
);
619 * Check if filters are in conflict and guaranteed to return no results.
623 protected function areFiltersInConflict() {
624 $opts = $this->getOptions();
625 foreach ( $this->getFilterGroups() as $group ) {
626 if ( $group->getConflictingGroups() ) {
629 " specifies conflicts with other groups but these are not supported yet."
633 foreach ( $group->getConflictingFilters() as $conflictingFilter ) {
634 if ( $conflictingFilter->activelyInConflictWithGroup( $group, $opts ) ) {
639 foreach ( $group->getFilters() as $filter ) {
640 foreach ( $filter->getConflictingFilters() as $conflictingFilter ) {
642 $conflictingFilter->activelyInConflictWithFilter( $filter, $opts ) &&
643 $filter->activelyInConflictWithFilter( $conflictingFilter, $opts )
657 * @param string|null $subpage
659 public function execute( $subpage ) {
660 $this->rcSubpage
= $subpage;
662 if ( $this->considerActionsForDefaultSavedQuery( $subpage ) ) {
663 // Don't bother rendering the page if we'll be performing a redirect (T330100).
667 // Enable OOUI and module for the clock icon.
668 if ( $this->getConfig()->get( MainConfigNames
::WatchlistExpiry
) && !$this->including() ) {
669 $this->getOutput()->enableOOUI();
670 $this->getOutput()->addModules( 'mediawiki.special.changeslist.watchlistexpiry' );
673 $opts = $this->getOptions();
675 $rows = $this->getRows();
676 if ( $rows === false ) {
677 $rows = new FakeResultWrapper( [] );
680 // Used by Structured UI app to get results without MW chrome
681 if ( $this->getRequest()->getRawVal( 'action' ) === 'render' ) {
682 $this->getOutput()->setArticleBodyOnly( true );
685 // Used by "live update" and "view newest" to check
686 // if there's new changes with minimal data transfer
687 if ( $this->getRequest()->getBool( 'peek' ) ) {
688 $code = $rows->numRows() > 0 ?
200 : 204;
689 $this->getOutput()->setStatusCode( $code );
691 if ( $this->getUser()->isAnon() !==
692 $this->getRequest()->getFuzzyBool( 'isAnon' )
694 $this->getOutput()->setStatusCode( 205 );
700 $services = MediaWikiServices
::getInstance();
701 $logFormatterFactory = $services->getLogFormatterFactory();
702 $linkBatchFactory = $services->getLinkBatchFactory();
703 $batch = $linkBatchFactory->newLinkBatch();
705 foreach ( $rows as $row ) {
706 $batch->add( NS_USER
, $row->rc_user_text
);
707 $batch->add( NS_USER_TALK
, $row->rc_user_text
);
708 $userNames[] = $row->rc_user_text
;
709 $batch->add( $row->rc_namespace
, $row->rc_title
);
710 if ( $row->rc_source
=== RecentChange
::SRC_LOG
) {
711 $formatter = $logFormatterFactory->newFromRow( $row );
712 foreach ( $formatter->getPreloadTitles() as $title ) {
713 $batch->addObj( $title );
714 if ( $title->inNamespace( NS_USER
) ||
$title->inNamespace( NS_USER_TALK
) ) {
715 $userNames[] = $title->getText();
721 foreach ( UserArray
::newFromNames( $userNames ) as $_ ) {
722 // Trigger UserEditTracker::setCachedUserEditCount via User::loadFromRow
723 // Preloads edit count for User::getExperienceLevel() and Linker::userToolLinks()
727 $this->outputHeader();
729 $this->webOutput( $rows, $opts );
732 } catch ( DBQueryTimeoutError
$timeoutException ) {
733 MWExceptionHandler
::logException( $timeoutException );
736 $this->outputHeader();
739 $this->getOutput()->setStatusCode( 500 );
740 $this->webOutputHeader( 0, $opts );
741 $this->outputTimeout();
744 $this->includeRcFiltersApp();
748 * Set the temp user config.
751 * @param TempUserConfig $tempUserConfig
754 public function setTempUserConfig( TempUserConfig
$tempUserConfig ) {
755 $this->tempUserConfig
= $tempUserConfig;
759 * Check whether or not the page should load defaults, and if so, whether
760 * a default saved query is relevant to be redirected to. If it is relevant,
761 * redirect properly with all necessary query parameters.
763 * @param string $subpage
764 * @return bool Whether a redirect will be performed.
766 protected function considerActionsForDefaultSavedQuery( $subpage ) {
767 if ( !$this->isStructuredFilterUiEnabled() ||
$this->including() ) {
771 $knownParams = $this->getRequest()->getValues(
772 ...array_keys( $this->getOptions()->getAllValues() )
775 // HACK: Temporarily until we can properly define "sticky" filters and parameters,
776 // we need to exclude several parameters we know should not be counted towards preventing
777 // the loading of defaults.
778 $excludedParams = [ 'limit' => '', 'days' => '', 'enhanced' => '', 'from' => '' ];
779 $knownParams = array_diff_key( $knownParams, $excludedParams );
782 // If there are NO known parameters in the URL request
783 // (that are not excluded) then we need to check into loading
784 // the default saved query
785 count( $knownParams ) === 0
787 $prefJson = MediaWikiServices
::getInstance()
788 ->getUserOptionsLookup()
789 ->getOption( $this->getUser(), $this->getSavedQueriesPreferenceName() );
791 // Get the saved queries data and parse it
792 $savedQueries = $prefJson ? FormatJson
::decode( $prefJson, true ) : false;
794 if ( $savedQueries && isset( $savedQueries[ 'default' ] ) ) {
795 // Only load queries that are 'version' 2, since those
796 // have parameter representation
797 if ( isset( $savedQueries[ 'version' ] ) && $savedQueries[ 'version' ] === '2' ) {
798 $savedQueryDefaultID = $savedQueries[ 'default' ];
799 $defaultQuery = $savedQueries[ 'queries' ][ $savedQueryDefaultID ][ 'data' ];
801 // Build the entire parameter list
802 $query = array_merge(
803 $defaultQuery[ 'params' ],
804 $defaultQuery[ 'highlights' ],
809 // Add to the query any parameters that we may have ignored before
810 // but are still valid and requested in the URL
811 $query = array_merge( $this->getRequest()->getQueryValues(), $query );
812 unset( $query[ 'title' ] );
813 $this->getOutput()->redirect( $this->getPageTitle( $subpage )->getCanonicalURL( $query ) );
815 // Signal that we only need to redirect to the full URL
816 // and can skip rendering the actual page (T330100).
819 // There's a default, but the version is not 2, and the server can't
820 // actually recognize the query itself. This happens if it is before
821 // the conversion, so we need to tell the UI to reload saved query as
822 // it does the conversion to version 2
823 $this->getOutput()->addJsConfigVars(
824 'wgStructuredChangeFiltersDefaultSavedQueryExists',
828 // Add the class that tells the frontend it is still loading
830 $this->getOutput()->addBodyClasses( 'mw-rcfilters-ui-loading' );
839 * @see \MediaWiki\MainConfigSchema::RCLinkDays and \MediaWiki\MainConfigSchema::RCFilterByAge.
842 protected function getLinkDays() {
843 $linkDays = $this->getConfig()->get( MainConfigNames
::RCLinkDays
);
844 $filterByAge = $this->getConfig()->get( MainConfigNames
::RCFilterByAge
);
845 $maxAge = $this->getConfig()->get( MainConfigNames
::RCMaxAge
);
846 if ( $filterByAge ) {
847 // Trim it to only links which are within $wgRCMaxAge.
848 // Note that we allow one link higher than the max for things like
849 // "age 56 days" being accessible through the "60 days" link.
852 $maxAgeDays = $maxAge / ( 3600 * 24 );
853 foreach ( $linkDays as $i => $days ) {
854 if ( $days >= $maxAgeDays ) {
855 array_splice( $linkDays, $i +
1 );
865 * Include the modules and configuration for the RCFilters app.
866 * Conditional on the user having the feature enabled.
868 * If it is disabled, add a <body> class marking that
870 protected function includeRcFiltersApp() {
871 $out = $this->getOutput();
872 if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
873 $jsData = $this->getStructuredFilterJsData();
875 foreach ( $jsData['messageKeys'] as $key ) {
876 $messages[$key] = $this->msg( $key )->plain();
879 $out->addBodyClasses( 'mw-rcfilters-enabled' );
880 $collapsed = MediaWikiServices
::getInstance()->getUserOptionsLookup()
881 ->getBoolOption( $this->getUser(), $this->getCollapsedPreferenceName() );
883 $out->addBodyClasses( 'mw-rcfilters-collapsed' );
886 // These config and message exports should be moved into a ResourceLoader data module (T201574)
887 $out->addJsConfigVars( 'wgStructuredChangeFilters', $jsData['groups'] );
888 $out->addJsConfigVars( 'wgStructuredChangeFiltersMessages', $messages );
889 $out->addJsConfigVars( 'wgStructuredChangeFiltersCollapsedState', $collapsed );
891 $out->addJsConfigVars(
892 'StructuredChangeFiltersDisplayConfig',
894 'maxDays' => // Translate to days
895 (int)$this->getConfig()->get( MainConfigNames
::RCMaxAge
) / ( 24 * 3600 ),
896 'limitArray' => $this->getConfig()->get( MainConfigNames
::RCLinkLimits
),
897 'limitDefault' => $this->getDefaultLimit(),
898 'daysArray' => $this->getLinkDays(),
899 'daysDefault' => $this->getDefaultDays(),
903 $out->addJsConfigVars(
904 'wgStructuredChangeFiltersSavedQueriesPreferenceName',
905 $this->getSavedQueriesPreferenceName()
907 $out->addJsConfigVars(
908 'wgStructuredChangeFiltersLimitPreferenceName',
909 $this->getLimitPreferenceName()
911 $out->addJsConfigVars(
912 'wgStructuredChangeFiltersDaysPreferenceName',
913 $this->getDefaultDaysPreferenceName()
915 $out->addJsConfigVars(
916 'wgStructuredChangeFiltersCollapsedPreferenceName',
917 $this->getCollapsedPreferenceName()
920 $out->addBodyClasses( 'mw-rcfilters-disabled' );
925 * Get essential data about getRcFiltersConfigVars() for change detection.
927 * @internal For use by Resources.php only.
928 * @see Module::getDefinitionSummary() and Module::getVersionHash()
929 * @param RL\Context $context
932 public static function getRcFiltersConfigSummary( RL\Context
$context ) {
933 $lang = MediaWikiServices
::getInstance()->getLanguageFactory()
934 ->getLanguage( $context->getLanguage() );
936 // Reduce version computation by avoiding Message parsing
937 'RCFiltersChangeTags' => ChangeTags
::getChangeTagListSummary( $context, $lang ),
938 'StructuredChangeFiltersEditWatchlistUrl' =>
939 SpecialPage
::getTitleFor( 'EditWatchlist' )->getLocalURL()
944 * Get config vars to export with the mediawiki.rcfilters.filters.ui module.
946 * @internal For use by Resources.php only.
947 * @param RL\Context $context
950 public static function getRcFiltersConfigVars( RL\Context
$context ) {
951 $lang = MediaWikiServices
::getInstance()->getLanguageFactory()
952 ->getLanguage( $context->getLanguage() );
954 'RCFiltersChangeTags' => ChangeTags
::getChangeTagList( $context, $lang ),
955 'StructuredChangeFiltersEditWatchlistUrl' =>
956 SpecialPage
::getTitleFor( 'EditWatchlist' )->getLocalURL()
961 * Add the "no results" message to the output
963 protected function outputNoResults() {
964 $this->getOutput()->addHTML(
967 [ 'class' => 'mw-changeslist-empty' ],
968 $this->msg( 'recentchanges-noresult' )->parse()
974 * Add the "timeout" message to the output
976 protected function outputTimeout() {
977 $this->getOutput()->addHTML(
978 '<div class="mw-changeslist-empty mw-changeslist-timeout">' .
979 $this->msg( 'recentchanges-timeout' )->parse() .
985 * Get the database result for this special page instance. Used by ApiFeedRecentChanges.
987 * @return IResultWrapper|false
989 public function getRows() {
990 $opts = $this->getOptions();
997 $this->buildQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
999 return $this->doMainQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
1003 * Get the current FormOptions for this request
1005 * @return FormOptions
1007 public function getOptions() {
1008 if ( $this->rcOptions
=== null ) {
1009 $this->rcOptions
= $this->setup( $this->rcSubpage
);
1012 return $this->rcOptions
;
1016 * Register all filters and their groups (including those from hooks), plus handle
1017 * conflicts and defaults.
1019 * You might want to customize these in the same method, in subclasses. You can
1020 * call getFilterGroup to access a group, and (on the group) getFilter to access a
1021 * filter, then make necessary modfications to the filter or group (e.g. with
1024 protected function registerFilters() {
1025 $isRegistrationRequiredToEdit = !MediaWikiServices
::getInstance()
1026 ->getPermissionManager()
1027 ->isEveryoneAllowed( "edit" );
1028 if ( $isRegistrationRequiredToEdit ) {
1029 $this->removeRegistrationFilterDefinitions();
1031 $this->registerFiltersFromDefinitions( $this->filterGroupDefinitions
);
1033 // Make sure this is not being transcluded (we don't want to show this
1034 // information to all users just because the user that saves the edit can
1035 // patrol or is logged in)
1036 if ( !$this->including() && $this->getUser()->useRCPatrol() ) {
1037 $this->registerFiltersFromDefinitions( $this->legacyReviewStatusFilterGroupDefinition
);
1038 $this->registerFiltersFromDefinitions( $this->reviewStatusFilterGroupDefinition
);
1041 $changeTypeGroup = $this->getFilterGroup( 'changeType' );
1043 $categoryFilter = null;
1044 if ( $this->getConfig()->get( MainConfigNames
::RCWatchCategoryMembership
) ) {
1045 $transformedHideCategorizationDef = $this->transformFilterDefinition(
1046 $this->hideCategorizationFilterDefinition
1049 $transformedHideCategorizationDef['group'] = $changeTypeGroup;
1051 $categoryFilter = new ChangesListBooleanFilter(
1052 $transformedHideCategorizationDef
1056 $this->getHookRunner()->onChangesListSpecialPageStructuredFilters( $this );
1058 $this->registerFiltersFromDefinitions( [] );
1060 $userExperienceLevel = $this->getFilterGroup( 'userExpLevel' );
1061 if ( !$isRegistrationRequiredToEdit ) {
1062 $registered = $userExperienceLevel->getFilter( 'registered' );
1063 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'newcomer' ) );
1064 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'learner' ) );
1065 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'experienced' ) );
1068 $logactionsFilter = $changeTypeGroup->getFilter( 'hidelog' );
1069 $lognewuserFilter = $changeTypeGroup->getFilter( 'hidenewuserlog' );
1070 $pagecreationFilter = $changeTypeGroup->getFilter( 'hidenewpages' );
1072 $significanceTypeGroup = $this->getFilterGroup( 'significance' );
1073 $hideMinorFilter = $significanceTypeGroup->getFilter( 'hideminor' );
1075 if ( $categoryFilter !== null ) {
1076 $hideMinorFilter->conflictsWith(
1078 'rcfilters-hideminor-conflicts-typeofchange-global',
1079 'rcfilters-hideminor-conflicts-typeofchange',
1080 'rcfilters-typeofchange-conflicts-hideminor'
1083 $hideMinorFilter->conflictsWith(
1085 'rcfilters-hideminor-conflicts-typeofchange-global',
1086 'rcfilters-hideminor-conflicts-typeofchange',
1087 'rcfilters-typeofchange-conflicts-hideminor'
1089 $hideMinorFilter->conflictsWith(
1091 'rcfilters-hideminor-conflicts-typeofchange-global',
1092 'rcfilters-hideminor-conflicts-typeofchange',
1093 'rcfilters-typeofchange-conflicts-hideminor'
1095 $hideMinorFilter->conflictsWith(
1096 $pagecreationFilter,
1097 'rcfilters-hideminor-conflicts-typeofchange-global',
1098 'rcfilters-hideminor-conflicts-typeofchange',
1099 'rcfilters-typeofchange-conflicts-hideminor'
1104 * Transforms filter definition to prepare it for constructor.
1106 * See overrides of this method as well.
1108 * @param array $filterDefinition Original filter definition
1110 * @return array Transformed definition
1112 protected function transformFilterDefinition( array $filterDefinition ) {
1113 return $filterDefinition;
1117 * Register filters from a definition object
1119 * Array specifying groups and their filters; see Filter and
1120 * ChangesListFilterGroup constructors.
1122 * There is light processing to simplify core maintenance.
1123 * @param array $definition
1124 * @phan-param array<int,array{class:class-string<ChangesListFilterGroup>,filters:array}> $definition
1126 protected function registerFiltersFromDefinitions( array $definition ) {
1127 $autoFillPriority = -1;
1128 foreach ( $definition as $groupDefinition ) {
1129 if ( !isset( $groupDefinition['priority'] ) ) {
1130 $groupDefinition['priority'] = $autoFillPriority;
1132 // If it's explicitly specified, start over the auto-fill
1133 $autoFillPriority = $groupDefinition['priority'];
1136 $autoFillPriority--;
1138 $className = $groupDefinition['class'];
1139 unset( $groupDefinition['class'] );
1141 foreach ( $groupDefinition['filters'] as &$filterDefinition ) {
1142 $filterDefinition = $this->transformFilterDefinition( $filterDefinition );
1145 $this->registerFilterGroup( new $className( $groupDefinition ) );
1150 * @return ChangesListBooleanFilter[] The legacy show/hide toggle filters
1152 protected function getLegacyShowHideFilters() {
1154 foreach ( $this->filterGroups
as $group ) {
1155 if ( $group instanceof ChangesListBooleanFilterGroup
) {
1156 foreach ( $group->getFilters() as $key => $filter ) {
1157 if ( $filter->displaysOnUnstructuredUi() ) {
1158 $filters[ $key ] = $filter;
1167 * Register all the filters, including legacy hook-driven ones.
1168 * Then create a FormOptions object with options as specified by the user
1170 * @param string $parameters
1172 * @return FormOptions
1174 public function setup( $parameters ) {
1175 $this->registerFilters();
1177 $opts = $this->getDefaultOptions();
1179 $opts = $this->fetchOptionsFromRequest( $opts );
1181 // Give precedence to subpage syntax
1182 if ( $parameters !== null ) {
1183 $this->parseParameters( $parameters, $opts );
1186 $this->validateOptions( $opts );
1192 * Get a FormOptions object containing the default options. By default, returns
1193 * some basic options. The filters listed explicitly here are overridden in this
1194 * method, in subclasses, but most filters (e.g. hideminor, userExpLevel filters,
1195 * and more) are structured. Structured filters are overridden in registerFilters.
1198 * @return FormOptions
1200 public function getDefaultOptions() {
1201 $opts = new FormOptions();
1202 $structuredUI = $this->isStructuredFilterUiEnabled();
1203 // If urlversion=2 is set, ignore the filter defaults and set them all to false/empty
1204 $useDefaults = $this->getRequest()->getInt( 'urlversion' ) !== 2;
1206 /** @var ChangesListFilterGroup $filterGroup */
1207 foreach ( $this->filterGroups
as $filterGroup ) {
1208 $filterGroup->addOptions( $opts, $useDefaults, $structuredUI );
1211 $opts->add( 'namespace', '', FormOptions
::STRING );
1212 // TODO: Rename this option to 'invertnamespaces'?
1213 $opts->add( 'invert', false );
1214 $opts->add( 'associated', false );
1215 $opts->add( 'urlversion', 1 );
1216 $opts->add( 'tagfilter', '' );
1217 $opts->add( 'inverttags', false );
1219 $opts->add( 'days', $this->getDefaultDays(), FormOptions
::FLOAT );
1220 $opts->add( 'limit', $this->getDefaultLimit(), FormOptions
::INT );
1222 $opts->add( 'from', '' );
1228 * Register a structured changes list filter group
1230 public function registerFilterGroup( ChangesListFilterGroup
$group ) {
1231 $groupName = $group->getName();
1233 $this->filterGroups
[$groupName] = $group;
1237 * Gets the currently registered filters groups
1239 * @return ChangesListFilterGroup[] Associative array of ChangesListFilterGroup objects, with group name as key
1241 protected function getFilterGroups() {
1242 return $this->filterGroups
;
1246 * Gets a specified ChangesListFilterGroup by name
1248 * @param string $groupName Name of group
1250 * @return ChangesListFilterGroup|null Group, or null if not registered
1252 public function getFilterGroup( $groupName ) {
1253 return $this->filterGroups
[$groupName] ??
null;
1256 // Currently, this intentionally only includes filters that display
1257 // in the structured UI. This can be changed easily, though, if we want
1258 // to include data on filters that use the unstructured UI. messageKeys is a
1259 // special top-level value, with the value being an array of the message keys to
1260 // send to the client.
1263 * Gets structured filter information needed by JS
1265 * @return array Associative array
1266 * * array $return['groups'] Group data
1267 * * array $return['messageKeys'] Array of message keys
1269 public function getStructuredFilterJsData() {
1272 'messageKeys' => [],
1275 usort( $this->filterGroups
, static function ( ChangesListFilterGroup
$a, ChangesListFilterGroup
$b ) {
1276 return $b->getPriority() <=> $a->getPriority();
1279 foreach ( $this->filterGroups
as $group ) {
1280 $groupOutput = $group->getJsData();
1281 if ( $groupOutput !== null ) {
1282 $output['messageKeys'] = array_merge(
1283 $output['messageKeys'],
1284 $groupOutput['messageKeys']
1287 unset( $groupOutput['messageKeys'] );
1288 $output['groups'][] = $groupOutput;
1296 * Fetch values for a FormOptions object from the WebRequest associated with this instance.
1298 * Intended for subclassing, e.g. to add a backwards-compatibility layer.
1300 * @param FormOptions $opts
1301 * @return FormOptions
1303 protected function fetchOptionsFromRequest( $opts ) {
1304 $opts->fetchValuesFromRequest( $this->getRequest() );
1310 * Process $par and put options found in $opts. Used when including the page.
1312 * @param string $par
1313 * @param FormOptions $opts
1315 public function parseParameters( $par, FormOptions
$opts ) {
1316 $stringParameterNameSet = [];
1317 $hideParameterNameSet = [];
1319 // URL parameters can be per-group, like 'userExpLevel',
1320 // or per-filter, like 'hideminor'.
1322 foreach ( $this->filterGroups
as $filterGroup ) {
1323 if ( $filterGroup instanceof ChangesListStringOptionsFilterGroup
) {
1324 $stringParameterNameSet[$filterGroup->getName()] = true;
1325 } elseif ( $filterGroup instanceof ChangesListBooleanFilterGroup
) {
1326 foreach ( $filterGroup->getFilters() as $filter ) {
1327 $hideParameterNameSet[$filter->getName()] = true;
1332 $bits = preg_split( '/\s*,\s*/', trim( $par ) );
1333 foreach ( $bits as $bit ) {
1335 if ( isset( $hideParameterNameSet[$bit] ) ) {
1336 // hidefoo => hidefoo=true
1338 } elseif ( isset( $hideParameterNameSet["hide$bit"] ) ) {
1339 // foo => hidefoo=false
1340 $opts["hide$bit"] = false;
1341 } elseif ( preg_match( '/^(.*)=(.*)$/', $bit, $m ) ) {
1342 if ( isset( $stringParameterNameSet[$m[1]] ) ) {
1343 $opts[$m[1]] = $m[2];
1350 * Validate a FormOptions object generated by getDefaultOptions() with values already populated.
1352 public function validateOptions( FormOptions
$opts ) {
1353 $isContradictory = $this->fixContradictoryOptions( $opts );
1354 $isReplaced = $this->replaceOldOptions( $opts );
1356 if ( $isContradictory ||
$isReplaced ) {
1357 $query = wfArrayToCgi( $this->convertParamsForLink( $opts->getChangedValues() ) );
1358 $this->getOutput()->redirect( $this->getPageTitle()->getCanonicalURL( $query ) );
1361 $opts->validateIntBounds( 'limit', 0, 5000 );
1362 $opts->validateBounds( 'days', 0,
1363 $this->getConfig()->get( MainConfigNames
::RCMaxAge
) / ( 3600 * 24 ) );
1367 * Fix invalid options by resetting pairs that should never appear together.
1369 * @param FormOptions $opts
1370 * @return bool True if any option was reset
1372 private function fixContradictoryOptions( FormOptions
$opts ) {
1373 $fixed = $this->fixBackwardsCompatibilityOptions( $opts );
1375 foreach ( $this->filterGroups
as $filterGroup ) {
1376 if ( $filterGroup instanceof ChangesListBooleanFilterGroup
) {
1377 $filters = $filterGroup->getFilters();
1379 if ( count( $filters ) === 1 ) {
1380 // legacy boolean filters should not be considered
1384 $allInGroupEnabled = array_reduce(
1386 static function ( bool $carry, ChangesListBooleanFilter
$filter ) use ( $opts ) {
1387 return $carry && $opts[ $filter->getName() ];
1389 /* initialValue */ count( $filters ) > 0
1392 if ( $allInGroupEnabled ) {
1393 foreach ( $filters as $filter ) {
1394 $opts[ $filter->getName() ] = false;
1406 * Fix a special case (hideanons=1 and hideliu=1) in a special way, for backwards
1409 * This is deprecated and may be removed.
1411 * @param FormOptions $opts
1412 * @return bool True if this change was mode
1414 private function fixBackwardsCompatibilityOptions( FormOptions
$opts ) {
1415 if ( $opts['hideanons'] && $opts['hideliu'] ) {
1416 $opts->reset( 'hideanons' );
1417 if ( !$opts['hidebots'] ) {
1418 $opts->reset( 'hideliu' );
1419 $opts['hidehumans'] = 1;
1429 * Replace old options with their structured UI equivalents
1431 * @param FormOptions $opts
1432 * @return bool True if the change was made
1434 public function replaceOldOptions( FormOptions
$opts ) {
1435 if ( !$this->isStructuredFilterUiEnabled() ) {
1441 // At this point 'hideanons' and 'hideliu' cannot be both true,
1442 // because fixBackwardsCompatibilityOptions resets (at least) 'hideanons' in such case
1443 if ( $opts[ 'hideanons' ] ) {
1444 $opts->reset( 'hideanons' );
1445 $opts[ 'userExpLevel' ] = 'registered';
1449 if ( $opts[ 'hideliu' ] ) {
1450 $opts->reset( 'hideliu' );
1451 $opts[ 'userExpLevel' ] = 'unregistered';
1455 if ( $this->getFilterGroup( 'legacyReviewStatus' ) ) {
1456 if ( $opts[ 'hidepatrolled' ] ) {
1457 $opts->reset( 'hidepatrolled' );
1458 $opts[ 'reviewStatus' ] = 'unpatrolled';
1462 if ( $opts[ 'hideunpatrolled' ] ) {
1463 $opts->reset( 'hideunpatrolled' );
1464 $opts[ 'reviewStatus' ] = implode(
1465 ChangesListStringOptionsFilterGroup
::SEPARATOR
,
1466 [ 'manual', 'auto' ]
1476 * Convert parameters values from true/false to 1/0
1477 * so they are not omitted by wfArrayToCgi()
1480 * @param array $params
1483 protected function convertParamsForLink( $params ) {
1484 foreach ( $params as &$value ) {
1485 if ( $value === false ) {
1494 * Sets appropriate tables, fields, conditions, etc. depending on which filters
1495 * the user requested.
1497 * @param array &$tables Array of tables; see IReadableDatabase::select $table
1498 * @param array &$fields Array of fields; see IReadableDatabase::select $vars
1499 * @param array &$conds Array of conditions; see IReadableDatabase::select $conds
1500 * @param array &$query_options Array of query options; see IReadableDatabase::select $options
1501 * @param array &$join_conds Array of join conditions; see IReadableDatabase::select $join_conds
1502 * @param FormOptions $opts
1504 protected function buildQuery( &$tables, &$fields, &$conds, &$query_options,
1505 &$join_conds, FormOptions
$opts
1507 $dbr = $this->getDB();
1508 $isStructuredUI = $this->isStructuredFilterUiEnabled();
1510 /** @var ChangesListFilterGroup $filterGroup */
1511 foreach ( $this->filterGroups
as $filterGroup ) {
1512 $filterGroup->modifyQuery( $dbr, $this, $tables, $fields, $conds,
1513 $query_options, $join_conds, $opts, $isStructuredUI );
1516 // Namespace filtering
1517 if ( $opts[ 'namespace' ] !== '' ) {
1518 $namespaces = explode( ';', $opts[ 'namespace' ] );
1520 $namespaces = $this->expandSymbolicNamespaceFilters( $namespaces );
1522 $namespaceInfo = MediaWikiServices
::getInstance()->getNamespaceInfo();
1523 $namespaces = array_filter( $namespaces, [ $namespaceInfo, 'exists' ] );
1525 if ( $namespaces !== [] ) {
1526 // Namespaces are just ints, use them as int when acting with the database
1527 $namespaces = array_map( 'intval', $namespaces );
1529 if ( $opts[ 'associated' ] ) {
1530 $associatedNamespaces = array_map(
1531 [ $namespaceInfo, 'getAssociated' ],
1532 array_filter( $namespaces, [ $namespaceInfo, 'hasTalkNamespace' ] )
1534 $namespaces = array_unique( array_merge( $namespaces, $associatedNamespaces ) );
1537 $operator = $opts[ 'invert' ] ?
'!=' : '=';
1538 sort( $namespaces );
1539 $conds[] = $dbr->expr( 'rc_namespace', $operator, $namespaces );
1544 $cutoff_unixtime = ConvertibleTimestamp
::time() - $opts['days'] * 3600 * 24;
1545 $cutoff = $dbr->timestamp( $cutoff_unixtime );
1547 $fromValid = preg_match( '/^[0-9]{14}$/', $opts['from'] );
1548 if ( $fromValid && $opts['from'] > wfTimestamp( TS_MW
, $cutoff ) ) {
1549 $cutoff = $dbr->timestamp( $opts['from'] );
1551 $opts->reset( 'from' );
1554 $conds[] = $dbr->expr( 'rc_timestamp', '>=', $cutoff );
1560 * @param array $tables Array of tables; see IReadableDatabase::select $table
1561 * @param array $fields Array of fields; see IReadableDatabase::select $vars
1562 * @param array $conds Array of conditions; see IReadableDatabase::select $conds
1563 * @param array $query_options Array of query options; see IReadableDatabase::select $options
1564 * @param array $join_conds Array of join conditions; see IReadableDatabase::select $join_conds
1565 * @param FormOptions $opts
1566 * @return bool|IResultWrapper Result or false
1568 protected function doMainQuery( $tables, $fields, $conds,
1569 $query_options, $join_conds, FormOptions
$opts
1571 $rcQuery = RecentChange
::getQueryInfo();
1572 $tables = array_merge( $tables, $rcQuery['tables'] );
1573 $fields = array_merge( $rcQuery['fields'], $fields );
1574 $join_conds = array_merge( $join_conds, $rcQuery['joins'] );
1576 MediaWikiServices
::getInstance()->getChangeTagsStore()->modifyDisplayQuery(
1583 $opts[ 'inverttags' ]
1587 !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds, $opts )
1592 $dbr = $this->getDB();
1594 return $dbr->newSelectQueryBuilder()
1598 ->caller( __METHOD__
)
1599 ->options( $query_options )
1600 ->joinConds( $join_conds )
1604 protected function runMainQueryHook( &$tables, &$fields, &$conds,
1605 &$query_options, &$join_conds, $opts
1607 return $this->getHookRunner()->onChangesListSpecialPageQuery(
1608 $this->getName(), $tables, $fields, $conds, $query_options, $join_conds, $opts );
1612 * Which database to use for read queries
1614 protected function getDB(): IReadableDatabase
{
1615 return MediaWikiServices
::getInstance()->getConnectionProvider()->getReplicaDatabase();
1619 * Send header output to the OutputPage object, only called if not using feeds
1621 * @param int $rowCount Number of database rows
1622 * @param FormOptions $opts
1624 private function webOutputHeader( $rowCount, $opts ) {
1625 if ( !$this->including() ) {
1626 $this->outputFeedLinks();
1627 $this->doHeader( $opts, $rowCount );
1632 * Send output to the OutputPage object, only called if not used feeds
1634 * @param IResultWrapper $rows Database rows
1635 * @param FormOptions $opts
1637 public function webOutput( $rows, $opts ) {
1638 $this->webOutputHeader( $rows->numRows(), $opts );
1640 $this->outputChangesList( $rows, $opts );
1643 public function outputFeedLinks() {
1644 // nothing by default
1648 * Build and output the actual changes list.
1650 * @param IResultWrapper $rows Database rows
1651 * @param FormOptions $opts
1653 abstract public function outputChangesList( $rows, $opts );
1656 * Set the text to be displayed above the changes
1658 * @param FormOptions $opts
1659 * @param int $numRows Number of rows in the result to show after this header
1661 public function doHeader( $opts, $numRows ) {
1662 $this->setTopText( $opts );
1664 // @todo Lots of stuff should be done here.
1666 $this->setBottomText( $opts );
1670 * Send the text to be displayed before the options.
1671 * Should use $this->getOutput()->addWikiTextAsInterface()
1672 * or similar methods to print the text.
1674 public function setTopText( FormOptions
$opts ) {
1675 // nothing by default
1679 * Send the text to be displayed after the options.
1680 * Should use $this->getOutput()->addWikiTextAsInterface()
1681 * or similar methods to print the text.
1683 public function setBottomText( FormOptions
$opts ) {
1684 // nothing by default
1688 * Get options to be displayed in a form
1689 * @todo This should handle options returned by getDefaultOptions().
1690 * @todo Not called by anything in this class (but is in subclasses), should be
1691 * called by something… doHeader() maybe?
1693 * @param FormOptions $opts
1696 public function getExtraOptions( $opts ) {
1701 * Return the legend displayed within the fieldset
1705 public function makeLegend() {
1706 $context = $this->getContext();
1707 $user = $context->getUser();
1708 # The legend showing what the letters and stuff mean
1709 $legend = Html
::openElement( 'dl' ) . "\n";
1710 # Iterates through them and gets the messages for both letter and tooltip
1711 $legendItems = $context->getConfig()->get( MainConfigNames
::RecentChangesFlags
);
1712 if ( !( $user->useRCPatrol() ||
$user->useNPPatrol() ) ) {
1713 unset( $legendItems['unpatrolled'] );
1715 foreach ( $legendItems as $key => $item ) { # generate items of the legend
1716 $label = $item['legend'] ??
$item['title'];
1717 $letter = $item['letter'];
1718 $cssClass = $item['class'] ??
$key;
1720 $legend .= Html
::element( 'dt',
1721 [ 'class' => $cssClass ], $context->msg( $letter )->text()
1723 Html
::rawElement( 'dd',
1724 [ 'class' => Sanitizer
::escapeClass( 'mw-changeslist-legend-' . $key ) ],
1725 $context->msg( $label )->parse()
1729 $legend .= Html
::rawElement( 'dt',
1730 [ 'class' => 'mw-plusminus-pos' ],
1731 $context->msg( 'recentchanges-legend-plusminus' )->parse()
1733 $legend .= Html
::element(
1735 [ 'class' => 'mw-changeslist-legend-plusminus' ],
1736 $context->msg( 'recentchanges-label-plusminus' )->text()
1738 // Watchlist expiry clock icon.
1739 if ( $context->getConfig()->get( MainConfigNames
::WatchlistExpiry
) && !$this->including() ) {
1740 $widget = new IconWidget( [
1742 'classes' => [ 'mw-changesList-watchlistExpiry' ],
1744 // Link the image to its label for assistive technologies.
1745 $watchlistLabelId = 'mw-changeslist-watchlistExpiry-label';
1746 $widget->getIconElement()->setAttributes( [
1748 'aria-labelledby' => $watchlistLabelId,
1750 $legend .= Html
::rawElement(
1752 [ 'class' => 'mw-changeslist-legend-watchlistexpiry' ],
1755 $legend .= Html
::element(
1757 [ 'class' => 'mw-changeslist-legend-watchlistexpiry', 'id' => $watchlistLabelId ],
1758 $context->msg( 'recentchanges-legend-watchlistexpiry' )->text()
1761 $legend .= Html
::closeElement( 'dl' ) . "\n";
1763 $legendHeading = $this->isStructuredFilterUiEnabled() ?
1764 $context->msg( 'rcfilters-legend-heading' )->parse() :
1765 $context->msg( 'recentchanges-legend-heading' )->parse();
1768 $collapsedState = $this->getRequest()->getCookie( 'changeslist-state' );
1770 $legend = Html
::rawElement( 'details', [
1771 'class' => 'mw-changeslist-legend',
1772 'open' => $collapsedState !== 'collapsed' ?
'open' : null,
1774 Html
::rawElement( 'summary', [], $legendHeading ) .
1782 * Add page-specific modules.
1784 protected function addModules() {
1785 $out = $this->getOutput();
1786 // Styles and behavior for the legend box (see makeLegend())
1787 $out->addModuleStyles( [
1788 'mediawiki.interface.helpers.styles',
1789 'mediawiki.special.changeslist.legend',
1790 'mediawiki.special.changeslist',
1792 $out->addModules( 'mediawiki.special.changeslist.legend.js' );
1794 if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
1795 $out->addModules( 'mediawiki.rcfilters.filters.ui' );
1796 $out->addModuleStyles( 'mediawiki.rcfilters.filters.base.styles' );
1800 protected function getGroupName() {
1805 * Return expression that is true when the user is or isn't registered.
1806 * @param bool $isRegistered
1807 * @param IReadableDatabase $dbr
1808 * @return IExpression
1810 private function getRegisteredExpr( $isRegistered, $dbr ): IExpression
{
1811 $expr = $dbr->expr( 'actor_user', $isRegistered ?
'!=' : '=', null );
1812 if ( !$this->tempUserConfig
->isKnown() ) {
1815 if ( $isRegistered ) {
1816 return $expr->andExpr( $this->tempUserConfig
->getMatchCondition( $dbr,
1817 'actor_name', IExpression
::NOT_LIKE
) );
1819 return $expr->orExpr( $this->tempUserConfig
->getMatchCondition( $dbr,
1820 'actor_name', IExpression
::LIKE
) );
1825 * Return expression that is true when the user has reached the given experience level.
1826 * @param string $level 'learner' or 'experienced'
1827 * @param int $now Current time as UNIX timestamp (if 0, uses actual time)
1828 * @param IReadableDatabase $dbr
1829 * @param bool $asNotCondition
1830 * @return IExpression
1832 private function getExperienceExpr( $level, $now, IReadableDatabase
$dbr, $asNotCondition = false ): IExpression
{
1833 $config = $this->getConfig();
1836 'learner' => $config->get( MainConfigNames
::LearnerMemberSince
),
1837 'experienced' => $config->get( MainConfigNames
::ExperiencedUserMemberSince
),
1840 $now = ConvertibleTimestamp
::time();
1842 $secondsPerDay = 86400;
1843 $timeCutoff = $now - $configSince * $secondsPerDay;
1846 'learner' => $config->get( MainConfigNames
::LearnerEdits
),
1847 'experienced' => $config->get( MainConfigNames
::ExperiencedUserEdits
),
1850 if ( $asNotCondition ) {
1851 return $dbr->expr( 'user_editcount', '<', intval( $editCutoff ) )
1852 ->or( 'user_registration', '>', $dbr->timestamp( $timeCutoff ) );
1854 return $dbr->expr( 'user_editcount', '>=', intval( $editCutoff ) )->andExpr(
1855 // Users who don't have user_registration set are very old, so we assume they're above any cutoff
1856 $dbr->expr( 'user_registration', '=', null )
1857 ->or( 'user_registration', '<=', $dbr->timestamp( $timeCutoff ) )
1862 * Filter on users' experience levels; this will not be called if nothing is
1865 * @param string $specialPageClassName Class name of current special page
1866 * @param IContextSource $context Context, for e.g. user
1867 * @param IReadableDatabase $dbr Database, for addQuotes, makeList, and similar
1868 * @param array &$tables Array of tables; see IReadableDatabase::select $table
1869 * @param array &$fields Array of fields; see IReadableDatabase::select $vars
1870 * @param array &$conds Array of conditions; see IReadableDatabase::select $conds
1871 * @param array &$query_options Array of query options; see IReadableDatabase::select $options
1872 * @param array &$join_conds Array of join conditions; see IReadableDatabase::select $join_conds
1873 * @param array $selectedExpLevels The allowed active values, sorted
1874 * @param int $now Current time as UNIX timestamp (if 0, uses actual time)
1876 public function filterOnUserExperienceLevel( $specialPageClassName, $context, $dbr,
1877 &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedExpLevels, $now = 0
1879 $selected = array_fill_keys( $selectedExpLevels, true );
1881 $isUnregistered = $this->getRegisteredExpr( false, $dbr );
1882 $isRegistered = $this->getRegisteredExpr( true, $dbr );
1883 $aboveNewcomer = $this->getExperienceExpr( 'learner', $now, $dbr );
1884 $notAboveNewcomer = $this->getExperienceExpr( 'learner', $now, $dbr, true );
1885 $aboveLearner = $this->getExperienceExpr( 'experienced', $now, $dbr );
1886 $notAboveLearner = $this->getExperienceExpr( 'experienced', $now, $dbr, true );
1888 // We need to select some range of user experience levels, from the following table:
1889 // | Unregistered | --------- Registered --------- |
1890 // | | Newcomers | Learners | Experienced |
1891 // |<------------>|<----------->|<---------->|<----------->|
1892 // We just need to define a condition for each of the columns, figure out which are selected,
1893 // and then OR them together.
1895 'unregistered' => $isUnregistered,
1896 'registered' => $isRegistered,
1897 'newcomer' => $dbr->andExpr( [ $isRegistered, $notAboveNewcomer ] ),
1898 'learner' => $dbr->andExpr( [ $isRegistered, $aboveNewcomer, $notAboveLearner ] ),
1899 'experienced' => $dbr->andExpr( [ $isRegistered, $aboveLearner ] ),
1902 // There are some cases where we can easily optimize away some queries:
1903 // | Unregistered | --------- Registered --------- |
1904 // | | Newcomers | Learners | Experienced |
1905 // | |<-------------------------------------->| (1)
1906 // |<----------------------------------------------------->| (2)
1908 // (1) Selecting all of "Newcomers", "Learners" and "Experienced users" is the same as "Registered".
1910 isset( $selected['registered'] ) ||
1911 ( isset( $selected['newcomer'] ) && isset( $selected['learner'] ) && isset( $selected['experienced'] ) )
1913 unset( $selected['newcomer'], $selected['learner'], $selected['experienced'] );
1914 $selected['registered'] = true;
1916 // (2) Selecting "Unregistered" and "Registered" covers all users.
1917 if ( isset( $selected['registered'] ) && isset( $selected['unregistered'] ) ) {
1918 unset( $selected['registered'], $selected['unregistered'] );
1921 // Combine the conditions for the selected columns.
1925 $selectedColumnConds = array_values( array_intersect_key( $columnConds, $selected ) );
1926 $conds[] = $dbr->orExpr( $selectedColumnConds );
1928 // Add necessary tables to the queries.
1929 $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
1930 if ( isset( $selected['newcomer'] ) ||
isset( $selected['learner'] ) ||
isset( $selected['experienced'] ) ) {
1932 $join_conds['user'] = [ 'LEFT JOIN', 'actor_user=user_id' ];
1937 * Check whether the structured filter UI is enabled
1941 public function isStructuredFilterUiEnabled() {
1942 if ( $this->getRequest()->getBool( 'rcfilters' ) ) {
1946 return static::checkStructuredFilterUiEnabled( $this->getUser() );
1950 * Static method to check whether StructuredFilter UI is enabled for the given user
1953 * @param UserIdentity $user
1956 public static function checkStructuredFilterUiEnabled( UserIdentity
$user ) {
1957 return !MediaWikiServices
::getInstance()
1958 ->getUserOptionsLookup()
1959 ->getOption( $user, 'rcenhancedfilters-disable' );
1963 * Get the default value of the number of changes to display when loading
1969 public function getDefaultLimit() {
1970 return MediaWikiServices
::getInstance()
1971 ->getUserOptionsLookup()
1972 ->getIntOption( $this->getUser(), $this->getLimitPreferenceName() );
1976 * Get the default value of the number of days to display when loading
1978 * Supports fractional values, and should be cast to a float.
1983 public function getDefaultDays() {
1984 return floatval( MediaWikiServices
::getInstance()
1985 ->getUserOptionsLookup()
1986 ->getOption( $this->getUser(), $this->getDefaultDaysPreferenceName() ) );
1990 * Getting the preference name for 'limit'.
1995 abstract protected function getLimitPreferenceName(): string;
1998 * Preference name for saved queries.
2003 abstract protected function getSavedQueriesPreferenceName(): string;
2006 * Preference name for 'days'.
2011 abstract protected function getDefaultDaysPreferenceName(): string;
2014 * Preference name for collapsing the active filter display.
2019 abstract protected function getCollapsedPreferenceName(): string;
2022 * @param array $namespaces
2025 private function expandSymbolicNamespaceFilters( array $namespaces ) {
2026 $nsInfo = MediaWikiServices
::getInstance()->getNamespaceInfo();
2027 $symbolicFilters = [
2028 'all-contents' => $nsInfo->getSubjectNamespaces(),
2029 'all-discussions' => $nsInfo->getTalkNamespaces(),
2031 $additionalNamespaces = [];
2032 foreach ( $symbolicFilters as $name => $values ) {
2033 if ( in_array( $name, $namespaces ) ) {
2034 $additionalNamespaces = array_merge( $additionalNamespaces, $values );
2037 $namespaces = array_diff( $namespaces, array_keys( $symbolicFilters ) );
2038 $namespaces = array_merge( $namespaces, $additionalNamespaces );
2039 return array_unique( $namespaces );
2043 /** @deprecated class alias since 1.41 */
2044 class_alias( ChangesListSpecialPage
::class, 'ChangesListSpecialPage' );