MediaWiki REL1_35
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 $limitPreferenceName;
62
67 protected static $collapsedPreferenceName;
68
70 protected $rcSubpage;
71
73 protected $rcOptions;
74
75 // Order of both groups and filters is significant; first is top-most priority,
76 // descending from there.
77 // 'showHideSuffix' is a shortcut to and avoid spelling out
78 // details specific to subclasses here.
92
93 // Same format as filterGroupDefinitions, but for a single group (reviewStatus)
94 // that is registered conditionally.
96
97 // Single filter group registered conditionally
99
100 // Single filter group registered conditionally
102
109 protected $filterGroups = [];
110
111 public function __construct( $name, $restriction ) {
112 parent::__construct( $name, $restriction );
113
114 $nonRevisionTypes = [ RC_LOG ];
115 $this->getHookRunner()->onSpecialWatchlistGetNonRevisionTypes( $nonRevisionTypes );
116
117 $this->filterGroupDefinitions = [
118 [
119 'name' => 'registration',
120 'title' => 'rcfilters-filtergroup-registration',
121 'class' => ChangesListBooleanFilterGroup::class,
122 'filters' => [
123 [
124 'name' => 'hideliu',
125 // rcshowhideliu-show, rcshowhideliu-hide,
126 // wlshowhideliu
127 'showHideSuffix' => 'showhideliu',
128 'default' => false,
129 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
130 &$query_options, &$join_conds
131 ) {
132 $actorMigration = ActorMigration::newMigration();
133 $actorQuery = $actorMigration->getJoin( 'rc_user' );
134 $tables += $actorQuery['tables'];
135 $join_conds += $actorQuery['joins'];
136 $conds[] = $actorMigration->isAnon( $actorQuery['fields']['rc_user'] );
137 },
138 'isReplacedInStructuredUi' => true,
139
140 ],
141 [
142 'name' => 'hideanons',
143 // rcshowhideanons-show, rcshowhideanons-hide,
144 // wlshowhideanons
145 'showHideSuffix' => 'showhideanons',
146 'default' => false,
147 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
148 &$query_options, &$join_conds
149 ) {
150 $actorMigration = ActorMigration::newMigration();
151 $actorQuery = $actorMigration->getJoin( 'rc_user' );
152 $tables += $actorQuery['tables'];
153 $join_conds += $actorQuery['joins'];
154 $conds[] = $actorMigration->isNotAnon( $actorQuery['fields']['rc_user'] );
155 },
156 'isReplacedInStructuredUi' => true,
157 ]
158 ],
159 ],
160
161 [
162 'name' => 'userExpLevel',
163 'title' => 'rcfilters-filtergroup-user-experience-level',
164 'class' => ChangesListStringOptionsFilterGroup::class,
165 'isFullCoverage' => true,
166 'filters' => [
167 [
168 'name' => 'unregistered',
169 'label' => 'rcfilters-filter-user-experience-level-unregistered-label',
170 'description' => 'rcfilters-filter-user-experience-level-unregistered-description',
171 'cssClassSuffix' => 'user-unregistered',
172 'isRowApplicableCallable' => function ( $ctx, $rc ) {
173 return !$rc->getAttribute( 'rc_user' );
174 }
175 ],
176 [
177 'name' => 'registered',
178 'label' => 'rcfilters-filter-user-experience-level-registered-label',
179 'description' => 'rcfilters-filter-user-experience-level-registered-description',
180 'cssClassSuffix' => 'user-registered',
181 'isRowApplicableCallable' => function ( $ctx, $rc ) {
182 return $rc->getAttribute( 'rc_user' );
183 }
184 ],
185 [
186 'name' => 'newcomer',
187 'label' => 'rcfilters-filter-user-experience-level-newcomer-label',
188 'description' => 'rcfilters-filter-user-experience-level-newcomer-description',
189 'cssClassSuffix' => 'user-newcomer',
190 'isRowApplicableCallable' => function ( $ctx, $rc ) {
191 $performer = $rc->getPerformer();
192 return $performer && $performer->isLoggedIn() &&
193 $performer->getExperienceLevel() === 'newcomer';
194 }
195 ],
196 [
197 'name' => 'learner',
198 'label' => 'rcfilters-filter-user-experience-level-learner-label',
199 'description' => 'rcfilters-filter-user-experience-level-learner-description',
200 'cssClassSuffix' => 'user-learner',
201 'isRowApplicableCallable' => function ( $ctx, $rc ) {
202 $performer = $rc->getPerformer();
203 return $performer && $performer->isLoggedIn() &&
204 $performer->getExperienceLevel() === 'learner';
205 },
206 ],
207 [
208 'name' => 'experienced',
209 'label' => 'rcfilters-filter-user-experience-level-experienced-label',
210 'description' => 'rcfilters-filter-user-experience-level-experienced-description',
211 'cssClassSuffix' => 'user-experienced',
212 'isRowApplicableCallable' => function ( $ctx, $rc ) {
213 $performer = $rc->getPerformer();
214 return $performer && $performer->isLoggedIn() &&
215 $performer->getExperienceLevel() === 'experienced';
216 },
217 ]
218 ],
220 'queryCallable' => [ $this, 'filterOnUserExperienceLevel' ],
221 ],
222
223 [
224 'name' => 'authorship',
225 'title' => 'rcfilters-filtergroup-authorship',
226 'class' => ChangesListBooleanFilterGroup::class,
227 'filters' => [
228 [
229 'name' => 'hidemyself',
230 'label' => 'rcfilters-filter-editsbyself-label',
231 'description' => 'rcfilters-filter-editsbyself-description',
232 // rcshowhidemine-show, rcshowhidemine-hide,
233 // wlshowhidemine
234 'showHideSuffix' => 'showhidemine',
235 'default' => false,
236 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
237 &$query_options, &$join_conds
238 ) {
239 $actorQuery = ActorMigration::newMigration()->getWhere( $dbr, 'rc_user', $ctx->getUser() );
240 $tables += $actorQuery['tables'];
241 $join_conds += $actorQuery['joins'];
242 $conds[] = 'NOT(' . $actorQuery['conds'] . ')';
243 },
244 'cssClassSuffix' => 'self',
245 'isRowApplicableCallable' => function ( $ctx, $rc ) {
246 return $ctx->getUser()->equals( $rc->getPerformer() );
247 },
248 ],
249 [
250 'name' => 'hidebyothers',
251 'label' => 'rcfilters-filter-editsbyother-label',
252 'description' => 'rcfilters-filter-editsbyother-description',
253 'default' => false,
254 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
255 &$query_options, &$join_conds
256 ) {
257 $actorQuery = ActorMigration::newMigration()
258 ->getWhere( $dbr, 'rc_user', $ctx->getUser(), false );
259 $tables += $actorQuery['tables'];
260 $join_conds += $actorQuery['joins'];
261 $conds[] = $actorQuery['conds'];
262 },
263 'cssClassSuffix' => 'others',
264 'isRowApplicableCallable' => function ( $ctx, $rc ) {
265 return !$ctx->getUser()->equals( $rc->getPerformer() );
266 },
267 ]
268 ]
269 ],
270
271 [
272 'name' => 'automated',
273 'title' => 'rcfilters-filtergroup-automated',
274 'class' => ChangesListBooleanFilterGroup::class,
275 'filters' => [
276 [
277 'name' => 'hidebots',
278 'label' => 'rcfilters-filter-bots-label',
279 'description' => 'rcfilters-filter-bots-description',
280 // rcshowhidebots-show, rcshowhidebots-hide,
281 // wlshowhidebots
282 'showHideSuffix' => 'showhidebots',
283 'default' => false,
284 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
285 &$query_options, &$join_conds
286 ) {
287 $conds['rc_bot'] = 0;
288 },
289 'cssClassSuffix' => 'bot',
290 'isRowApplicableCallable' => function ( $ctx, $rc ) {
291 return $rc->getAttribute( 'rc_bot' );
292 },
293 ],
294 [
295 'name' => 'hidehumans',
296 'label' => 'rcfilters-filter-humans-label',
297 'description' => 'rcfilters-filter-humans-description',
298 'default' => false,
299 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
300 &$query_options, &$join_conds
301 ) {
302 $conds['rc_bot'] = 1;
303 },
304 'cssClassSuffix' => 'human',
305 'isRowApplicableCallable' => function ( $ctx, $rc ) {
306 return !$rc->getAttribute( 'rc_bot' );
307 },
308 ]
309 ]
310 ],
311
312 // significance (conditional)
313
314 [
315 'name' => 'significance',
316 'title' => 'rcfilters-filtergroup-significance',
317 'class' => ChangesListBooleanFilterGroup::class,
318 'priority' => -6,
319 'filters' => [
320 [
321 'name' => 'hideminor',
322 'label' => 'rcfilters-filter-minor-label',
323 'description' => 'rcfilters-filter-minor-description',
324 // rcshowhideminor-show, rcshowhideminor-hide,
325 // wlshowhideminor
326 'showHideSuffix' => 'showhideminor',
327 'default' => false,
328 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
329 &$query_options, &$join_conds
330 ) {
331 $conds[] = 'rc_minor = 0';
332 },
333 'cssClassSuffix' => 'minor',
334 'isRowApplicableCallable' => function ( $ctx, $rc ) {
335 return $rc->getAttribute( 'rc_minor' );
336 }
337 ],
338 [
339 'name' => 'hidemajor',
340 'label' => 'rcfilters-filter-major-label',
341 'description' => 'rcfilters-filter-major-description',
342 'default' => false,
343 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
344 &$query_options, &$join_conds
345 ) {
346 $conds[] = 'rc_minor = 1';
347 },
348 'cssClassSuffix' => 'major',
349 'isRowApplicableCallable' => function ( $ctx, $rc ) {
350 return !$rc->getAttribute( 'rc_minor' );
351 }
352 ]
353 ]
354 ],
355
356 [
357 'name' => 'lastRevision',
358 'title' => 'rcfilters-filtergroup-lastrevision',
359 'class' => ChangesListBooleanFilterGroup::class,
360 'priority' => -7,
361 'filters' => [
362 [
363 'name' => 'hidelastrevision',
364 'label' => 'rcfilters-filter-lastrevision-label',
365 'description' => 'rcfilters-filter-lastrevision-description',
366 'default' => false,
367 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
368 &$query_options, &$join_conds ) use ( $nonRevisionTypes ) {
369 $conds[] = $dbr->makeList(
370 [
371 'rc_this_oldid <> page_latest',
372 'rc_type' => $nonRevisionTypes,
373 ],
374 LIST_OR
375 );
376 },
377 'cssClassSuffix' => 'last',
378 'isRowApplicableCallable' => function ( $ctx, $rc ) {
379 return $rc->getAttribute( 'rc_this_oldid' ) === $rc->getAttribute( 'page_latest' );
380 }
381 ],
382 [
383 'name' => 'hidepreviousrevisions',
384 'label' => 'rcfilters-filter-previousrevision-label',
385 'description' => 'rcfilters-filter-previousrevision-description',
386 'default' => false,
387 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
388 &$query_options, &$join_conds ) use ( $nonRevisionTypes ) {
389 $conds[] = $dbr->makeList(
390 [
391 'rc_this_oldid = page_latest',
392 'rc_type' => $nonRevisionTypes,
393 ],
394 LIST_OR
395 );
396 },
397 'cssClassSuffix' => 'previous',
398 'isRowApplicableCallable' => function ( $ctx, $rc ) {
399 return $rc->getAttribute( 'rc_this_oldid' ) !== $rc->getAttribute( 'page_latest' );
400 }
401 ]
402 ]
403 ],
404
405 // With extensions, there can be change types that will not be hidden by any of these.
406 [
407 'name' => 'changeType',
408 'title' => 'rcfilters-filtergroup-changetype',
409 'class' => ChangesListBooleanFilterGroup::class,
410 'priority' => -8,
411 'filters' => [
412 [
413 'name' => 'hidepageedits',
414 'label' => 'rcfilters-filter-pageedits-label',
415 'description' => 'rcfilters-filter-pageedits-description',
416 'default' => false,
417 'priority' => -2,
418 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
419 &$query_options, &$join_conds
420 ) {
421 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_EDIT );
422 },
423 'cssClassSuffix' => 'src-mw-edit',
424 'isRowApplicableCallable' => function ( $ctx, $rc ) {
425 return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_EDIT;
426 },
427 ],
428 [
429 'name' => 'hidenewpages',
430 'label' => 'rcfilters-filter-newpages-label',
431 'description' => 'rcfilters-filter-newpages-description',
432 'default' => false,
433 'priority' => -3,
434 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
435 &$query_options, &$join_conds
436 ) {
437 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_NEW );
438 },
439 'cssClassSuffix' => 'src-mw-new',
440 'isRowApplicableCallable' => function ( $ctx, $rc ) {
441 return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_NEW;
442 },
443 ],
444
445 // hidecategorization
446
447 [
448 'name' => 'hidelog',
449 'label' => 'rcfilters-filter-logactions-label',
450 'description' => 'rcfilters-filter-logactions-description',
451 'default' => false,
452 'priority' => -5,
453 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
454 &$query_options, &$join_conds
455 ) {
456 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_LOG );
457 },
458 'cssClassSuffix' => 'src-mw-log',
459 'isRowApplicableCallable' => function ( $ctx, $rc ) {
460 return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_LOG;
461 }
462 ],
463 ],
464 ],
465
466 ];
467
468 $this->legacyReviewStatusFilterGroupDefinition = [
469 [
470 'name' => 'legacyReviewStatus',
471 'title' => 'rcfilters-filtergroup-reviewstatus',
472 'class' => ChangesListBooleanFilterGroup::class,
473 'filters' => [
474 [
475 'name' => 'hidepatrolled',
476 // rcshowhidepatr-show, rcshowhidepatr-hide
477 // wlshowhidepatr
478 'showHideSuffix' => 'showhidepatr',
479 'default' => false,
480 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
481 &$query_options, &$join_conds
482 ) {
483 $conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED;
484 },
485 'isReplacedInStructuredUi' => true,
486 ],
487 [
488 'name' => 'hideunpatrolled',
489 'default' => false,
490 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
491 &$query_options, &$join_conds
492 ) {
493 $conds[] = 'rc_patrolled != ' . RecentChange::PRC_UNPATROLLED;
494 },
495 'isReplacedInStructuredUi' => true,
496 ],
497 ],
498 ]
499 ];
500
501 $this->reviewStatusFilterGroupDefinition = [
502 [
503 'name' => 'reviewStatus',
504 'title' => 'rcfilters-filtergroup-reviewstatus',
505 'class' => ChangesListStringOptionsFilterGroup::class,
506 'isFullCoverage' => true,
507 'priority' => -5,
508 'filters' => [
509 [
510 'name' => 'unpatrolled',
511 'label' => 'rcfilters-filter-reviewstatus-unpatrolled-label',
512 'description' => 'rcfilters-filter-reviewstatus-unpatrolled-description',
513 'cssClassSuffix' => 'reviewstatus-unpatrolled',
514 'isRowApplicableCallable' => function ( $ctx, $rc ) {
515 return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED;
516 },
517 ],
518 [
519 'name' => 'manual',
520 'label' => 'rcfilters-filter-reviewstatus-manual-label',
521 'description' => 'rcfilters-filter-reviewstatus-manual-description',
522 'cssClassSuffix' => 'reviewstatus-manual',
523 'isRowApplicableCallable' => function ( $ctx, $rc ) {
524 return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_PATROLLED;
525 },
526 ],
527 [
528 'name' => 'auto',
529 'label' => 'rcfilters-filter-reviewstatus-auto-label',
530 'description' => 'rcfilters-filter-reviewstatus-auto-description',
531 'cssClassSuffix' => 'reviewstatus-auto',
532 'isRowApplicableCallable' => function ( $ctx, $rc ) {
533 return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_AUTOPATROLLED;
534 },
535 ],
536 ],
538 'queryCallable' => function ( $specialPageClassName, $ctx, $dbr,
539 &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selected
540 ) {
541 if ( $selected === [] ) {
542 return;
543 }
544 $rcPatrolledValues = [
545 'unpatrolled' => RecentChange::PRC_UNPATROLLED,
546 'manual' => RecentChange::PRC_PATROLLED,
547 'auto' => RecentChange::PRC_AUTOPATROLLED,
548 ];
549 // e.g. rc_patrolled IN (0, 2)
550 $conds['rc_patrolled'] = array_map( function ( $s ) use ( $rcPatrolledValues ) {
551 return $rcPatrolledValues[ $s ];
552 }, $selected );
553 }
554 ]
555 ];
556
557 $this->hideCategorizationFilterDefinition = [
558 'name' => 'hidecategorization',
559 'label' => 'rcfilters-filter-categorization-label',
560 'description' => 'rcfilters-filter-categorization-description',
561 // rcshowhidecategorization-show, rcshowhidecategorization-hide.
562 // wlshowhidecategorization
563 'showHideSuffix' => 'showhidecategorization',
564 'default' => false,
565 'priority' => -4,
566 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
567 &$query_options, &$join_conds
568 ) {
569 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_CATEGORIZE );
570 },
571 'cssClassSuffix' => 'src-mw-categorize',
572 'isRowApplicableCallable' => function ( $ctx, $rc ) {
573 return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_CATEGORIZE;
574 },
575 ];
576 }
577
583 protected function areFiltersInConflict() {
584 $opts = $this->getOptions();
586 foreach ( $this->getFilterGroups() as $group ) {
587 if ( $group->getConflictingGroups() ) {
589 $group->getName() .
590 " specifies conflicts with other groups but these are not supported yet."
591 );
592 }
593
595 foreach ( $group->getConflictingFilters() as $conflictingFilter ) {
596 if ( $conflictingFilter->activelyInConflictWithGroup( $group, $opts ) ) {
597 return true;
598 }
599 }
600
602 foreach ( $group->getFilters() as $filter ) {
604 foreach ( $filter->getConflictingFilters() as $conflictingFilter ) {
605 if (
606 $conflictingFilter->activelyInConflictWithFilter( $filter, $opts ) &&
607 $filter->activelyInConflictWithFilter( $conflictingFilter, $opts )
608 ) {
609 return true;
610 }
611 }
612
613 }
614
615 }
616
617 return false;
618 }
619
623 public function execute( $subpage ) {
624 $this->rcSubpage = $subpage;
625
626 $this->considerActionsForDefaultSavedQuery( $subpage );
627
628 // Enable OOUI and module for the clock icon.
629 if ( $this->getConfig()->get( 'WatchlistExpiry' ) ) {
630 $this->getOutput()->enableOOUI();
631 $this->getOutput()->addModules( 'mediawiki.special.changeslist.watchlistexpiry' );
632 }
633
634 $opts = $this->getOptions();
635 try {
636 $rows = $this->getRows();
637 if ( $rows === false ) {
638 $rows = new FakeResultWrapper( [] );
639 }
640
641 // Used by Structured UI app to get results without MW chrome
642 if ( $this->getRequest()->getVal( 'action' ) === 'render' ) {
643 $this->getOutput()->setArticleBodyOnly( true );
644 }
645
646 // Used by "live update" and "view newest" to check
647 // if there's new changes with minimal data transfer
648 if ( $this->getRequest()->getBool( 'peek' ) ) {
649 $code = $rows->numRows() > 0 ? 200 : 204;
650 $this->getOutput()->setStatusCode( $code );
651
652 if ( $this->getUser()->isAnon() !==
653 $this->getRequest()->getFuzzyBool( 'isAnon' )
654 ) {
655 $this->getOutput()->setStatusCode( 205 );
656 }
657
658 return;
659 }
660
661 $batch = new LinkBatch;
662 foreach ( $rows as $row ) {
663 $batch->add( NS_USER, $row->rc_user_text );
664 $batch->add( NS_USER_TALK, $row->rc_user_text );
665 $batch->add( $row->rc_namespace, $row->rc_title );
666 if ( $row->rc_source === RecentChange::SRC_LOG ) {
667 $formatter = LogFormatter::newFromRow( $row );
668 foreach ( $formatter->getPreloadTitles() as $title ) {
669 $batch->addObj( $title );
670 }
671 }
672 }
673 $batch->execute();
674
675 $this->setHeaders();
676 $this->outputHeader();
677 $this->addModules();
678 $this->webOutput( $rows, $opts );
679
680 $rows->free();
681 } catch ( DBQueryTimeoutError $timeoutException ) {
682 MWExceptionHandler::logException( $timeoutException );
683
684 $this->setHeaders();
685 $this->outputHeader();
686 $this->addModules();
687
688 $this->getOutput()->setStatusCode( 500 );
689 $this->webOutputHeader( 0, $opts );
690 $this->outputTimeout();
691 }
692
693 if ( $this->getConfig()->get( 'EnableWANCacheReaper' ) ) {
694 // Clean up any bad page entries for titles showing up in RC
695 DeferredUpdates::addUpdate( new WANCacheReapUpdate(
696 $this->getDB(),
697 LoggerFactory::getInstance( 'objectcache' )
698 ) );
699 }
700
701 $this->includeRcFiltersApp();
702 }
703
711 protected function considerActionsForDefaultSavedQuery( $subpage ) {
712 if ( !$this->isStructuredFilterUiEnabled() || $this->including() ) {
713 return;
714 }
715
716 $knownParams = $this->getRequest()->getValues(
717 ...array_keys( $this->getOptions()->getAllValues() )
718 );
719
720 // HACK: Temporarily until we can properly define "sticky" filters and parameters,
721 // we need to exclude several parameters we know should not be counted towards preventing
722 // the loading of defaults.
723 $excludedParams = [ 'limit' => '', 'days' => '', 'enhanced' => '', 'from' => '' ];
724 $knownParams = array_diff_key( $knownParams, $excludedParams );
725
726 if (
727 // If there are NO known parameters in the URL request
728 // (that are not excluded) then we need to check into loading
729 // the default saved query
730 count( $knownParams ) === 0
731 ) {
732 $prefJson = $this->getUser()->getOption( static::$savedQueriesPreferenceName );
733
734 // Get the saved queries data and parse it
735 $savedQueries = $prefJson ? FormatJson::decode( $prefJson, true ) : false;
736
737 if ( $savedQueries && isset( $savedQueries[ 'default' ] ) ) {
738 // Only load queries that are 'version' 2, since those
739 // have parameter representation
740 if ( isset( $savedQueries[ 'version' ] ) && $savedQueries[ 'version' ] === '2' ) {
741 $savedQueryDefaultID = $savedQueries[ 'default' ];
742 $defaultQuery = $savedQueries[ 'queries' ][ $savedQueryDefaultID ][ 'data' ];
743
744 // Build the entire parameter list
745 $query = array_merge(
746 $defaultQuery[ 'params' ],
747 $defaultQuery[ 'highlights' ],
748 [
749 'urlversion' => '2',
750 ]
751 );
752 // Add to the query any parameters that we may have ignored before
753 // but are still valid and requested in the URL
754 $query = array_merge( $this->getRequest()->getValues(), $query );
755 unset( $query[ 'title' ] );
756 $this->getOutput()->redirect( $this->getPageTitle( $subpage )->getCanonicalURL( $query ) );
757 } else {
758 // There's a default, but the version is not 2, and the server can't
759 // actually recognize the query itself. This happens if it is before
760 // the conversion, so we need to tell the UI to reload saved query as
761 // it does the conversion to version 2
762 $this->getOutput()->addJsConfigVars(
763 'wgStructuredChangeFiltersDefaultSavedQueryExists',
764 true
765 );
766
767 // Add the class that tells the frontend it is still loading
768 // another query
769 $this->getOutput()->addBodyClasses( 'mw-rcfilters-ui-loading' );
770 }
771 }
772 }
773 }
774
780 protected function getLinkDays() {
781 $linkDays = $this->getConfig()->get( 'RCLinkDays' );
782 $filterByAge = $this->getConfig()->get( 'RCFilterByAge' );
783 $maxAge = $this->getConfig()->get( 'RCMaxAge' );
784 if ( $filterByAge ) {
785 // Trim it to only links which are within $wgRCMaxAge.
786 // Note that we allow one link higher than the max for things like
787 // "age 56 days" being accessible through the "60 days" link.
788 sort( $linkDays );
789
790 $maxAgeDays = $maxAge / ( 3600 * 24 );
791 foreach ( $linkDays as $i => $days ) {
792 if ( $days >= $maxAgeDays ) {
793 array_splice( $linkDays, $i + 1 );
794 break;
795 }
796 }
797 }
798
799 return $linkDays;
800 }
801
808 protected function includeRcFiltersApp() {
809 $out = $this->getOutput();
810 if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
811 $jsData = $this->getStructuredFilterJsData();
812 $messages = [];
813 foreach ( $jsData['messageKeys'] as $key ) {
814 $messages[$key] = $this->msg( $key )->plain();
815 }
816
817 $out->addBodyClasses( 'mw-rcfilters-enabled' );
818 $collapsed = $this->getUser()->getBoolOption( static::$collapsedPreferenceName );
819 if ( $collapsed ) {
820 $out->addBodyClasses( 'mw-rcfilters-collapsed' );
821 }
822
823 // These config and message exports should be moved into a ResourceLoader data module (T201574)
824 $out->addJsConfigVars( 'wgStructuredChangeFilters', $jsData['groups'] );
825 $out->addJsConfigVars( 'wgStructuredChangeFiltersMessages', $messages );
826 $out->addJsConfigVars( 'wgStructuredChangeFiltersCollapsedState', $collapsed );
827
828 $out->addJsConfigVars(
829 'StructuredChangeFiltersDisplayConfig',
830 [
831 'maxDays' => (int)$this->getConfig()->get( 'RCMaxAge' ) / ( 24 * 3600 ), // Translate to days
832 'limitArray' => $this->getConfig()->get( 'RCLinkLimits' ),
833 'limitDefault' => $this->getDefaultLimit(),
834 'daysArray' => $this->getLinkDays(),
835 'daysDefault' => $this->getDefaultDays(),
836 ]
837 );
838
839 $out->addJsConfigVars(
840 'wgStructuredChangeFiltersSavedQueriesPreferenceName',
841 static::$savedQueriesPreferenceName
842 );
843 $out->addJsConfigVars(
844 'wgStructuredChangeFiltersLimitPreferenceName',
845 static::$limitPreferenceName
846 );
847 $out->addJsConfigVars(
848 'wgStructuredChangeFiltersDaysPreferenceName',
849 static::$daysPreferenceName
850 );
851 $out->addJsConfigVars(
852 'wgStructuredChangeFiltersCollapsedPreferenceName',
853 static::$collapsedPreferenceName
854 );
855 } else {
856 $out->addBodyClasses( 'mw-rcfilters-disabled' );
857 }
858 }
859
868 public static function getRcFiltersConfigSummary( ResourceLoaderContext $context ) {
869 return [
870 // Reduce version computation by avoiding Message parsing
871 'RCFiltersChangeTags' => self::getChangeTagListSummary( $context ),
872 'StructuredChangeFiltersEditWatchlistUrl' =>
873 SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
874 ];
875 }
876
884 public static function getRcFiltersConfigVars( ResourceLoaderContext $context ) {
885 return [
886 'RCFiltersChangeTags' => self::getChangeTagList( $context ),
887 'StructuredChangeFiltersEditWatchlistUrl' =>
888 SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
889 ];
890 }
891
912 protected static function getChangeTagListSummary( ResourceLoaderContext $context ) {
913 $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
914 return $cache->getWithSetCallback(
915 $cache->makeKey( 'ChangesListSpecialPage-changeTagListSummary', $context->getLanguage() ),
916 WANObjectCache::TTL_DAY,
917 function ( $oldValue, &$ttl, array &$setOpts ) use ( $context ) {
918 $explicitlyDefinedTags = array_fill_keys( ChangeTags::listExplicitlyDefinedTags(), 0 );
919 $softwareActivatedTags = array_fill_keys( ChangeTags::listSoftwareActivatedTags(), 0 );
920
921 $tagStats = ChangeTags::tagUsageStatistics();
922 $tagHitCounts = array_merge( $explicitlyDefinedTags, $softwareActivatedTags, $tagStats );
923
924 $result = [];
925 foreach ( $tagHitCounts as $tagName => $hits ) {
926 if (
927 (
928 // Only get active tags
929 isset( $explicitlyDefinedTags[ $tagName ] ) ||
930 isset( $softwareActivatedTags[ $tagName ] )
931 ) &&
932 // Only get tags with more than 0 hits
933 $hits > 0
934 ) {
935 $labelMsg = ChangeTags::tagShortDescriptionMessage( $tagName, $context );
936 if ( $labelMsg === false ) {
937 // Tag is hidden, skip it
938 continue;
939 }
940 $descriptionMsg = ChangeTags::tagLongDescriptionMessage( $tagName, $context );
941 $result[] = [
942 'name' => $tagName,
943 'labelMsg' => $labelMsg,
944 'label' => $labelMsg->plain(),
945 'descriptionMsg' => $descriptionMsg,
946 'description' => $descriptionMsg ? $descriptionMsg->plain() : '',
947 'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ),
948 'hits' => $hits,
949 ];
950 }
951 }
952 return $result;
953 }
954 );
955 }
956
970 protected static function getChangeTagList( ResourceLoaderContext $context ) {
971 $tags = self::getChangeTagListSummary( $context );
972 $language = MediaWikiServices::getInstance()->getLanguageFactory()
973 ->getLanguage( $context->getLanguage() );
974 foreach ( $tags as &$tagInfo ) {
975 $tagInfo['label'] = Sanitizer::stripAllTags( $tagInfo['labelMsg']->parse() );
976 $tagInfo['description'] = $tagInfo['descriptionMsg'] ?
977 $language->truncateForVisual(
978 Sanitizer::stripAllTags( $tagInfo['descriptionMsg']->parse() ),
979 self::TAG_DESC_CHARACTER_LIMIT
980 ) :
981 '';
982 unset( $tagInfo['labelMsg'] );
983 unset( $tagInfo['descriptionMsg'] );
984 }
985
986 // Instead of sorting by hit count (disabled for now), sort by display name
987 usort( $tags, function ( $a, $b ) {
988 return strcasecmp( $a['label'], $b['label'] );
989 } );
990 return $tags;
991 }
992
996 protected function outputNoResults() {
997 $this->getOutput()->addHTML(
998 '<div class="mw-changeslist-empty">' .
999 $this->msg( 'recentchanges-noresult' )->parse() .
1000 '</div>'
1001 );
1002 }
1003
1007 protected function outputTimeout() {
1008 $this->getOutput()->addHTML(
1009 '<div class="mw-changeslist-empty mw-changeslist-timeout">' .
1010 $this->msg( 'recentchanges-timeout' )->parse() .
1011 '</div>'
1012 );
1013 }
1014
1020 public function getRows() {
1021 $opts = $this->getOptions();
1022
1023 $tables = [];
1024 $fields = [];
1025 $conds = [];
1026 $query_options = [];
1027 $join_conds = [];
1028 $this->buildQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
1029
1030 return $this->doMainQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
1031 }
1032
1038 public function getOptions() {
1039 if ( $this->rcOptions === null ) {
1040 $this->rcOptions = $this->setup( $this->rcSubpage );
1041 }
1042
1043 return $this->rcOptions;
1044 }
1045
1055 protected function registerFilters() {
1056 $this->registerFiltersFromDefinitions( $this->filterGroupDefinitions );
1057
1058 // Make sure this is not being transcluded (we don't want to show this
1059 // information to all users just because the user that saves the edit can
1060 // patrol or is logged in)
1061 if ( !$this->including() && $this->getUser()->useRCPatrol() ) {
1062 $this->registerFiltersFromDefinitions( $this->legacyReviewStatusFilterGroupDefinition );
1063 $this->registerFiltersFromDefinitions( $this->reviewStatusFilterGroupDefinition );
1064 }
1065
1066 $changeTypeGroup = $this->getFilterGroup( 'changeType' );
1067
1068 if ( $this->getConfig()->get( 'RCWatchCategoryMembership' ) ) {
1069 $transformedHideCategorizationDef = $this->transformFilterDefinition(
1070 $this->hideCategorizationFilterDefinition
1071 );
1072
1073 $transformedHideCategorizationDef['group'] = $changeTypeGroup;
1074
1075 $hideCategorization = new ChangesListBooleanFilter(
1076 $transformedHideCategorizationDef
1077 );
1078 }
1079
1080 $this->getHookRunner()->onChangesListSpecialPageStructuredFilters( $this );
1081
1082 $this->registerFiltersFromDefinitions( [] );
1083
1084 $userExperienceLevel = $this->getFilterGroup( 'userExpLevel' );
1085 $registered = $userExperienceLevel->getFilter( 'registered' );
1086 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'newcomer' ) );
1087 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'learner' ) );
1088 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'experienced' ) );
1089
1090 $categoryFilter = $changeTypeGroup->getFilter( 'hidecategorization' );
1091 $logactionsFilter = $changeTypeGroup->getFilter( 'hidelog' );
1092 $pagecreationFilter = $changeTypeGroup->getFilter( 'hidenewpages' );
1093
1094 $significanceTypeGroup = $this->getFilterGroup( 'significance' );
1095 $hideMinorFilter = $significanceTypeGroup->getFilter( 'hideminor' );
1096
1097 // categoryFilter is conditional; see registerFilters
1098 if ( $categoryFilter !== null ) {
1099 $hideMinorFilter->conflictsWith(
1100 $categoryFilter,
1101 'rcfilters-hideminor-conflicts-typeofchange-global',
1102 'rcfilters-hideminor-conflicts-typeofchange',
1103 'rcfilters-typeofchange-conflicts-hideminor'
1104 );
1105 }
1106 $hideMinorFilter->conflictsWith(
1107 $logactionsFilter,
1108 'rcfilters-hideminor-conflicts-typeofchange-global',
1109 'rcfilters-hideminor-conflicts-typeofchange',
1110 'rcfilters-typeofchange-conflicts-hideminor'
1111 );
1112 $hideMinorFilter->conflictsWith(
1113 $pagecreationFilter,
1114 'rcfilters-hideminor-conflicts-typeofchange-global',
1115 'rcfilters-hideminor-conflicts-typeofchange',
1116 'rcfilters-typeofchange-conflicts-hideminor'
1117 );
1118 }
1119
1129 protected function transformFilterDefinition( array $filterDefinition ) {
1130 return $filterDefinition;
1131 }
1132
1143 protected function registerFiltersFromDefinitions( array $definition ) {
1144 $autoFillPriority = -1;
1145 foreach ( $definition as $groupDefinition ) {
1146 if ( !isset( $groupDefinition['priority'] ) ) {
1147 $groupDefinition['priority'] = $autoFillPriority;
1148 } else {
1149 // If it's explicitly specified, start over the auto-fill
1150 $autoFillPriority = $groupDefinition['priority'];
1151 }
1152
1153 $autoFillPriority--;
1154
1155 $className = $groupDefinition['class'];
1156 unset( $groupDefinition['class'] );
1157
1158 foreach ( $groupDefinition['filters'] as &$filterDefinition ) {
1159 $filterDefinition = $this->transformFilterDefinition( $filterDefinition );
1160 }
1161
1162 $this->registerFilterGroup( new $className( $groupDefinition ) );
1163 }
1164 }
1165
1169 protected function getLegacyShowHideFilters() {
1170 $filters = [];
1171 foreach ( $this->filterGroups as $group ) {
1172 if ( $group instanceof ChangesListBooleanFilterGroup ) {
1173 foreach ( $group->getFilters() as $key => $filter ) {
1174 if ( $filter->displaysOnUnstructuredUi() ) {
1175 $filters[ $key ] = $filter;
1176 }
1177 }
1178 }
1179 }
1180 return $filters;
1181 }
1182
1191 public function setup( $parameters ) {
1192 $this->registerFilters();
1193
1194 $opts = $this->getDefaultOptions();
1195
1196 $opts = $this->fetchOptionsFromRequest( $opts );
1197
1198 // Give precedence to subpage syntax
1199 if ( $parameters !== null ) {
1200 $this->parseParameters( $parameters, $opts );
1201 }
1202
1203 $this->validateOptions( $opts );
1204
1205 return $opts;
1206 }
1207
1217 public function getDefaultOptions() {
1218 $opts = new FormOptions();
1219 $structuredUI = $this->isStructuredFilterUiEnabled();
1220 // If urlversion=2 is set, ignore the filter defaults and set them all to false/empty
1221 $useDefaults = $this->getRequest()->getInt( 'urlversion' ) !== 2;
1222
1224 foreach ( $this->filterGroups as $filterGroup ) {
1225 $filterGroup->addOptions( $opts, $useDefaults, $structuredUI );
1226 }
1227
1228 $opts->add( 'namespace', '', FormOptions::STRING );
1229 $opts->add( 'invert', false );
1230 $opts->add( 'associated', false );
1231 $opts->add( 'urlversion', 1 );
1232 $opts->add( 'tagfilter', '' );
1233
1234 $opts->add( 'days', $this->getDefaultDays(), FormOptions::FLOAT );
1235 $opts->add( 'limit', $this->getDefaultLimit(), FormOptions::INT );
1236
1237 $opts->add( 'from', '' );
1238
1239 return $opts;
1240 }
1241
1248 $groupName = $group->getName();
1249
1250 $this->filterGroups[$groupName] = $group;
1251 }
1252
1258 protected function getFilterGroups() {
1259 return $this->filterGroups;
1260 }
1261
1269 public function getFilterGroup( $groupName ) {
1270 return $this->filterGroups[$groupName] ?? null;
1271 }
1272
1273 // Currently, this intentionally only includes filters that display
1274 // in the structured UI. This can be changed easily, though, if we want
1275 // to include data on filters that use the unstructured UI. messageKeys is a
1276 // special top-level value, with the value being an array of the message keys to
1277 // send to the client.
1278
1286 public function getStructuredFilterJsData() {
1287 $output = [
1288 'groups' => [],
1289 'messageKeys' => [],
1290 ];
1291
1292 usort( $this->filterGroups, function ( $a, $b ) {
1293 return $b->getPriority() <=> $a->getPriority();
1294 } );
1295
1296 foreach ( $this->filterGroups as $groupName => $group ) {
1297 $groupOutput = $group->getJsData();
1298 if ( $groupOutput !== null ) {
1299 $output['messageKeys'] = array_merge(
1300 $output['messageKeys'],
1301 $groupOutput['messageKeys']
1302 );
1303
1304 unset( $groupOutput['messageKeys'] );
1305 $output['groups'][] = $groupOutput;
1306 }
1307 }
1308
1309 return $output;
1310 }
1311
1320 protected function fetchOptionsFromRequest( $opts ) {
1321 $opts->fetchValuesFromRequest( $this->getRequest() );
1322
1323 return $opts;
1324 }
1325
1332 public function parseParameters( $par, FormOptions $opts ) {
1333 $stringParameterNameSet = [];
1334 $hideParameterNameSet = [];
1335
1336 // URL parameters can be per-group, like 'userExpLevel',
1337 // or per-filter, like 'hideminor'.
1338
1339 foreach ( $this->filterGroups as $filterGroup ) {
1340 if ( $filterGroup instanceof ChangesListStringOptionsFilterGroup ) {
1341 $stringParameterNameSet[$filterGroup->getName()] = true;
1342 } elseif ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
1343 foreach ( $filterGroup->getFilters() as $filter ) {
1344 $hideParameterNameSet[$filter->getName()] = true;
1345 }
1346 }
1347 }
1348
1349 $bits = preg_split( '/\s*,\s*/', trim( $par ) );
1350 foreach ( $bits as $bit ) {
1351 $m = [];
1352 if ( isset( $hideParameterNameSet[$bit] ) ) {
1353 // hidefoo => hidefoo=true
1354 $opts[$bit] = true;
1355 } elseif ( isset( $hideParameterNameSet["hide$bit"] ) ) {
1356 // foo => hidefoo=false
1357 $opts["hide$bit"] = false;
1358 } elseif ( preg_match( '/^(.*)=(.*)$/', $bit, $m ) ) {
1359 if ( isset( $stringParameterNameSet[$m[1]] ) ) {
1360 $opts[$m[1]] = $m[2];
1361 }
1362 }
1363 }
1364 }
1365
1371 public function validateOptions( FormOptions $opts ) {
1372 $isContradictory = $this->fixContradictoryOptions( $opts );
1373 $isReplaced = $this->replaceOldOptions( $opts );
1374
1375 if ( $isContradictory || $isReplaced ) {
1376 $query = wfArrayToCgi( $this->convertParamsForLink( $opts->getChangedValues() ) );
1377 $this->getOutput()->redirect( $this->getPageTitle()->getCanonicalURL( $query ) );
1378 }
1379
1380 $opts->validateIntBounds( 'limit', 0, 5000 );
1381 $opts->validateBounds( 'days', 0, $this->getConfig()->get( 'RCMaxAge' ) / ( 3600 * 24 ) );
1382 }
1383
1390 private function fixContradictoryOptions( FormOptions $opts ) {
1391 $fixed = $this->fixBackwardsCompatibilityOptions( $opts );
1392
1393 foreach ( $this->filterGroups as $filterGroup ) {
1394 if ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
1395 $filters = $filterGroup->getFilters();
1396
1397 if ( count( $filters ) === 1 ) {
1398 // legacy boolean filters should not be considered
1399 continue;
1400 }
1401
1402 $allInGroupEnabled = array_reduce(
1403 $filters,
1404 function ( $carry, $filter ) use ( $opts ) {
1405 return $carry && $opts[ $filter->getName() ];
1406 },
1407 /* initialValue */ count( $filters ) > 0
1408 );
1409
1410 if ( $allInGroupEnabled ) {
1411 foreach ( $filters as $filter ) {
1412 $opts[ $filter->getName() ] = false;
1413 }
1414
1415 $fixed = true;
1416 }
1417 }
1418 }
1419
1420 return $fixed;
1421 }
1422
1433 if ( $opts['hideanons'] && $opts['hideliu'] ) {
1434 $opts->reset( 'hideanons' );
1435 if ( !$opts['hidebots'] ) {
1436 $opts->reset( 'hideliu' );
1437 $opts['hidehumans'] = 1;
1438 }
1439
1440 return true;
1441 }
1442
1443 return false;
1444 }
1445
1452 public function replaceOldOptions( FormOptions $opts ) {
1453 if ( !$this->isStructuredFilterUiEnabled() ) {
1454 return false;
1455 }
1456
1457 $changed = false;
1458
1459 // At this point 'hideanons' and 'hideliu' cannot be both true,
1460 // because fixBackwardsCompatibilityOptions resets (at least) 'hideanons' in such case
1461 if ( $opts[ 'hideanons' ] ) {
1462 $opts->reset( 'hideanons' );
1463 $opts[ 'userExpLevel' ] = 'registered';
1464 $changed = true;
1465 }
1466
1467 if ( $opts[ 'hideliu' ] ) {
1468 $opts->reset( 'hideliu' );
1469 $opts[ 'userExpLevel' ] = 'unregistered';
1470 $changed = true;
1471 }
1472
1473 if ( $this->getFilterGroup( 'legacyReviewStatus' ) ) {
1474 if ( $opts[ 'hidepatrolled' ] ) {
1475 $opts->reset( 'hidepatrolled' );
1476 $opts[ 'reviewStatus' ] = 'unpatrolled';
1477 $changed = true;
1478 }
1479
1480 if ( $opts[ 'hideunpatrolled' ] ) {
1481 $opts->reset( 'hideunpatrolled' );
1482 $opts[ 'reviewStatus' ] = implode(
1484 [ 'manual', 'auto' ]
1485 );
1486 $changed = true;
1487 }
1488 }
1489
1490 return $changed;
1491 }
1492
1501 protected function convertParamsForLink( $params ) {
1502 foreach ( $params as &$value ) {
1503 if ( $value === false ) {
1504 $value = '0';
1505 }
1506 }
1507 unset( $value );
1508 return $params;
1509 }
1510
1522 protected function buildQuery( &$tables, &$fields, &$conds, &$query_options,
1523 &$join_conds, FormOptions $opts
1524 ) {
1525 $dbr = $this->getDB();
1526 $isStructuredUI = $this->isStructuredFilterUiEnabled();
1527
1529 foreach ( $this->filterGroups as $filterGroup ) {
1530 $filterGroup->modifyQuery( $dbr, $this, $tables, $fields, $conds,
1531 $query_options, $join_conds, $opts, $isStructuredUI );
1532 }
1533
1534 // Namespace filtering
1535 if ( $opts[ 'namespace' ] !== '' ) {
1536 $namespaces = explode( ';', $opts[ 'namespace' ] );
1537
1538 $namespaces = $this->expandSymbolicNamespaceFilters( $namespaces );
1539
1540 if ( $opts[ 'associated' ] ) {
1541 $namespaceInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
1542 $associatedNamespaces = array_map(
1543 function ( $ns ) use ( $namespaceInfo ){
1544 return $namespaceInfo->getAssociated( $ns );
1545 },
1546 array_filter(
1547 $namespaces,
1548 function ( $ns ) use ( $namespaceInfo ) {
1549 return $namespaceInfo->hasTalkNamespace( $ns );
1550 }
1551 )
1552 );
1553 $namespaces = array_unique( array_merge( $namespaces, $associatedNamespaces ) );
1554 }
1555
1556 if ( count( $namespaces ) === 1 ) {
1557 $operator = $opts[ 'invert' ] ? '!=' : '=';
1558 $value = $dbr->addQuotes( reset( $namespaces ) );
1559 } else {
1560 $operator = $opts[ 'invert' ] ? 'NOT IN' : 'IN';
1561 sort( $namespaces );
1562 $value = '(' . $dbr->makeList( $namespaces ) . ')';
1563 }
1564 $conds[] = "rc_namespace $operator $value";
1565 }
1566
1567 // Calculate cutoff
1568 $cutoff_unixtime = time() - $opts['days'] * 3600 * 24;
1569 $cutoff = $dbr->timestamp( $cutoff_unixtime );
1570
1571 $fromValid = preg_match( '/^[0-9]{14}$/', $opts['from'] );
1572 if ( $fromValid && $opts['from'] > wfTimestamp( TS_MW, $cutoff ) ) {
1573 $cutoff = $dbr->timestamp( $opts['from'] );
1574 } else {
1575 $opts->reset( 'from' );
1576 }
1577
1578 $conds[] = 'rc_timestamp >= ' . $dbr->addQuotes( $cutoff );
1579 }
1580
1592 protected function doMainQuery( $tables, $fields, $conds,
1593 $query_options, $join_conds, FormOptions $opts
1594 ) {
1595 $rcQuery = RecentChange::getQueryInfo();
1596 $tables = array_merge( $tables, $rcQuery['tables'] );
1597 $fields = array_merge( $rcQuery['fields'], $fields );
1598 $join_conds = array_merge( $join_conds, $rcQuery['joins'] );
1599
1601 $tables,
1602 $fields,
1603 $conds,
1604 $join_conds,
1605 $query_options,
1606 ''
1607 );
1608
1609 if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
1610 $opts )
1611 ) {
1612 return false;
1613 }
1614
1615 $dbr = $this->getDB();
1616
1617 return $dbr->select(
1618 $tables,
1619 $fields,
1620 $conds,
1621 __METHOD__,
1622 $query_options,
1623 $join_conds
1624 );
1625 }
1626
1627 protected function runMainQueryHook( &$tables, &$fields, &$conds,
1628 &$query_options, &$join_conds, $opts
1629 ) {
1630 return $this->getHookRunner()->onChangesListSpecialPageQuery(
1631 $this->getName(), $tables, $fields, $conds, $query_options, $join_conds, $opts );
1632 }
1633
1639 protected function getDB() {
1640 return wfGetDB( DB_REPLICA );
1641 }
1642
1649 private function webOutputHeader( $rowCount, $opts ) {
1650 if ( !$this->including() ) {
1651 $this->outputFeedLinks();
1652 $this->doHeader( $opts, $rowCount );
1653 }
1654 }
1655
1662 public function webOutput( $rows, $opts ) {
1663 $this->webOutputHeader( $rows->numRows(), $opts );
1664
1665 $this->outputChangesList( $rows, $opts );
1666 }
1667
1671 public function outputFeedLinks() {
1672 // nothing by default
1673 }
1674
1681 abstract public function outputChangesList( $rows, $opts );
1682
1689 public function doHeader( $opts, $numRows ) {
1690 $this->setTopText( $opts );
1691
1692 // @todo Lots of stuff should be done here.
1693
1694 $this->setBottomText( $opts );
1695 }
1696
1704 public function setTopText( FormOptions $opts ) {
1705 // nothing by default
1706 }
1707
1715 public function setBottomText( FormOptions $opts ) {
1716 // nothing by default
1717 }
1718
1728 public function getExtraOptions( $opts ) {
1729 return [];
1730 }
1731
1737 public function makeLegend() {
1738 $context = $this->getContext();
1739 $user = $context->getUser();
1740 # The legend showing what the letters and stuff mean
1741 $legend = Html::openElement( 'dl' ) . "\n";
1742 # Iterates through them and gets the messages for both letter and tooltip
1743 $legendItems = $context->getConfig()->get( 'RecentChangesFlags' );
1744 if ( !( $user->useRCPatrol() || $user->useNPPatrol() ) ) {
1745 unset( $legendItems['unpatrolled'] );
1746 }
1747 foreach ( $legendItems as $key => $item ) { # generate items of the legend
1748 $label = $item['legend'] ?? $item['title'];
1749 $letter = $item['letter'];
1750 $cssClass = $item['class'] ?? $key;
1751
1752 $legend .= Html::element( 'dt',
1753 [ 'class' => $cssClass ], $context->msg( $letter )->text()
1754 ) . "\n" .
1755 Html::rawElement( 'dd',
1756 [ 'class' => Sanitizer::escapeClass( 'mw-changeslist-legend-' . $key ) ],
1757 $context->msg( $label )->parse()
1758 ) . "\n";
1759 }
1760 # (+-123)
1761 $legend .= Html::rawElement( 'dt',
1762 [ 'class' => 'mw-plusminus-pos' ],
1763 $context->msg( 'recentchanges-legend-plusminus' )->parse()
1764 ) . "\n";
1765 $legend .= Html::element(
1766 'dd',
1767 [ 'class' => 'mw-changeslist-legend-plusminus' ],
1768 $context->msg( 'recentchanges-label-plusminus' )->text()
1769 ) . "\n";
1770 // Watchlist expiry clock icon.
1771 if ( $context->getConfig()->get( 'WatchlistExpiry' ) ) {
1772 $widget = new IconWidget( [
1773 'icon' => 'clock',
1774 'classes' => [ 'mw-changesList-watchlistExpiry' ],
1775 ] );
1776 // Link the image to its label for assistive technologies.
1777 $watchlistLabelId = 'mw-changeslist-watchlistExpiry-label';
1778 $widget->getIconElement()->setAttributes( [
1779 'role' => 'img',
1780 'aria-labelledby' => $watchlistLabelId,
1781 ] );
1782 $legend .= Html::rawElement(
1783 'dt',
1784 [ 'class' => 'mw-changeslist-legend-watchlistexpiry' ],
1785 $widget
1786 );
1787 $legend .= Html::element(
1788 'dd',
1789 [ 'class' => 'mw-changeslist-legend-watchlistexpiry', 'id' => $watchlistLabelId ],
1790 $context->msg( 'recentchanges-legend-watchlistexpiry' )->text()
1791 );
1792 }
1793 $legend .= Html::closeElement( 'dl' ) . "\n";
1794
1795 $legendHeading = $this->isStructuredFilterUiEnabled() ?
1796 $context->msg( 'rcfilters-legend-heading' )->parse() :
1797 $context->msg( 'recentchanges-legend-heading' )->parse();
1798
1799 # Collapsible
1800 $collapsedState = $this->getRequest()->getCookie( 'changeslist-state' );
1801 $collapsedClass = $collapsedState === 'collapsed' ? ' mw-collapsed' : '';
1802
1803 $legend =
1804 '<div class="mw-changeslist-legend mw-collapsible' . $collapsedClass . '">' .
1805 $legendHeading .
1806 '<div class="mw-collapsible-content">' . $legend . '</div>' .
1807 '</div>';
1808
1809 return $legend;
1810 }
1811
1815 protected function addModules() {
1816 $out = $this->getOutput();
1817 // Styles and behavior for the legend box (see makeLegend())
1818 $out->addModuleStyles( [
1819 'mediawiki.interface.helpers.styles',
1820 'mediawiki.special.changeslist.legend',
1821 'mediawiki.special.changeslist',
1822 ] );
1823 $out->addModules( 'mediawiki.special.changeslist.legend.js' );
1824
1825 if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
1826 $out->addModules( 'mediawiki.rcfilters.filters.ui' );
1827 $out->addModuleStyles( 'mediawiki.rcfilters.filters.base.styles' );
1828 }
1829 }
1830
1831 protected function getGroupName() {
1832 return 'changes';
1833 }
1834
1851 public function filterOnUserExperienceLevel( $specialPageClassName, $context, $dbr,
1852 &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedExpLevels, $now = 0
1853 ) {
1854 global $wgLearnerEdits,
1858
1859 $LEVEL_COUNT = 5;
1860
1861 // If all levels are selected, don't filter
1862 if ( count( $selectedExpLevels ) === $LEVEL_COUNT ) {
1863 return;
1864 }
1865
1866 // both 'registered' and 'unregistered', experience levels, if any, are included in 'registered'
1867 if (
1868 in_array( 'registered', $selectedExpLevels ) &&
1869 in_array( 'unregistered', $selectedExpLevels )
1870 ) {
1871 return;
1872 }
1873
1874 $actorMigration = ActorMigration::newMigration();
1875 $actorQuery = $actorMigration->getJoin( 'rc_user' );
1876 $tables += $actorQuery['tables'];
1877 $join_conds += $actorQuery['joins'];
1878
1879 // 'registered' but not 'unregistered', experience levels, if any, are included in 'registered'
1880 if (
1881 in_array( 'registered', $selectedExpLevels ) &&
1882 !in_array( 'unregistered', $selectedExpLevels )
1883 ) {
1884 $conds[] = $actorMigration->isNotAnon( $actorQuery['fields']['rc_user'] );
1885 return;
1886 }
1887
1888 if ( $selectedExpLevels === [ 'unregistered' ] ) {
1889 $conds[] = $actorMigration->isAnon( $actorQuery['fields']['rc_user'] );
1890 return;
1891 }
1892
1893 $tables[] = 'user';
1894 $join_conds['user'] = [ 'LEFT JOIN', $actorQuery['fields']['rc_user'] . ' = user_id' ];
1895
1896 if ( $now === 0 ) {
1897 $now = time();
1898 }
1899 $secondsPerDay = 86400;
1900 $learnerCutoff = $now - $wgLearnerMemberSince * $secondsPerDay;
1901 $experiencedUserCutoff = $now - $wgExperiencedUserMemberSince * $secondsPerDay;
1902
1903 $aboveNewcomer = $dbr->makeList(
1904 [
1905 'user_editcount >= ' . intval( $wgLearnerEdits ),
1906 'user_registration <= ' . $dbr->addQuotes( $dbr->timestamp( $learnerCutoff ) ),
1907 ],
1908 IDatabase::LIST_AND
1909 );
1910
1911 $aboveLearner = $dbr->makeList(
1912 [
1913 'user_editcount >= ' . intval( $wgExperiencedUserEdits ),
1914 'user_registration <= ' .
1915 $dbr->addQuotes( $dbr->timestamp( $experiencedUserCutoff ) ),
1916 ],
1917 IDatabase::LIST_AND
1918 );
1919
1920 $conditions = [];
1921
1922 if ( in_array( 'unregistered', $selectedExpLevels ) ) {
1923 $selectedExpLevels = array_diff( $selectedExpLevels, [ 'unregistered' ] );
1924 $conditions[] = $actorMigration->isAnon( $actorQuery['fields']['rc_user'] );
1925 }
1926
1927 if ( $selectedExpLevels === [ 'newcomer' ] ) {
1928 $conditions[] = "NOT ( $aboveNewcomer )";
1929 } elseif ( $selectedExpLevels === [ 'learner' ] ) {
1930 $conditions[] = $dbr->makeList(
1931 [ $aboveNewcomer, "NOT ( $aboveLearner )" ],
1932 IDatabase::LIST_AND
1933 );
1934 } elseif ( $selectedExpLevels === [ 'experienced' ] ) {
1935 $conditions[] = $aboveLearner;
1936 } elseif ( $selectedExpLevels === [ 'learner', 'newcomer' ] ) {
1937 $conditions[] = "NOT ( $aboveLearner )";
1938 } elseif ( $selectedExpLevels === [ 'experienced', 'newcomer' ] ) {
1939 $conditions[] = $dbr->makeList(
1940 [ "NOT ( $aboveNewcomer )", $aboveLearner ],
1941 IDatabase::LIST_OR
1942 );
1943 } elseif ( $selectedExpLevels === [ 'experienced', 'learner' ] ) {
1944 $conditions[] = $aboveNewcomer;
1945 } elseif ( $selectedExpLevels === [ 'experienced', 'learner', 'newcomer' ] ) {
1946 $conditions[] = $actorMigration->isNotAnon( $actorQuery['fields']['rc_user'] );
1947 }
1948
1949 if ( count( $conditions ) > 1 ) {
1950 $conds[] = $dbr->makeList( $conditions, IDatabase::LIST_OR );
1951 } elseif ( count( $conditions ) === 1 ) {
1952 $conds[] = reset( $conditions );
1953 }
1954 }
1955
1962 if ( $this->getRequest()->getBool( 'rcfilters' ) ) {
1963 return true;
1964 }
1965
1966 return static::checkStructuredFilterUiEnabled( $this->getUser() );
1967 }
1968
1976 public static function checkStructuredFilterUiEnabled( $user ) {
1977 if ( $user instanceof Config ) {
1978 wfDeprecated( __METHOD__ . ' with Config argument', '1.34' );
1979 $user = func_get_arg( 1 );
1980 }
1981 return !$user->getOption( 'rcenhancedfilters-disable' );
1982 }
1983
1991 public function getDefaultLimit() {
1992 return $this->getUser()->getIntOption( static::$limitPreferenceName );
1993 }
1994
2003 public function getDefaultDays() {
2004 return floatval( $this->getUser()->getOption( static::$daysPreferenceName ) );
2005 }
2006
2007 private function expandSymbolicNamespaceFilters( array $namespaces ) {
2008 $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
2009 $symbolicFilters = [
2010 'all-contents' => $nsInfo->getSubjectNamespaces(),
2011 'all-discussions' => $nsInfo->getTalkNamespaces(),
2012 ];
2013 $additionalNamespaces = [];
2014 foreach ( $symbolicFilters as $name => $values ) {
2015 if ( in_array( $name, $namespaces ) ) {
2016 $additionalNamespaces = array_merge( $additionalNamespaces, $values );
2017 }
2018 }
2019 $namespaces = array_diff( $namespaces, array_keys( $symbolicFilters ) );
2020 $namespaces = array_merge( $namespaces, $additionalNamespaces );
2021 return array_unique( $namespaces );
2022 }
2023}
getDB()
getUser()
$wgLearnerMemberSince
Specify the difference engine to use.
$wgExperiencedUserMemberSince
Specify the difference engine to use.
$wgLearnerEdits
The following variables define 3 user experience levels:
$wgExperiencedUserEdits
Specify the difference engine to use.
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 $function is deprecated.
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.
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.
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 $limitPreferenceName
Preference name for 'limit'.
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)
outputFeedLinks()
Output feed links.
outputTimeout()
Add the "timeout" message to the output.
doHeader( $opts, $numRows)
Set the text to be displayed above the changes.
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.
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 )
Class representing a list of titles The execute() method checks them all for existence and adds them ...
Definition LinkBatch.php:35
add( $ns, $dbkey)
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.
Context object that contains information about the state of a specific ResourceLoader web request.
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.
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.
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 ResultsWrapper so it doesn't go anywhere near an actual da...
const NS_USER
Definition Defines.php:72
const RC_NEW
Definition Defines.php:133
const LIST_OR
Definition Defines.php:52
const RC_LOG
Definition Defines.php:134
const NS_USER_TALK
Definition Defines.php:73
const RC_EDIT
Definition Defines.php:132
const RC_CATEGORIZE
Definition Defines.php:136
Interface for configuration instances.
Definition Config.php:30
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
const DB_REPLICA
Definition defines.php:25