MediaWiki REL1_37
ChangesListSpecialPage.php
Go to the documentation of this file.
1<?php
26use OOUI\IconWidget;
31
38abstract class ChangesListSpecialPage extends SpecialPage {
43 private const TAG_DESC_CHARACTER_LIMIT = 120;
44
50
55 protected static $daysPreferenceName;
56
61 protected static $collapsedPreferenceName;
62
64 protected $rcSubpage;
65
67 protected $rcOptions;
68
69 // Order of both groups and filters is significant; first is top-most priority,
70 // descending from there.
71 // 'showHideSuffix' is a shortcut to and avoid spelling out
72 // details specific to subclasses here.
86
92
95
98
105 protected $filterGroups = [];
106
107 public function __construct( $name, $restriction ) {
108 parent::__construct( $name, $restriction );
109
110 $nonRevisionTypes = [ RC_LOG ];
111 $this->getHookRunner()->onSpecialWatchlistGetNonRevisionTypes( $nonRevisionTypes );
112
113 $this->filterGroupDefinitions = [
114 [
115 'name' => 'registration',
116 'title' => 'rcfilters-filtergroup-registration',
117 'class' => ChangesListBooleanFilterGroup::class,
118 'filters' => [
119 [
120 'name' => 'hideliu',
121 // rcshowhideliu-show, rcshowhideliu-hide,
122 // wlshowhideliu
123 'showHideSuffix' => 'showhideliu',
124 'default' => false,
125 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
126 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
127 ) {
128 $conds['actor_user'] = null;
129 },
130 'isReplacedInStructuredUi' => true,
131
132 ],
133 [
134 'name' => 'hideanons',
135 // rcshowhideanons-show, rcshowhideanons-hide,
136 // wlshowhideanons
137 'showHideSuffix' => 'showhideanons',
138 'default' => false,
139 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
140 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
141 ) {
142 $conds[] = 'actor_user IS NOT NULL';
143 },
144 'isReplacedInStructuredUi' => true,
145 ]
146 ],
147 ],
148
149 [
150 'name' => 'userExpLevel',
151 'title' => 'rcfilters-filtergroup-user-experience-level',
152 'class' => ChangesListStringOptionsFilterGroup::class,
153 'isFullCoverage' => true,
154 'filters' => [
155 [
156 'name' => 'unregistered',
157 'label' => 'rcfilters-filter-user-experience-level-unregistered-label',
158 'description' => 'rcfilters-filter-user-experience-level-unregistered-description',
159 'cssClassSuffix' => 'user-unregistered',
160 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
161 return !$rc->getAttribute( 'rc_user' );
162 }
163 ],
164 [
165 'name' => 'registered',
166 'label' => 'rcfilters-filter-user-experience-level-registered-label',
167 'description' => 'rcfilters-filter-user-experience-level-registered-description',
168 'cssClassSuffix' => 'user-registered',
169 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
170 return $rc->getAttribute( 'rc_user' );
171 }
172 ],
173 [
174 'name' => 'newcomer',
175 'label' => 'rcfilters-filter-user-experience-level-newcomer-label',
176 'description' => 'rcfilters-filter-user-experience-level-newcomer-description',
177 'cssClassSuffix' => 'user-newcomer',
178 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
179 $performer = User::newFromIdentity( $rc->getPerformerIdentity() );
180 return $performer && $performer->isRegistered() &&
181 $performer->getExperienceLevel() === 'newcomer';
182 }
183 ],
184 [
185 'name' => 'learner',
186 'label' => 'rcfilters-filter-user-experience-level-learner-label',
187 'description' => 'rcfilters-filter-user-experience-level-learner-description',
188 'cssClassSuffix' => 'user-learner',
189 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
190 $performer = User::newFromIdentity( $rc->getPerformerIdentity() );
191 return $performer && $performer->isRegistered() &&
192 $performer->getExperienceLevel() === 'learner';
193 },
194 ],
195 [
196 'name' => 'experienced',
197 'label' => 'rcfilters-filter-user-experience-level-experienced-label',
198 'description' => 'rcfilters-filter-user-experience-level-experienced-description',
199 'cssClassSuffix' => 'user-experienced',
200 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
201 $performer = User::newFromIdentity( $rc->getPerformerIdentity() );
202 return $performer && $performer->isRegistered() &&
203 $performer->getExperienceLevel() === 'experienced';
204 },
205 ]
206 ],
208 'queryCallable' => [ $this, 'filterOnUserExperienceLevel' ],
209 ],
210
211 [
212 'name' => 'authorship',
213 'title' => 'rcfilters-filtergroup-authorship',
214 'class' => ChangesListBooleanFilterGroup::class,
215 'filters' => [
216 [
217 'name' => 'hidemyself',
218 'label' => 'rcfilters-filter-editsbyself-label',
219 'description' => 'rcfilters-filter-editsbyself-description',
220 // rcshowhidemine-show, rcshowhidemine-hide,
221 // wlshowhidemine
222 'showHideSuffix' => 'showhidemine',
223 'default' => false,
224 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
225 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
226 ) {
227 $user = $ctx->getUser();
228 $conds[] = 'actor_name<>' . $dbr->addQuotes( $user->getName() );
229 },
230 'cssClassSuffix' => 'self',
231 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
232 return $ctx->getUser()->equals( $rc->getPerformerIdentity() );
233 },
234 ],
235 [
236 'name' => 'hidebyothers',
237 'label' => 'rcfilters-filter-editsbyother-label',
238 'description' => 'rcfilters-filter-editsbyother-description',
239 'default' => false,
240 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
241 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
242 ) {
243 $user = $ctx->getUser();
244 if ( $user->isAnon() ) {
245 $conds['actor_name'] = $user->getName();
246 } else {
247 $conds['actor_user'] = $user->getId();
248 }
249 },
250 'cssClassSuffix' => 'others',
251 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
252 return !$ctx->getUser()->equals( $rc->getPerformerIdentity() );
253 },
254 ]
255 ]
256 ],
257
258 [
259 'name' => 'automated',
260 'title' => 'rcfilters-filtergroup-automated',
261 'class' => ChangesListBooleanFilterGroup::class,
262 'filters' => [
263 [
264 'name' => 'hidebots',
265 'label' => 'rcfilters-filter-bots-label',
266 'description' => 'rcfilters-filter-bots-description',
267 // rcshowhidebots-show, rcshowhidebots-hide,
268 // wlshowhidebots
269 'showHideSuffix' => 'showhidebots',
270 'default' => false,
271 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
272 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
273 ) {
274 $conds['rc_bot'] = 0;
275 },
276 'cssClassSuffix' => 'bot',
277 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
278 return $rc->getAttribute( 'rc_bot' );
279 },
280 ],
281 [
282 'name' => 'hidehumans',
283 'label' => 'rcfilters-filter-humans-label',
284 'description' => 'rcfilters-filter-humans-description',
285 'default' => false,
286 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
287 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
288 ) {
289 $conds['rc_bot'] = 1;
290 },
291 'cssClassSuffix' => 'human',
292 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
293 return !$rc->getAttribute( 'rc_bot' );
294 },
295 ]
296 ]
297 ],
298
299 // significance (conditional)
300
301 [
302 'name' => 'significance',
303 'title' => 'rcfilters-filtergroup-significance',
304 'class' => ChangesListBooleanFilterGroup::class,
305 'priority' => -6,
306 'filters' => [
307 [
308 'name' => 'hideminor',
309 'label' => 'rcfilters-filter-minor-label',
310 'description' => 'rcfilters-filter-minor-description',
311 // rcshowhideminor-show, rcshowhideminor-hide,
312 // wlshowhideminor
313 'showHideSuffix' => 'showhideminor',
314 'default' => false,
315 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
316 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
317 ) {
318 $conds[] = 'rc_minor = 0';
319 },
320 'cssClassSuffix' => 'minor',
321 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
322 return $rc->getAttribute( 'rc_minor' );
323 }
324 ],
325 [
326 'name' => 'hidemajor',
327 'label' => 'rcfilters-filter-major-label',
328 'description' => 'rcfilters-filter-major-description',
329 'default' => false,
330 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
331 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
332 ) {
333 $conds[] = 'rc_minor = 1';
334 },
335 'cssClassSuffix' => 'major',
336 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
337 return !$rc->getAttribute( 'rc_minor' );
338 }
339 ]
340 ]
341 ],
342
343 [
344 'name' => 'lastRevision',
345 'title' => 'rcfilters-filtergroup-lastrevision',
346 'class' => ChangesListBooleanFilterGroup::class,
347 'priority' => -7,
348 'filters' => [
349 [
350 'name' => 'hidelastrevision',
351 'label' => 'rcfilters-filter-lastrevision-label',
352 'description' => 'rcfilters-filter-lastrevision-description',
353 'default' => false,
354 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
355 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
356 ) use ( $nonRevisionTypes ) {
357 $conds[] = $dbr->makeList(
358 [
359 'rc_this_oldid <> page_latest',
360 'rc_type' => $nonRevisionTypes,
361 ],
362 LIST_OR
363 );
364 },
365 'cssClassSuffix' => 'last',
366 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
367 return $rc->getAttribute( 'rc_this_oldid' ) === $rc->getAttribute( 'page_latest' );
368 }
369 ],
370 [
371 'name' => 'hidepreviousrevisions',
372 'label' => 'rcfilters-filter-previousrevision-label',
373 'description' => 'rcfilters-filter-previousrevision-description',
374 'default' => false,
375 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
376 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
377 ) use ( $nonRevisionTypes ) {
378 $conds[] = $dbr->makeList(
379 [
380 'rc_this_oldid = page_latest',
381 'rc_type' => $nonRevisionTypes,
382 ],
383 LIST_OR
384 );
385 },
386 'cssClassSuffix' => 'previous',
387 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
388 return $rc->getAttribute( 'rc_this_oldid' ) !== $rc->getAttribute( 'page_latest' );
389 }
390 ]
391 ]
392 ],
393
394 // With extensions, there can be change types that will not be hidden by any of these.
395 [
396 'name' => 'changeType',
397 'title' => 'rcfilters-filtergroup-changetype',
398 'class' => ChangesListBooleanFilterGroup::class,
399 'priority' => -8,
400 'filters' => [
401 [
402 'name' => 'hidepageedits',
403 'label' => 'rcfilters-filter-pageedits-label',
404 'description' => 'rcfilters-filter-pageedits-description',
405 'default' => false,
406 'priority' => -2,
407 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
408 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
409 ) {
410 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_EDIT );
411 },
412 'cssClassSuffix' => 'src-mw-edit',
413 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
414 return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_EDIT;
415 },
416 ],
417 [
418 'name' => 'hidenewpages',
419 'label' => 'rcfilters-filter-newpages-label',
420 'description' => 'rcfilters-filter-newpages-description',
421 'default' => false,
422 'priority' => -3,
423 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
424 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
425 ) {
426 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_NEW );
427 },
428 'cssClassSuffix' => 'src-mw-new',
429 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
430 return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_NEW;
431 },
432 ],
433
434 // hidecategorization
435
436 [
437 'name' => 'hidelog',
438 'label' => 'rcfilters-filter-logactions-label',
439 'description' => 'rcfilters-filter-logactions-description',
440 'default' => false,
441 'priority' => -5,
442 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
443 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
444 ) {
445 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_LOG );
446 },
447 'cssClassSuffix' => 'src-mw-log',
448 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
449 return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_LOG;
450 }
451 ],
452 ],
453 ],
454
455 ];
456
457 $this->legacyReviewStatusFilterGroupDefinition = [
458 [
459 'name' => 'legacyReviewStatus',
460 'title' => 'rcfilters-filtergroup-reviewstatus',
461 'class' => ChangesListBooleanFilterGroup::class,
462 'filters' => [
463 [
464 'name' => 'hidepatrolled',
465 // rcshowhidepatr-show, rcshowhidepatr-hide
466 // wlshowhidepatr
467 'showHideSuffix' => 'showhidepatr',
468 'default' => false,
469 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
470 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
471 ) {
472 $conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED;
473 },
474 'isReplacedInStructuredUi' => true,
475 ],
476 [
477 'name' => 'hideunpatrolled',
478 'default' => false,
479 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
480 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
481 ) {
482 $conds[] = 'rc_patrolled != ' . RecentChange::PRC_UNPATROLLED;
483 },
484 'isReplacedInStructuredUi' => true,
485 ],
486 ],
487 ]
488 ];
489
490 $this->reviewStatusFilterGroupDefinition = [
491 [
492 'name' => 'reviewStatus',
493 'title' => 'rcfilters-filtergroup-reviewstatus',
494 'class' => ChangesListStringOptionsFilterGroup::class,
495 'isFullCoverage' => true,
496 'priority' => -5,
497 'filters' => [
498 [
499 'name' => 'unpatrolled',
500 'label' => 'rcfilters-filter-reviewstatus-unpatrolled-label',
501 'description' => 'rcfilters-filter-reviewstatus-unpatrolled-description',
502 'cssClassSuffix' => 'reviewstatus-unpatrolled',
503 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
504 return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED;
505 },
506 ],
507 [
508 'name' => 'manual',
509 'label' => 'rcfilters-filter-reviewstatus-manual-label',
510 'description' => 'rcfilters-filter-reviewstatus-manual-description',
511 'cssClassSuffix' => 'reviewstatus-manual',
512 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
513 return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_PATROLLED;
514 },
515 ],
516 [
517 'name' => 'auto',
518 'label' => 'rcfilters-filter-reviewstatus-auto-label',
519 'description' => 'rcfilters-filter-reviewstatus-auto-description',
520 'cssClassSuffix' => 'reviewstatus-auto',
521 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
522 return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_AUTOPATROLLED;
523 },
524 ],
525 ],
527 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
528 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selected
529 ) {
530 if ( $selected === [] ) {
531 return;
532 }
533 $rcPatrolledValues = [
534 'unpatrolled' => RecentChange::PRC_UNPATROLLED,
535 'manual' => RecentChange::PRC_PATROLLED,
536 'auto' => RecentChange::PRC_AUTOPATROLLED,
537 ];
538 // e.g. rc_patrolled IN (0, 2)
539 $conds['rc_patrolled'] = array_map( static function ( $s ) use ( $rcPatrolledValues ) {
540 return $rcPatrolledValues[ $s ];
541 }, $selected );
542 }
543 ]
544 ];
545
546 $this->hideCategorizationFilterDefinition = [
547 'name' => 'hidecategorization',
548 'label' => 'rcfilters-filter-categorization-label',
549 'description' => 'rcfilters-filter-categorization-description',
550 // rcshowhidecategorization-show, rcshowhidecategorization-hide.
551 // wlshowhidecategorization
552 'showHideSuffix' => 'showhidecategorization',
553 'default' => false,
554 'priority' => -4,
555 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
556 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
557 ) {
558 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_CATEGORIZE );
559 },
560 'cssClassSuffix' => 'src-mw-categorize',
561 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
562 return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_CATEGORIZE;
563 },
564 ];
565 }
566
572 protected function areFiltersInConflict() {
573 $opts = $this->getOptions();
575 foreach ( $this->getFilterGroups() as $group ) {
576 if ( $group->getConflictingGroups() ) {
578 $group->getName() .
579 " specifies conflicts with other groups but these are not supported yet."
580 );
581 }
582
584 foreach ( $group->getConflictingFilters() as $conflictingFilter ) {
585 if ( $conflictingFilter->activelyInConflictWithGroup( $group, $opts ) ) {
586 return true;
587 }
588 }
589
591 foreach ( $group->getFilters() as $filter ) {
593 foreach ( $filter->getConflictingFilters() as $conflictingFilter ) {
594 if (
595 $conflictingFilter->activelyInConflictWithFilter( $filter, $opts ) &&
596 $filter->activelyInConflictWithFilter( $conflictingFilter, $opts )
597 ) {
598 return true;
599 }
600 }
601
602 }
603
604 }
605
606 return false;
607 }
608
612 public function execute( $subpage ) {
613 $this->rcSubpage = $subpage;
614
615 $this->considerActionsForDefaultSavedQuery( $subpage );
616
617 // Enable OOUI and module for the clock icon.
618 if ( $this->getConfig()->get( 'WatchlistExpiry' ) ) {
619 $this->getOutput()->enableOOUI();
620 $this->getOutput()->addModules( 'mediawiki.special.changeslist.watchlistexpiry' );
621 }
622
623 $opts = $this->getOptions();
624 try {
625 $rows = $this->getRows();
626 if ( $rows === false ) {
627 $rows = new FakeResultWrapper( [] );
628 }
629
630 // Used by Structured UI app to get results without MW chrome
631 if ( $this->getRequest()->getRawVal( 'action' ) === 'render' ) {
632 $this->getOutput()->setArticleBodyOnly( true );
633 }
634
635 // Used by "live update" and "view newest" to check
636 // if there's new changes with minimal data transfer
637 if ( $this->getRequest()->getBool( 'peek' ) ) {
638 $code = $rows->numRows() > 0 ? 200 : 204;
639 $this->getOutput()->setStatusCode( $code );
640
641 if ( $this->getUser()->isAnon() !==
642 $this->getRequest()->getFuzzyBool( 'isAnon' )
643 ) {
644 $this->getOutput()->setStatusCode( 205 );
645 }
646
647 return;
648 }
649
650 $linkBatchFactory = MediaWikiServices::getInstance()->getLinkBatchFactory();
651 $batch = $linkBatchFactory->newLinkBatch();
652 foreach ( $rows as $row ) {
653 $batch->add( NS_USER, $row->rc_user_text );
654 $batch->add( NS_USER_TALK, $row->rc_user_text );
655 $batch->add( $row->rc_namespace, $row->rc_title );
656 if ( $row->rc_source === RecentChange::SRC_LOG ) {
657 $formatter = LogFormatter::newFromRow( $row );
658 foreach ( $formatter->getPreloadTitles() as $title ) {
659 $batch->addObj( $title );
660 }
661 }
662 }
663 $batch->execute();
664
665 $this->setHeaders();
666 $this->outputHeader();
667 $this->addModules();
668 $this->webOutput( $rows, $opts );
669
670 $rows->free();
671 } catch ( DBQueryTimeoutError $timeoutException ) {
672 MWExceptionHandler::logException( $timeoutException );
673
674 $this->setHeaders();
675 $this->outputHeader();
676 $this->addModules();
677
678 $this->getOutput()->setStatusCode( 500 );
679 $this->webOutputHeader( 0, $opts );
680 $this->outputTimeout();
681 }
682
683 if ( $this->getConfig()->get( 'EnableWANCacheReaper' ) ) {
684 // Clean up any bad page entries for titles showing up in RC
685 DeferredUpdates::addUpdate( new WANCacheReapUpdate(
686 $this->getDB(),
687 LoggerFactory::getInstance( 'objectcache' )
688 ) );
689 }
690
691 $this->includeRcFiltersApp();
692 }
693
701 protected function considerActionsForDefaultSavedQuery( $subpage ) {
702 if ( !$this->isStructuredFilterUiEnabled() || $this->including() ) {
703 return;
704 }
705
706 $knownParams = $this->getRequest()->getValues(
707 ...array_keys( $this->getOptions()->getAllValues() )
708 );
709
710 // HACK: Temporarily until we can properly define "sticky" filters and parameters,
711 // we need to exclude several parameters we know should not be counted towards preventing
712 // the loading of defaults.
713 $excludedParams = [ 'limit' => '', 'days' => '', 'enhanced' => '', 'from' => '' ];
714 $knownParams = array_diff_key( $knownParams, $excludedParams );
715
716 if (
717 // If there are NO known parameters in the URL request
718 // (that are not excluded) then we need to check into loading
719 // the default saved query
720 count( $knownParams ) === 0
721 ) {
722 $prefJson = $this->getUser()->getOption( static::$savedQueriesPreferenceName );
723
724 // Get the saved queries data and parse it
725 $savedQueries = $prefJson ? FormatJson::decode( $prefJson, true ) : false;
726
727 if ( $savedQueries && isset( $savedQueries[ 'default' ] ) ) {
728 // Only load queries that are 'version' 2, since those
729 // have parameter representation
730 if ( isset( $savedQueries[ 'version' ] ) && $savedQueries[ 'version' ] === '2' ) {
731 $savedQueryDefaultID = $savedQueries[ 'default' ];
732 $defaultQuery = $savedQueries[ 'queries' ][ $savedQueryDefaultID ][ 'data' ];
733
734 // Build the entire parameter list
735 $query = array_merge(
736 $defaultQuery[ 'params' ],
737 $defaultQuery[ 'highlights' ],
738 [
739 'urlversion' => '2',
740 ]
741 );
742 // Add to the query any parameters that we may have ignored before
743 // but are still valid and requested in the URL
744 $query = array_merge( $this->getRequest()->getValues(), $query );
745 unset( $query[ 'title' ] );
746 $this->getOutput()->redirect( $this->getPageTitle( $subpage )->getCanonicalURL( $query ) );
747 } else {
748 // There's a default, but the version is not 2, and the server can't
749 // actually recognize the query itself. This happens if it is before
750 // the conversion, so we need to tell the UI to reload saved query as
751 // it does the conversion to version 2
752 $this->getOutput()->addJsConfigVars(
753 'wgStructuredChangeFiltersDefaultSavedQueryExists',
754 true
755 );
756
757 // Add the class that tells the frontend it is still loading
758 // another query
759 $this->getOutput()->addBodyClasses( 'mw-rcfilters-ui-loading' );
760 }
761 }
762 }
763 }
764
770 protected function getLinkDays() {
771 $linkDays = $this->getConfig()->get( 'RCLinkDays' );
772 $filterByAge = $this->getConfig()->get( 'RCFilterByAge' );
773 $maxAge = $this->getConfig()->get( 'RCMaxAge' );
774 if ( $filterByAge ) {
775 // Trim it to only links which are within $wgRCMaxAge.
776 // Note that we allow one link higher than the max for things like
777 // "age 56 days" being accessible through the "60 days" link.
778 sort( $linkDays );
779
780 $maxAgeDays = $maxAge / ( 3600 * 24 );
781 foreach ( $linkDays as $i => $days ) {
782 if ( $days >= $maxAgeDays ) {
783 array_splice( $linkDays, $i + 1 );
784 break;
785 }
786 }
787 }
788
789 return $linkDays;
790 }
791
798 protected function includeRcFiltersApp() {
799 $out = $this->getOutput();
800 if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
801 $jsData = $this->getStructuredFilterJsData();
802 $messages = [];
803 foreach ( $jsData['messageKeys'] as $key ) {
804 $messages[$key] = $this->msg( $key )->plain();
805 }
806
807 $out->addBodyClasses( 'mw-rcfilters-enabled' );
808 $collapsed = MediaWikiServices::getInstance()->getUserOptionsLookup()
809 ->getBoolOption( $this->getUser(), static::$collapsedPreferenceName );
810 if ( $collapsed ) {
811 $out->addBodyClasses( 'mw-rcfilters-collapsed' );
812 }
813
814 // These config and message exports should be moved into a ResourceLoader data module (T201574)
815 $out->addJsConfigVars( 'wgStructuredChangeFilters', $jsData['groups'] );
816 $out->addJsConfigVars( 'wgStructuredChangeFiltersMessages', $messages );
817 $out->addJsConfigVars( 'wgStructuredChangeFiltersCollapsedState', $collapsed );
818
819 $out->addJsConfigVars(
820 'StructuredChangeFiltersDisplayConfig',
821 [
822 'maxDays' => (int)$this->getConfig()->get( 'RCMaxAge' ) / ( 24 * 3600 ), // Translate to days
823 'limitArray' => $this->getConfig()->get( 'RCLinkLimits' ),
824 'limitDefault' => $this->getDefaultLimit(),
825 'daysArray' => $this->getLinkDays(),
826 'daysDefault' => $this->getDefaultDays(),
827 ]
828 );
829
830 $out->addJsConfigVars(
831 'wgStructuredChangeFiltersSavedQueriesPreferenceName',
832 static::$savedQueriesPreferenceName
833 );
834 $out->addJsConfigVars(
835 'wgStructuredChangeFiltersLimitPreferenceName',
837 );
838 $out->addJsConfigVars(
839 'wgStructuredChangeFiltersDaysPreferenceName',
840 static::$daysPreferenceName
841 );
842 $out->addJsConfigVars(
843 'wgStructuredChangeFiltersCollapsedPreferenceName',
844 static::$collapsedPreferenceName
845 );
846 } else {
847 $out->addBodyClasses( 'mw-rcfilters-disabled' );
848 }
849 }
850
859 public static function getRcFiltersConfigSummary( ResourceLoaderContext $context ) {
860 return [
861 // Reduce version computation by avoiding Message parsing
862 'RCFiltersChangeTags' => self::getChangeTagListSummary( $context ),
863 'StructuredChangeFiltersEditWatchlistUrl' =>
864 SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
865 ];
866 }
867
875 public static function getRcFiltersConfigVars( ResourceLoaderContext $context ) {
876 return [
877 'RCFiltersChangeTags' => self::getChangeTagList( $context ),
878 'StructuredChangeFiltersEditWatchlistUrl' =>
879 SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
880 ];
881 }
882
903 protected static function getChangeTagListSummary( ResourceLoaderContext $context ) {
904 $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
905 return $cache->getWithSetCallback(
906 $cache->makeKey( 'ChangesListSpecialPage-changeTagListSummary', $context->getLanguage() ),
907 WANObjectCache::TTL_DAY,
908 static function ( $oldValue, &$ttl, array &$setOpts ) use ( $context ) {
909 $explicitlyDefinedTags = array_fill_keys( ChangeTags::listExplicitlyDefinedTags(), 0 );
910 $softwareActivatedTags = array_fill_keys( ChangeTags::listSoftwareActivatedTags(), 0 );
911
912 $tagStats = ChangeTags::tagUsageStatistics();
913 $tagHitCounts = array_merge( $explicitlyDefinedTags, $softwareActivatedTags, $tagStats );
914
915 $result = [];
916 foreach ( $tagHitCounts as $tagName => $hits ) {
917 if (
918 (
919 // Only get active tags
920 isset( $explicitlyDefinedTags[ $tagName ] ) ||
921 isset( $softwareActivatedTags[ $tagName ] )
922 ) &&
923 // Only get tags with more than 0 hits
924 $hits > 0
925 ) {
926 $labelMsg = ChangeTags::tagShortDescriptionMessage( $tagName, $context );
927 $descriptionMsg = ChangeTags::tagLongDescriptionMessage( $tagName, $context );
928 $result[] = [
929 'name' => $tagName,
930 'labelMsg' => $labelMsg,
931 'label' => $labelMsg ? $labelMsg->plain() : $tagName,
932 'descriptionMsg' => $descriptionMsg,
933 'description' => $descriptionMsg ? $descriptionMsg->plain() : '',
934 'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ),
935 'hits' => $hits,
936 ];
937 }
938 }
939 return $result;
940 }
941 );
942 }
943
957 protected static function getChangeTagList( ResourceLoaderContext $context ) {
958 $tags = self::getChangeTagListSummary( $context );
959 $language = MediaWikiServices::getInstance()->getLanguageFactory()
960 ->getLanguage( $context->getLanguage() );
961 foreach ( $tags as &$tagInfo ) {
962 if ( $tagInfo['labelMsg'] ) {
963 $tagInfo['label'] = Sanitizer::stripAllTags( $tagInfo['labelMsg']->parse() );
964 } else {
965 $tagInfo['label'] = $context->msg( 'rcfilters-tag-hidden', $tagInfo['name'] )->text();
966 }
967 $tagInfo['description'] = $tagInfo['descriptionMsg'] ?
968 $language->truncateForVisual(
969 Sanitizer::stripAllTags( $tagInfo['descriptionMsg']->parse() ),
970 self::TAG_DESC_CHARACTER_LIMIT
971 ) :
972 '';
973 unset( $tagInfo['labelMsg'] );
974 unset( $tagInfo['descriptionMsg'] );
975 }
976
977 // Instead of sorting by hit count (disabled for now), sort by display name
978 usort( $tags, static function ( $a, $b ) {
979 return strcasecmp( $a['label'], $b['label'] );
980 } );
981 return $tags;
982 }
983
987 protected function outputNoResults() {
988 $this->getOutput()->addHTML(
989 Html::rawElement(
990 'div',
991 [ 'class' => 'mw-changeslist-empty' ],
992 $this->msg( 'recentchanges-noresult' )->parse()
993 )
994 );
995 }
996
1000 protected function outputTimeout() {
1001 $this->getOutput()->addHTML(
1002 '<div class="mw-changeslist-empty mw-changeslist-timeout">' .
1003 $this->msg( 'recentchanges-timeout' )->parse() .
1004 '</div>'
1005 );
1006 }
1007
1013 public function getRows() {
1014 $opts = $this->getOptions();
1015
1016 $tables = [];
1017 $fields = [];
1018 $conds = [];
1019 $query_options = [];
1020 $join_conds = [];
1021 $this->buildQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
1022
1023 return $this->doMainQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
1024 }
1025
1031 public function getOptions() {
1032 if ( $this->rcOptions === null ) {
1033 $this->rcOptions = $this->setup( $this->rcSubpage );
1034 }
1035
1036 return $this->rcOptions;
1037 }
1038
1048 protected function registerFilters() {
1049 $this->registerFiltersFromDefinitions( $this->filterGroupDefinitions );
1050
1051 // Make sure this is not being transcluded (we don't want to show this
1052 // information to all users just because the user that saves the edit can
1053 // patrol or is logged in)
1054 if ( !$this->including() && $this->getUser()->useRCPatrol() ) {
1055 $this->registerFiltersFromDefinitions( $this->legacyReviewStatusFilterGroupDefinition );
1056 $this->registerFiltersFromDefinitions( $this->reviewStatusFilterGroupDefinition );
1057 }
1058
1059 $changeTypeGroup = $this->getFilterGroup( 'changeType' );
1060
1061 if ( $this->getConfig()->get( 'RCWatchCategoryMembership' ) ) {
1062 $transformedHideCategorizationDef = $this->transformFilterDefinition(
1063 $this->hideCategorizationFilterDefinition
1064 );
1065
1066 $transformedHideCategorizationDef['group'] = $changeTypeGroup;
1067
1068 $hideCategorization = new ChangesListBooleanFilter(
1069 $transformedHideCategorizationDef
1070 );
1071 }
1072
1073 $this->getHookRunner()->onChangesListSpecialPageStructuredFilters( $this );
1074
1075 $this->registerFiltersFromDefinitions( [] );
1076
1077 $userExperienceLevel = $this->getFilterGroup( 'userExpLevel' );
1078 $registered = $userExperienceLevel->getFilter( 'registered' );
1079 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'newcomer' ) );
1080 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'learner' ) );
1081 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'experienced' ) );
1082
1083 $categoryFilter = $changeTypeGroup->getFilter( 'hidecategorization' );
1084 $logactionsFilter = $changeTypeGroup->getFilter( 'hidelog' );
1085 $pagecreationFilter = $changeTypeGroup->getFilter( 'hidenewpages' );
1086
1087 $significanceTypeGroup = $this->getFilterGroup( 'significance' );
1088 $hideMinorFilter = $significanceTypeGroup->getFilter( 'hideminor' );
1089
1090 // categoryFilter is conditional; see registerFilters
1091 if ( $categoryFilter !== null ) {
1092 $hideMinorFilter->conflictsWith(
1093 $categoryFilter,
1094 'rcfilters-hideminor-conflicts-typeofchange-global',
1095 'rcfilters-hideminor-conflicts-typeofchange',
1096 'rcfilters-typeofchange-conflicts-hideminor'
1097 );
1098 }
1099 $hideMinorFilter->conflictsWith(
1100 $logactionsFilter,
1101 'rcfilters-hideminor-conflicts-typeofchange-global',
1102 'rcfilters-hideminor-conflicts-typeofchange',
1103 'rcfilters-typeofchange-conflicts-hideminor'
1104 );
1105 $hideMinorFilter->conflictsWith(
1106 $pagecreationFilter,
1107 'rcfilters-hideminor-conflicts-typeofchange-global',
1108 'rcfilters-hideminor-conflicts-typeofchange',
1109 'rcfilters-typeofchange-conflicts-hideminor'
1110 );
1111 }
1112
1122 protected function transformFilterDefinition( array $filterDefinition ) {
1123 return $filterDefinition;
1124 }
1125
1136 protected function registerFiltersFromDefinitions( array $definition ) {
1137 $autoFillPriority = -1;
1138 foreach ( $definition as $groupDefinition ) {
1139 if ( !isset( $groupDefinition['priority'] ) ) {
1140 $groupDefinition['priority'] = $autoFillPriority;
1141 } else {
1142 // If it's explicitly specified, start over the auto-fill
1143 $autoFillPriority = $groupDefinition['priority'];
1144 }
1145
1146 $autoFillPriority--;
1147
1148 $className = $groupDefinition['class'];
1149 unset( $groupDefinition['class'] );
1150
1151 foreach ( $groupDefinition['filters'] as &$filterDefinition ) {
1152 $filterDefinition = $this->transformFilterDefinition( $filterDefinition );
1153 }
1154
1155 $this->registerFilterGroup( new $className( $groupDefinition ) );
1156 }
1157 }
1158
1162 protected function getLegacyShowHideFilters() {
1163 $filters = [];
1164 foreach ( $this->filterGroups as $group ) {
1165 if ( $group instanceof ChangesListBooleanFilterGroup ) {
1166 foreach ( $group->getFilters() as $key => $filter ) {
1167 if ( $filter->displaysOnUnstructuredUi() ) {
1168 $filters[ $key ] = $filter;
1169 }
1170 }
1171 }
1172 }
1173 return $filters;
1174 }
1175
1184 public function setup( $parameters ) {
1185 $this->registerFilters();
1186
1187 $opts = $this->getDefaultOptions();
1188
1189 $opts = $this->fetchOptionsFromRequest( $opts );
1190
1191 // Give precedence to subpage syntax
1192 if ( $parameters !== null ) {
1193 $this->parseParameters( $parameters, $opts );
1194 }
1195
1196 $this->validateOptions( $opts );
1197
1198 return $opts;
1199 }
1200
1210 public function getDefaultOptions() {
1211 $opts = new FormOptions();
1212 $structuredUI = $this->isStructuredFilterUiEnabled();
1213 // If urlversion=2 is set, ignore the filter defaults and set them all to false/empty
1214 $useDefaults = $this->getRequest()->getInt( 'urlversion' ) !== 2;
1215
1217 foreach ( $this->filterGroups as $filterGroup ) {
1218 $filterGroup->addOptions( $opts, $useDefaults, $structuredUI );
1219 }
1220
1221 $opts->add( 'namespace', '', FormOptions::STRING );
1222 $opts->add( 'invert', false );
1223 $opts->add( 'associated', false );
1224 $opts->add( 'urlversion', 1 );
1225 $opts->add( 'tagfilter', '' );
1226
1227 $opts->add( 'days', $this->getDefaultDays(), FormOptions::FLOAT );
1228 $opts->add( 'limit', $this->getDefaultLimit(), FormOptions::INT );
1229
1230 $opts->add( 'from', '' );
1231
1232 return $opts;
1233 }
1234
1241 $groupName = $group->getName();
1242
1243 $this->filterGroups[$groupName] = $group;
1244 }
1245
1251 protected function getFilterGroups() {
1252 return $this->filterGroups;
1253 }
1254
1262 public function getFilterGroup( $groupName ) {
1263 return $this->filterGroups[$groupName] ?? null;
1264 }
1265
1266 // Currently, this intentionally only includes filters that display
1267 // in the structured UI. This can be changed easily, though, if we want
1268 // to include data on filters that use the unstructured UI. messageKeys is a
1269 // special top-level value, with the value being an array of the message keys to
1270 // send to the client.
1271
1279 public function getStructuredFilterJsData() {
1280 $output = [
1281 'groups' => [],
1282 'messageKeys' => [],
1283 ];
1284
1285 usort( $this->filterGroups, static function ( ChangesListFilterGroup $a, ChangesListFilterGroup $b ) {
1286 return $b->getPriority() <=> $a->getPriority();
1287 } );
1288
1289 foreach ( $this->filterGroups as $groupName => $group ) {
1290 $groupOutput = $group->getJsData();
1291 if ( $groupOutput !== null ) {
1292 $output['messageKeys'] = array_merge(
1293 $output['messageKeys'],
1294 $groupOutput['messageKeys']
1295 );
1296
1297 unset( $groupOutput['messageKeys'] );
1298 $output['groups'][] = $groupOutput;
1299 }
1300 }
1301
1302 return $output;
1303 }
1304
1313 protected function fetchOptionsFromRequest( $opts ) {
1314 $opts->fetchValuesFromRequest( $this->getRequest() );
1315
1316 return $opts;
1317 }
1318
1325 public function parseParameters( $par, FormOptions $opts ) {
1326 $stringParameterNameSet = [];
1327 $hideParameterNameSet = [];
1328
1329 // URL parameters can be per-group, like 'userExpLevel',
1330 // or per-filter, like 'hideminor'.
1331
1332 foreach ( $this->filterGroups as $filterGroup ) {
1333 if ( $filterGroup instanceof ChangesListStringOptionsFilterGroup ) {
1334 $stringParameterNameSet[$filterGroup->getName()] = true;
1335 } elseif ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
1336 foreach ( $filterGroup->getFilters() as $filter ) {
1337 $hideParameterNameSet[$filter->getName()] = true;
1338 }
1339 }
1340 }
1341
1342 $bits = preg_split( '/\s*,\s*/', trim( $par ) );
1343 foreach ( $bits as $bit ) {
1344 $m = [];
1345 if ( isset( $hideParameterNameSet[$bit] ) ) {
1346 // hidefoo => hidefoo=true
1347 $opts[$bit] = true;
1348 } elseif ( isset( $hideParameterNameSet["hide$bit"] ) ) {
1349 // foo => hidefoo=false
1350 $opts["hide$bit"] = false;
1351 } elseif ( preg_match( '/^(.*)=(.*)$/', $bit, $m ) ) {
1352 if ( isset( $stringParameterNameSet[$m[1]] ) ) {
1353 $opts[$m[1]] = $m[2];
1354 }
1355 }
1356 }
1357 }
1358
1364 public function validateOptions( FormOptions $opts ) {
1365 $isContradictory = $this->fixContradictoryOptions( $opts );
1366 $isReplaced = $this->replaceOldOptions( $opts );
1367
1368 if ( $isContradictory || $isReplaced ) {
1369 $query = wfArrayToCgi( $this->convertParamsForLink( $opts->getChangedValues() ) );
1370 $this->getOutput()->redirect( $this->getPageTitle()->getCanonicalURL( $query ) );
1371 }
1372
1373 $opts->validateIntBounds( 'limit', 0, 5000 );
1374 $opts->validateBounds( 'days', 0, $this->getConfig()->get( 'RCMaxAge' ) / ( 3600 * 24 ) );
1375 }
1376
1383 private function fixContradictoryOptions( FormOptions $opts ) {
1384 $fixed = $this->fixBackwardsCompatibilityOptions( $opts );
1385
1386 foreach ( $this->filterGroups as $filterGroup ) {
1387 if ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
1388 $filters = $filterGroup->getFilters();
1389
1390 if ( count( $filters ) === 1 ) {
1391 // legacy boolean filters should not be considered
1392 continue;
1393 }
1394
1395 $allInGroupEnabled = array_reduce(
1396 $filters,
1397 static function ( bool $carry, ChangesListBooleanFilter $filter ) use ( $opts ) {
1398 return $carry && $opts[ $filter->getName() ];
1399 },
1400 /* initialValue */ count( $filters ) > 0
1401 );
1402
1403 if ( $allInGroupEnabled ) {
1404 foreach ( $filters as $filter ) {
1405 $opts[ $filter->getName() ] = false;
1406 }
1407
1408 $fixed = true;
1409 }
1410 }
1411 }
1412
1413 return $fixed;
1414 }
1415
1426 if ( $opts['hideanons'] && $opts['hideliu'] ) {
1427 $opts->reset( 'hideanons' );
1428 if ( !$opts['hidebots'] ) {
1429 $opts->reset( 'hideliu' );
1430 $opts['hidehumans'] = 1;
1431 }
1432
1433 return true;
1434 }
1435
1436 return false;
1437 }
1438
1445 public function replaceOldOptions( FormOptions $opts ) {
1446 if ( !$this->isStructuredFilterUiEnabled() ) {
1447 return false;
1448 }
1449
1450 $changed = false;
1451
1452 // At this point 'hideanons' and 'hideliu' cannot be both true,
1453 // because fixBackwardsCompatibilityOptions resets (at least) 'hideanons' in such case
1454 if ( $opts[ 'hideanons' ] ) {
1455 $opts->reset( 'hideanons' );
1456 $opts[ 'userExpLevel' ] = 'registered';
1457 $changed = true;
1458 }
1459
1460 if ( $opts[ 'hideliu' ] ) {
1461 $opts->reset( 'hideliu' );
1462 $opts[ 'userExpLevel' ] = 'unregistered';
1463 $changed = true;
1464 }
1465
1466 if ( $this->getFilterGroup( 'legacyReviewStatus' ) ) {
1467 if ( $opts[ 'hidepatrolled' ] ) {
1468 $opts->reset( 'hidepatrolled' );
1469 $opts[ 'reviewStatus' ] = 'unpatrolled';
1470 $changed = true;
1471 }
1472
1473 if ( $opts[ 'hideunpatrolled' ] ) {
1474 $opts->reset( 'hideunpatrolled' );
1475 $opts[ 'reviewStatus' ] = implode(
1477 [ 'manual', 'auto' ]
1478 );
1479 $changed = true;
1480 }
1481 }
1482
1483 return $changed;
1484 }
1485
1494 protected function convertParamsForLink( $params ) {
1495 foreach ( $params as &$value ) {
1496 if ( $value === false ) {
1497 $value = '0';
1498 }
1499 }
1500 unset( $value );
1501 return $params;
1502 }
1503
1515 protected function buildQuery( &$tables, &$fields, &$conds, &$query_options,
1516 &$join_conds, FormOptions $opts
1517 ) {
1518 $dbr = $this->getDB();
1519 $isStructuredUI = $this->isStructuredFilterUiEnabled();
1520
1522 foreach ( $this->filterGroups as $filterGroup ) {
1523 $filterGroup->modifyQuery( $dbr, $this, $tables, $fields, $conds,
1524 $query_options, $join_conds, $opts, $isStructuredUI );
1525 }
1526
1527 // Namespace filtering
1528 if ( $opts[ 'namespace' ] !== '' ) {
1529 $namespaces = explode( ';', $opts[ 'namespace' ] );
1530
1531 $namespaces = $this->expandSymbolicNamespaceFilters( $namespaces );
1532
1533 $namespaceInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
1534 $namespaces = array_filter(
1535 $namespaces,
1536 static function ( $ns ) use ( $namespaceInfo ) {
1537 return $namespaceInfo->exists( $ns );
1538 }
1539 );
1540
1541 if ( $namespaces !== [] ) {
1542 // Namespaces are just ints, use them as int when acting with the database
1543 $namespaces = array_map( 'intval', $namespaces );
1544
1545 if ( $opts[ 'associated' ] ) {
1546 $associatedNamespaces = array_map(
1547 static function ( $ns ) use ( $namespaceInfo ){
1548 return $namespaceInfo->getAssociated( $ns );
1549 },
1550 array_filter(
1551 $namespaces,
1552 static function ( $ns ) use ( $namespaceInfo ) {
1553 return $namespaceInfo->hasTalkNamespace( $ns );
1554 }
1555 )
1556 );
1557 $namespaces = array_unique( array_merge( $namespaces, $associatedNamespaces ) );
1558 }
1559
1560 if ( count( $namespaces ) === 1 ) {
1561 $operator = $opts[ 'invert' ] ? '!=' : '=';
1562 $value = $dbr->addQuotes( reset( $namespaces ) );
1563 } else {
1564 $operator = $opts[ 'invert' ] ? 'NOT IN' : 'IN';
1565 sort( $namespaces );
1566 $value = '(' . $dbr->makeList( $namespaces ) . ')';
1567 }
1568 $conds[] = "rc_namespace $operator $value";
1569 }
1570 }
1571
1572 // Calculate cutoff
1573 $cutoff_unixtime = time() - $opts['days'] * 3600 * 24;
1574 $cutoff = $dbr->timestamp( $cutoff_unixtime );
1575
1576 $fromValid = preg_match( '/^[0-9]{14}$/', $opts['from'] );
1577 if ( $fromValid && $opts['from'] > wfTimestamp( TS_MW, $cutoff ) ) {
1578 $cutoff = $dbr->timestamp( $opts['from'] );
1579 } else {
1580 $opts->reset( 'from' );
1581 }
1582
1583 $conds[] = 'rc_timestamp >= ' . $dbr->addQuotes( $cutoff );
1584 }
1585
1597 protected function doMainQuery( $tables, $fields, $conds,
1598 $query_options, $join_conds, FormOptions $opts
1599 ) {
1600 $rcQuery = RecentChange::getQueryInfo();
1601 $tables = array_merge( $tables, $rcQuery['tables'] );
1602 $fields = array_merge( $rcQuery['fields'], $fields );
1603 $join_conds = array_merge( $join_conds, $rcQuery['joins'] );
1604
1606 $tables,
1607 $fields,
1608 $conds,
1609 $join_conds,
1610 $query_options,
1611 ''
1612 );
1613
1614 if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
1615 $opts )
1616 ) {
1617 return false;
1618 }
1619
1620 $dbr = $this->getDB();
1621
1622 return $dbr->select(
1623 $tables,
1624 $fields,
1625 $conds,
1626 __METHOD__,
1627 $query_options,
1628 $join_conds
1629 );
1630 }
1631
1632 protected function runMainQueryHook( &$tables, &$fields, &$conds,
1633 &$query_options, &$join_conds, $opts
1634 ) {
1635 return $this->getHookRunner()->onChangesListSpecialPageQuery(
1636 $this->getName(), $tables, $fields, $conds, $query_options, $join_conds, $opts );
1637 }
1638
1644 protected function getDB() {
1645 return wfGetDB( DB_REPLICA );
1646 }
1647
1654 private function webOutputHeader( $rowCount, $opts ) {
1655 if ( !$this->including() ) {
1656 $this->outputFeedLinks();
1657 $this->doHeader( $opts, $rowCount );
1658 }
1659 }
1660
1667 public function webOutput( $rows, $opts ) {
1668 $this->webOutputHeader( $rows->numRows(), $opts );
1669
1670 $this->outputChangesList( $rows, $opts );
1671 }
1672
1673 public function outputFeedLinks() {
1674 // nothing by default
1675 }
1676
1683 abstract public function outputChangesList( $rows, $opts );
1684
1691 public function doHeader( $opts, $numRows ) {
1692 $this->setTopText( $opts );
1693
1694 // @todo Lots of stuff should be done here.
1695
1696 $this->setBottomText( $opts );
1697 }
1698
1706 public function setTopText( FormOptions $opts ) {
1707 // nothing by default
1708 }
1709
1717 public function setBottomText( FormOptions $opts ) {
1718 // nothing by default
1719 }
1720
1730 public function getExtraOptions( $opts ) {
1731 return [];
1732 }
1733
1739 public function makeLegend() {
1740 $context = $this->getContext();
1741 $user = $context->getUser();
1742 # The legend showing what the letters and stuff mean
1743 $legend = Html::openElement( 'dl' ) . "\n";
1744 # Iterates through them and gets the messages for both letter and tooltip
1745 $legendItems = $context->getConfig()->get( 'RecentChangesFlags' );
1746 if ( !( $user->useRCPatrol() || $user->useNPPatrol() ) ) {
1747 unset( $legendItems['unpatrolled'] );
1748 }
1749 foreach ( $legendItems as $key => $item ) { # generate items of the legend
1750 $label = $item['legend'] ?? $item['title'];
1751 $letter = $item['letter'];
1752 $cssClass = $item['class'] ?? $key;
1753
1754 $legend .= Html::element( 'dt',
1755 [ 'class' => $cssClass ], $context->msg( $letter )->text()
1756 ) . "\n" .
1757 Html::rawElement( 'dd',
1758 [ 'class' => Sanitizer::escapeClass( 'mw-changeslist-legend-' . $key ) ],
1759 $context->msg( $label )->parse()
1760 ) . "\n";
1761 }
1762 # (+-123)
1763 $legend .= Html::rawElement( 'dt',
1764 [ 'class' => 'mw-plusminus-pos' ],
1765 $context->msg( 'recentchanges-legend-plusminus' )->parse()
1766 ) . "\n";
1767 $legend .= Html::element(
1768 'dd',
1769 [ 'class' => 'mw-changeslist-legend-plusminus' ],
1770 $context->msg( 'recentchanges-label-plusminus' )->text()
1771 ) . "\n";
1772 // Watchlist expiry clock icon.
1773 if ( $context->getConfig()->get( 'WatchlistExpiry' ) ) {
1774 $widget = new IconWidget( [
1775 'icon' => 'clock',
1776 'classes' => [ 'mw-changesList-watchlistExpiry' ],
1777 ] );
1778 // Link the image to its label for assistive technologies.
1779 $watchlistLabelId = 'mw-changeslist-watchlistExpiry-label';
1780 $widget->getIconElement()->setAttributes( [
1781 'role' => 'img',
1782 'aria-labelledby' => $watchlistLabelId,
1783 ] );
1784 $legend .= Html::rawElement(
1785 'dt',
1786 [ 'class' => 'mw-changeslist-legend-watchlistexpiry' ],
1787 $widget
1788 );
1789 $legend .= Html::element(
1790 'dd',
1791 [ 'class' => 'mw-changeslist-legend-watchlistexpiry', 'id' => $watchlistLabelId ],
1792 $context->msg( 'recentchanges-legend-watchlistexpiry' )->text()
1793 );
1794 }
1795 $legend .= Html::closeElement( 'dl' ) . "\n";
1796
1797 $legendHeading = $this->isStructuredFilterUiEnabled() ?
1798 $context->msg( 'rcfilters-legend-heading' )->parse() :
1799 $context->msg( 'recentchanges-legend-heading' )->parse();
1800
1801 # Collapsible
1802 $collapsedState = $this->getRequest()->getCookie( 'changeslist-state' );
1803 $collapsedClass = $collapsedState === 'collapsed' ? 'mw-collapsed' : '';
1804
1805 $legend = Html::rawElement(
1806 'div',
1807 [ 'class' => [ 'mw-changeslist-legend', 'mw-collapsible', $collapsedClass ] ],
1808 $legendHeading .
1809 Html::rawElement( 'div', [ 'class' => 'mw-collapsible-content' ], $legend )
1810 );
1811
1812 return $legend;
1813 }
1814
1818 protected function addModules() {
1819 $out = $this->getOutput();
1820 // Styles and behavior for the legend box (see makeLegend())
1821 $out->addModuleStyles( [
1822 'mediawiki.interface.helpers.styles',
1823 'mediawiki.special.changeslist.legend',
1824 'mediawiki.special.changeslist',
1825 ] );
1826 $out->addModules( 'mediawiki.special.changeslist.legend.js' );
1827
1828 if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
1829 $out->addModules( 'mediawiki.rcfilters.filters.ui' );
1830 $out->addModuleStyles( 'mediawiki.rcfilters.filters.base.styles' );
1831 }
1832 }
1833
1834 protected function getGroupName() {
1835 return 'changes';
1836 }
1837
1854 public function filterOnUserExperienceLevel( $specialPageClassName, $context, $dbr,
1855 &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedExpLevels, $now = 0
1856 ) {
1857 global $wgLearnerEdits,
1861
1862 $LEVEL_COUNT = 5;
1863
1864 // If all levels are selected, don't filter
1865 if ( count( $selectedExpLevels ) === $LEVEL_COUNT ) {
1866 return;
1867 }
1868
1869 // both 'registered' and 'unregistered', experience levels, if any, are included in 'registered'
1870 if (
1871 in_array( 'registered', $selectedExpLevels ) &&
1872 in_array( 'unregistered', $selectedExpLevels )
1873 ) {
1874 return;
1875 }
1876
1877 // 'registered' but not 'unregistered', experience levels, if any, are included in 'registered'
1878 if (
1879 in_array( 'registered', $selectedExpLevels ) &&
1880 !in_array( 'unregistered', $selectedExpLevels )
1881 ) {
1882 $conds[] = 'actor_user IS NOT NULL';
1883 return;
1884 }
1885
1886 if ( $selectedExpLevels === [ 'unregistered' ] ) {
1887 $conds['actor_user'] = null;
1888 return;
1889 }
1890
1891 $tables[] = 'user';
1892 $join_conds['user'] = [ 'LEFT JOIN', 'actor_user=user_id' ];
1893
1894 if ( $now === 0 ) {
1895 $now = time();
1896 }
1897 $secondsPerDay = 86400;
1898 $learnerCutoff = $now - $wgLearnerMemberSince * $secondsPerDay;
1899 $experiencedUserCutoff = $now - $wgExperiencedUserMemberSince * $secondsPerDay;
1900
1901 $aboveNewcomer = $dbr->makeList(
1902 [
1903 'user_editcount >= ' . intval( $wgLearnerEdits ),
1904 $dbr->makeList( [
1905 'user_registration IS NULL',
1906 'user_registration <= ' . $dbr->addQuotes( $dbr->timestamp( $learnerCutoff ) ),
1907 ], IDatabase::LIST_OR ),
1908 ],
1909 IDatabase::LIST_AND
1910 );
1911
1912 $aboveLearner = $dbr->makeList(
1913 [
1914 'user_editcount >= ' . intval( $wgExperiencedUserEdits ),
1915 $dbr->makeList( [
1916 'user_registration IS NULL',
1917 'user_registration <= ' .
1918 $dbr->addQuotes( $dbr->timestamp( $experiencedUserCutoff ) ),
1919 ], IDatabase::LIST_OR ),
1920 ],
1921 IDatabase::LIST_AND
1922 );
1923
1924 $conditions = [];
1925
1926 if ( in_array( 'unregistered', $selectedExpLevels ) ) {
1927 $selectedExpLevels = array_diff( $selectedExpLevels, [ 'unregistered' ] );
1928 $conditions['actor_user'] = null;
1929 }
1930
1931 if ( $selectedExpLevels === [ 'newcomer' ] ) {
1932 $conditions[] = "NOT ( $aboveNewcomer )";
1933 } elseif ( $selectedExpLevels === [ 'learner' ] ) {
1934 $conditions[] = $dbr->makeList(
1935 [ $aboveNewcomer, "NOT ( $aboveLearner )" ],
1936 IDatabase::LIST_AND
1937 );
1938 } elseif ( $selectedExpLevels === [ 'experienced' ] ) {
1939 $conditions[] = $aboveLearner;
1940 } elseif ( $selectedExpLevels === [ 'learner', 'newcomer' ] ) {
1941 $conditions[] = "NOT ( $aboveLearner )";
1942 } elseif ( $selectedExpLevels === [ 'experienced', 'newcomer' ] ) {
1943 $conditions[] = $dbr->makeList(
1944 [ "NOT ( $aboveNewcomer )", $aboveLearner ],
1945 IDatabase::LIST_OR
1946 );
1947 } elseif ( $selectedExpLevels === [ 'experienced', 'learner' ] ) {
1948 $conditions[] = $aboveNewcomer;
1949 } elseif ( $selectedExpLevels === [ 'experienced', 'learner', 'newcomer' ] ) {
1950 $conditions[] = 'actor_user IS NOT NULL';
1951 }
1952
1953 if ( count( $conditions ) > 1 ) {
1954 $conds[] = $dbr->makeList( $conditions, IDatabase::LIST_OR );
1955 } elseif ( count( $conditions ) === 1 ) {
1956 $conds[] = reset( $conditions );
1957 }
1958 }
1959
1966 if ( $this->getRequest()->getBool( 'rcfilters' ) ) {
1967 return true;
1968 }
1969
1970 return static::checkStructuredFilterUiEnabled( $this->getUser() );
1971 }
1972
1980 public static function checkStructuredFilterUiEnabled( $user ) {
1981 if ( $user instanceof Config ) {
1982 wfDeprecated( __METHOD__ . ' with Config argument', '1.34' );
1983 $user = func_get_arg( 1 );
1984 }
1985 return !$user->getOption( 'rcenhancedfilters-disable' );
1986 }
1987
1995 public function getDefaultLimit() {
1996 return MediaWikiServices::getInstance()
1997 ->getUserOptionsLookup()
1998 ->getIntOption( $this->getUser(), $this->getLimitPreferenceName() );
1999 }
2000
2009 public function getDefaultDays() {
2010 return floatval( $this->getUser()->getOption( static::$daysPreferenceName ) );
2011 }
2012
2019 abstract protected function getLimitPreferenceName(): string;
2020
2025 private function expandSymbolicNamespaceFilters( array $namespaces ) {
2026 $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
2027 $symbolicFilters = [
2028 'all-contents' => $nsInfo->getSubjectNamespaces(),
2029 'all-discussions' => $nsInfo->getTalkNamespaces(),
2030 ];
2031 $additionalNamespaces = [];
2032 foreach ( $symbolicFilters as $name => $values ) {
2033 if ( in_array( $name, $namespaces ) ) {
2034 $additionalNamespaces = array_merge( $additionalNamespaces, $values );
2035 }
2036 }
2037 $namespaces = array_diff( $namespaces, array_keys( $symbolicFilters ) );
2038 $namespaces = array_merge( $namespaces, $additionalNamespaces );
2039 return array_unique( $namespaces );
2040 }
2041}
getDB()
$wgLearnerMemberSince
Number of days the user must exist before becoming a learner.
$wgExperiencedUserMemberSince
Number of days the user must exist before becoming "experienced".
$wgLearnerEdits
The following variables define 3 user experience levels:
$wgExperiencedUserEdits
Number of edits the user must have before becoming "experienced".
const NS_USER
Definition Defines.php:66
const RC_NEW
Definition Defines.php:116
const LIST_OR
Definition Defines.php:46
const RC_LOG
Definition Defines.php:117
const NS_USER_TALK
Definition Defines.php:67
const RC_EDIT
Definition Defines.php:115
const RC_CATEGORIZE
Definition Defines.php:119
wfGetDB( $db, $groups=[], $wiki=false)
Get a Database object.
wfLogWarning( $msg, $callerOffset=1, $level=E_USER_WARNING)
Send a warning as a PHP error and the debug log.
wfArrayToCgi( $array1, $array2=null, $prefix='')
This function takes one or two arrays as input, and returns a CGI-style string, e....
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
getContext()
static tagLongDescriptionMessage( $tag, MessageLocalizer $context)
Get the message object for the tag's long description.
static listSoftwareActivatedTags()
Lists those tags which core or extensions report as being "active".
static tagUsageStatistics()
Returns a map of any tags used on the wiki to number of edits tagged with them, ordered descending by...
static tagShortDescriptionMessage( $tag, MessageLocalizer $context)
Get the message object for the tag's short description.
static modifyDisplayQuery(&$tables, &$fields, &$conds, &$join_conds, &$options, $filter_tag='')
Applies all tags-related changes to a query.
static listExplicitlyDefinedTags()
Lists tags explicitly defined in the change_tag_def table of the database.
If the group is active, any unchecked filters will translate to hide parameters in the URL.
Represents a hide-based boolean filter (used on ChangesListSpecialPage and descendants)
Represents a filter group (used on ChangesListSpecialPage and descendants)
Represents a filter (used on ChangesListSpecialPage and descendants)
getConflictingFilters()
Get filters conflicting with this filter.
Special page which uses a ChangesList to show query results.
static getRcFiltersConfigVars(ResourceLoaderContext $context)
Get config vars to export with the mediawiki.rcfilters.filters.ui module.
const TAG_DESC_CHARACTER_LIMIT
Maximum length of a tag description in UTF-8 characters.
validateOptions(FormOptions $opts)
Validate a FormOptions object generated by getDefaultOptions() with values already populated.
getDefaultOptions()
Get a FormOptions object containing the default options.
getGroupName()
Under which header this special page is listed in Special:SpecialPages See messages 'specialpages-gro...
setTopText(FormOptions $opts)
Send the text to be displayed before the options.
filterOnUserExperienceLevel( $specialPageClassName, $context, $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedExpLevels, $now=0)
Filter on users' experience levels; this will not be called if nothing is selected.
runMainQueryHook(&$tables, &$fields, &$conds, &$query_options, &$join_conds, $opts)
getDefaultLimit()
Get the default value of the number of changes to display when loading the result set.
registerFiltersFromDefinitions(array $definition)
Register filters from a definition object.
convertParamsForLink( $params)
Convert parameters values from true/false to 1/0 so they are not omitted by wfArrayToCgi() T38524.
parseParameters( $par, FormOptions $opts)
Process $par and put options found in $opts.
static getChangeTagListSummary(ResourceLoaderContext $context)
Get information about change tags, without parsing messages, for getRcFiltersConfigSummary().
getFilterGroup( $groupName)
Gets a specified ChangesListFilterGroup by name.
replaceOldOptions(FormOptions $opts)
Replace old options with their structured UI equivalents.
isStructuredFilterUiEnabled()
Check whether the structured filter UI is enabled.
array $hideCategorizationFilterDefinition
Single filter group registered conditionally.
getExtraOptions( $opts)
Get options to be displayed in a form.
setup( $parameters)
Register all the filters, including legacy hook-driven ones.
static getChangeTagList(ResourceLoaderContext $context)
Get information about change tags to export to JS via getRcFiltersConfigVars().
static string $savedQueriesPreferenceName
Preference name for saved queries.
registerFilters()
Register all filters and their groups (including those from hooks), plus handle conflicts and default...
areFiltersInConflict()
Check if filters are in conflict and guaranteed to return no results.
array $legacyReviewStatusFilterGroupDefinition
Same format as filterGroupDefinitions, but for a single group (reviewStatus) that is registered condi...
getDefaultDays()
Get the default value of the number of days to display when loading the result set.
fixBackwardsCompatibilityOptions(FormOptions $opts)
Fix a special case (hideanons=1 and hideliu=1) in a special way, for backwards compatibility.
expandSymbolicNamespaceFilters(array $namespaces)
outputNoResults()
Add the "no results" message to the output.
static string $daysPreferenceName
Preference name for 'days'.
getFilterGroups()
Gets the currently registered filters groups.
registerFilterGroup(ChangesListFilterGroup $group)
Register a structured changes list filter group.
addModules()
Add page-specific modules.
fixContradictoryOptions(FormOptions $opts)
Fix invalid options by resetting pairs that should never appear together.
static getRcFiltersConfigSummary(ResourceLoaderContext $context)
Get essential data about getRcFiltersConfigVars() for change detection.
__construct( $name, $restriction)
outputTimeout()
Add the "timeout" message to the output.
doHeader( $opts, $numRows)
Set the text to be displayed above the changes.
array $reviewStatusFilterGroupDefinition
Single filter group registered conditionally.
fetchOptionsFromRequest( $opts)
Fetch values for a FormOptions object from the WebRequest associated with this instance.
getOptions()
Get the current FormOptions for this request.
doMainQuery( $tables, $fields, $conds, $query_options, $join_conds, FormOptions $opts)
Process the query.
setBottomText(FormOptions $opts)
Send the text to be displayed after the options.
getStructuredFilterJsData()
Gets structured filter information needed by JS.
buildQuery(&$tables, &$fields, &$conds, &$query_options, &$join_conds, FormOptions $opts)
Sets appropriate tables, fields, conditions, etc.
webOutputHeader( $rowCount, $opts)
Send header output to the OutputPage object, only called if not using feeds.
ChangesListFilterGroup[] $filterGroups
Filter groups, and their contained filters This is an associative array (with group name as key) of C...
makeLegend()
Return the legend displayed within the fieldset.
webOutput( $rows, $opts)
Send output to the OutputPage object, only called if not used feeds.
considerActionsForDefaultSavedQuery( $subpage)
Check whether or not the page should load defaults, and if so, whether a default saved query is relev...
transformFilterDefinition(array $filterDefinition)
Transforms filter definition to prepare it for constructor.
getDB()
Return a IDatabase object for reading.
getLimitPreferenceName()
Getting the preference name for 'limit'.
static checkStructuredFilterUiEnabled( $user)
Static method to check whether StructuredFilter UI is enabled for the given user.
outputChangesList( $rows, $opts)
Build and output the actual changes list.
getRows()
Get the database result for this special page instance.
array $filterGroupDefinitions
Definition information for the filters and their groups.
includeRcFiltersApp()
Include the modules and configuration for the RCFilters app.
static string $collapsedPreferenceName
Preference name for collapsing the active filter display.
Represents a filter group with multiple string options.
const NONE
Signifies that no options in the group are selected, meaning the group has no effect.
Helper class to keep track of options when mixing links and form elements.
reset( $name)
Delete the option value.
validateBounds( $name, $min, $max)
Constrain a numeric value for a given option to a given range.
validateIntBounds( $name, $min, $max)
getChangedValues()
Return options modified as an array ( name => value )
static newFromRow( $row)
Handy shortcut for constructing a formatter directly from database row.
PSR-3 logger instance factory.
MediaWikiServices is the service locator for the application scope of MediaWiki.
Utility class for creating new RC entries.
Context object that contains information about the state of a specific ResourceLoader web request.
msg( $key,... $params)
Get a Message object with context set.
Parent class for all special pages.
outputHeader( $summaryMessageKey='')
Outputs a summary message on top of special pages Per default the message key is the canonical name o...
getName()
Get the name of this Special Page.
setHeaders()
Sets headers - this should be called from the execute() method of all derived classes!
getOutput()
Get the OutputPage being used for this instance.
getUser()
Shortcut to get the User executing this instance.
static getTitleFor( $name, $subpage=false, $fragment='')
Get a localised Title object for a specified special page name If you don't need a full Title object,...
msg( $key,... $params)
Wrapper around wfMessage that sets the current context.
getConfig()
Shortcut to get main config object.
getRequest()
Get the WebRequest being used for this instance.
getPageTitle( $subpage=false)
Get a self-referential title object.
including( $x=null)
Whether the special page is being evaluated via transclusion.
static newFromIdentity(UserIdentity $identity)
Returns a User object corresponding to the given UserIdentity.
Definition User.php:684
Class for fixing stale WANObjectCache keys using a purge event source.
Error thrown when a query times out.
Overloads the relevant methods of the real ResultWrapper so it doesn't go anywhere near an actual dat...
Interface for configuration instances.
Definition Config.php:30
Interface for objects which can provide a MediaWiki context on request.
Basic database interface for live and lazy-loaded relation database handles.
Definition IDatabase.php:38
Result wrapper for grabbing data queried from an IDatabase object.
$cache
Definition mcc.php:33
foreach( $mmfl['setupFiles'] as $fileName) if($queue) if(empty( $mmfl['quiet'])) $s
const DB_REPLICA
Definition defines.php:25