MediaWiki  master
ChangesListSpecialPage.php
Go to the documentation of this file.
1 <?php
30 
37 abstract class ChangesListSpecialPage extends SpecialPage {
43 
48  protected static $savedQueriesPreferenceName;
49 
54  protected static $daysPreferenceName;
55 
60  protected static $limitPreferenceName;
61 
66  protected static $collapsedPreferenceName;
67 
69  protected $rcSubpage;
70 
72  protected $rcOptions;
73 
74  // Order of both groups and filters is significant; first is top-most priority,
75  // descending from there.
76  // 'showHideSuffix' is a shortcut to and avoid spelling out
77  // details specific to subclasses here.
91 
92  // Same format as filterGroupDefinitions, but for a single group (reviewStatus)
93  // that is registered conditionally.
95 
96  // Single filter group registered conditionally
98 
99  // Single filter group registered conditionally
101 
108  protected $filterGroups = [];
109 
110  public function __construct( $name, $restriction ) {
111  parent::__construct( $name, $restriction );
112 
113  $nonRevisionTypes = [ RC_LOG ];
114  Hooks::run( 'SpecialWatchlistGetNonRevisionTypes', [ &$nonRevisionTypes ] );
115 
116  $this->filterGroupDefinitions = [
117  [
118  'name' => 'registration',
119  'title' => 'rcfilters-filtergroup-registration',
120  'class' => ChangesListBooleanFilterGroup::class,
121  'filters' => [
122  [
123  'name' => 'hideliu',
124  // rcshowhideliu-show, rcshowhideliu-hide,
125  // wlshowhideliu
126  'showHideSuffix' => 'showhideliu',
127  'default' => false,
128  'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
129  &$query_options, &$join_conds
130  ) {
131  $actorMigration = ActorMigration::newMigration();
132  $actorQuery = $actorMigration->getJoin( 'rc_user' );
133  $tables += $actorQuery['tables'];
134  $join_conds += $actorQuery['joins'];
135  $conds[] = $actorMigration->isAnon( $actorQuery['fields']['rc_user'] );
136  },
137  'isReplacedInStructuredUi' => true,
138 
139  ],
140  [
141  'name' => 'hideanons',
142  // rcshowhideanons-show, rcshowhideanons-hide,
143  // wlshowhideanons
144  'showHideSuffix' => 'showhideanons',
145  'default' => false,
146  'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
147  &$query_options, &$join_conds
148  ) {
149  $actorMigration = ActorMigration::newMigration();
150  $actorQuery = $actorMigration->getJoin( 'rc_user' );
151  $tables += $actorQuery['tables'];
152  $join_conds += $actorQuery['joins'];
153  $conds[] = $actorMigration->isNotAnon( $actorQuery['fields']['rc_user'] );
154  },
155  'isReplacedInStructuredUi' => true,
156  ]
157  ],
158  ],
159 
160  [
161  'name' => 'userExpLevel',
162  'title' => 'rcfilters-filtergroup-user-experience-level',
163  'class' => ChangesListStringOptionsFilterGroup::class,
164  'isFullCoverage' => true,
165  'filters' => [
166  [
167  'name' => 'unregistered',
168  'label' => 'rcfilters-filter-user-experience-level-unregistered-label',
169  'description' => 'rcfilters-filter-user-experience-level-unregistered-description',
170  'cssClassSuffix' => 'user-unregistered',
171  'isRowApplicableCallable' => function ( $ctx, $rc ) {
172  return !$rc->getAttribute( 'rc_user' );
173  }
174  ],
175  [
176  'name' => 'registered',
177  'label' => 'rcfilters-filter-user-experience-level-registered-label',
178  'description' => 'rcfilters-filter-user-experience-level-registered-description',
179  'cssClassSuffix' => 'user-registered',
180  'isRowApplicableCallable' => function ( $ctx, $rc ) {
181  return $rc->getAttribute( 'rc_user' );
182  }
183  ],
184  [
185  'name' => 'newcomer',
186  'label' => 'rcfilters-filter-user-experience-level-newcomer-label',
187  'description' => 'rcfilters-filter-user-experience-level-newcomer-description',
188  'cssClassSuffix' => 'user-newcomer',
189  'isRowApplicableCallable' => function ( $ctx, $rc ) {
190  $performer = $rc->getPerformer();
191  return $performer && $performer->isLoggedIn() &&
192  $performer->getExperienceLevel() === 'newcomer';
193  }
194  ],
195  [
196  'name' => 'learner',
197  'label' => 'rcfilters-filter-user-experience-level-learner-label',
198  'description' => 'rcfilters-filter-user-experience-level-learner-description',
199  'cssClassSuffix' => 'user-learner',
200  'isRowApplicableCallable' => function ( $ctx, $rc ) {
201  $performer = $rc->getPerformer();
202  return $performer && $performer->isLoggedIn() &&
203  $performer->getExperienceLevel() === 'learner';
204  },
205  ],
206  [
207  'name' => 'experienced',
208  'label' => 'rcfilters-filter-user-experience-level-experienced-label',
209  'description' => 'rcfilters-filter-user-experience-level-experienced-description',
210  'cssClassSuffix' => 'user-experienced',
211  'isRowApplicableCallable' => function ( $ctx, $rc ) {
212  $performer = $rc->getPerformer();
213  return $performer && $performer->isLoggedIn() &&
214  $performer->getExperienceLevel() === 'experienced';
215  },
216  ]
217  ],
219  'queryCallable' => [ $this, 'filterOnUserExperienceLevel' ],
220  ],
221 
222  [
223  'name' => 'authorship',
224  'title' => 'rcfilters-filtergroup-authorship',
225  'class' => ChangesListBooleanFilterGroup::class,
226  'filters' => [
227  [
228  'name' => 'hidemyself',
229  'label' => 'rcfilters-filter-editsbyself-label',
230  'description' => 'rcfilters-filter-editsbyself-description',
231  // rcshowhidemine-show, rcshowhidemine-hide,
232  // wlshowhidemine
233  'showHideSuffix' => 'showhidemine',
234  'default' => false,
235  'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
236  &$query_options, &$join_conds
237  ) {
238  $actorQuery = ActorMigration::newMigration()->getWhere( $dbr, 'rc_user', $ctx->getUser() );
239  $tables += $actorQuery['tables'];
240  $join_conds += $actorQuery['joins'];
241  $conds[] = 'NOT(' . $actorQuery['conds'] . ')';
242  },
243  'cssClassSuffix' => 'self',
244  'isRowApplicableCallable' => function ( $ctx, $rc ) {
245  return $ctx->getUser()->equals( $rc->getPerformer() );
246  },
247  ],
248  [
249  'name' => 'hidebyothers',
250  'label' => 'rcfilters-filter-editsbyother-label',
251  'description' => 'rcfilters-filter-editsbyother-description',
252  'default' => false,
253  'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
254  &$query_options, &$join_conds
255  ) {
256  $actorQuery = ActorMigration::newMigration()
257  ->getWhere( $dbr, 'rc_user', $ctx->getUser(), false );
258  $tables += $actorQuery['tables'];
259  $join_conds += $actorQuery['joins'];
260  $conds[] = $actorQuery['conds'];
261  },
262  'cssClassSuffix' => 'others',
263  'isRowApplicableCallable' => function ( $ctx, $rc ) {
264  return !$ctx->getUser()->equals( $rc->getPerformer() );
265  },
266  ]
267  ]
268  ],
269 
270  [
271  'name' => 'automated',
272  'title' => 'rcfilters-filtergroup-automated',
273  'class' => ChangesListBooleanFilterGroup::class,
274  'filters' => [
275  [
276  'name' => 'hidebots',
277  'label' => 'rcfilters-filter-bots-label',
278  'description' => 'rcfilters-filter-bots-description',
279  // rcshowhidebots-show, rcshowhidebots-hide,
280  // wlshowhidebots
281  'showHideSuffix' => 'showhidebots',
282  'default' => false,
283  'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
284  &$query_options, &$join_conds
285  ) {
286  $conds['rc_bot'] = 0;
287  },
288  'cssClassSuffix' => 'bot',
289  'isRowApplicableCallable' => function ( $ctx, $rc ) {
290  return $rc->getAttribute( 'rc_bot' );
291  },
292  ],
293  [
294  'name' => 'hidehumans',
295  'label' => 'rcfilters-filter-humans-label',
296  'description' => 'rcfilters-filter-humans-description',
297  'default' => false,
298  'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
299  &$query_options, &$join_conds
300  ) {
301  $conds['rc_bot'] = 1;
302  },
303  'cssClassSuffix' => 'human',
304  'isRowApplicableCallable' => function ( $ctx, $rc ) {
305  return !$rc->getAttribute( 'rc_bot' );
306  },
307  ]
308  ]
309  ],
310 
311  // significance (conditional)
312 
313  [
314  'name' => 'significance',
315  'title' => 'rcfilters-filtergroup-significance',
316  'class' => ChangesListBooleanFilterGroup::class,
317  'priority' => -6,
318  'filters' => [
319  [
320  'name' => 'hideminor',
321  'label' => 'rcfilters-filter-minor-label',
322  'description' => 'rcfilters-filter-minor-description',
323  // rcshowhideminor-show, rcshowhideminor-hide,
324  // wlshowhideminor
325  'showHideSuffix' => 'showhideminor',
326  'default' => false,
327  'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
328  &$query_options, &$join_conds
329  ) {
330  $conds[] = 'rc_minor = 0';
331  },
332  'cssClassSuffix' => 'minor',
333  'isRowApplicableCallable' => function ( $ctx, $rc ) {
334  return $rc->getAttribute( 'rc_minor' );
335  }
336  ],
337  [
338  'name' => 'hidemajor',
339  'label' => 'rcfilters-filter-major-label',
340  'description' => 'rcfilters-filter-major-description',
341  'default' => false,
342  'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
343  &$query_options, &$join_conds
344  ) {
345  $conds[] = 'rc_minor = 1';
346  },
347  'cssClassSuffix' => 'major',
348  'isRowApplicableCallable' => function ( $ctx, $rc ) {
349  return !$rc->getAttribute( 'rc_minor' );
350  }
351  ]
352  ]
353  ],
354 
355  [
356  'name' => 'lastRevision',
357  'title' => 'rcfilters-filtergroup-lastrevision',
358  'class' => ChangesListBooleanFilterGroup::class,
359  'priority' => -7,
360  'filters' => [
361  [
362  'name' => 'hidelastrevision',
363  'label' => 'rcfilters-filter-lastrevision-label',
364  'description' => 'rcfilters-filter-lastrevision-description',
365  'default' => false,
366  'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
367  &$query_options, &$join_conds ) use ( $nonRevisionTypes ) {
368  $conds[] = $dbr->makeList(
369  [
370  'rc_this_oldid <> page_latest',
371  'rc_type' => $nonRevisionTypes,
372  ],
373  LIST_OR
374  );
375  },
376  'cssClassSuffix' => 'last',
377  'isRowApplicableCallable' => function ( $ctx, $rc ) {
378  return $rc->getAttribute( 'rc_this_oldid' ) === $rc->getAttribute( 'page_latest' );
379  }
380  ],
381  [
382  'name' => 'hidepreviousrevisions',
383  'label' => 'rcfilters-filter-previousrevision-label',
384  'description' => 'rcfilters-filter-previousrevision-description',
385  'default' => false,
386  'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
387  &$query_options, &$join_conds ) use ( $nonRevisionTypes ) {
388  $conds[] = $dbr->makeList(
389  [
390  'rc_this_oldid = page_latest',
391  'rc_type' => $nonRevisionTypes,
392  ],
393  LIST_OR
394  );
395  },
396  'cssClassSuffix' => 'previous',
397  'isRowApplicableCallable' => function ( $ctx, $rc ) {
398  return $rc->getAttribute( 'rc_this_oldid' ) !== $rc->getAttribute( 'page_latest' );
399  }
400  ]
401  ]
402  ],
403 
404  // With extensions, there can be change types that will not be hidden by any of these.
405  [
406  'name' => 'changeType',
407  'title' => 'rcfilters-filtergroup-changetype',
408  'class' => ChangesListBooleanFilterGroup::class,
409  'priority' => -8,
410  'filters' => [
411  [
412  'name' => 'hidepageedits',
413  'label' => 'rcfilters-filter-pageedits-label',
414  'description' => 'rcfilters-filter-pageedits-description',
415  'default' => false,
416  'priority' => -2,
417  'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
418  &$query_options, &$join_conds
419  ) {
420  $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_EDIT );
421  },
422  'cssClassSuffix' => 'src-mw-edit',
423  'isRowApplicableCallable' => function ( $ctx, $rc ) {
424  return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_EDIT;
425  },
426  ],
427  [
428  'name' => 'hidenewpages',
429  'label' => 'rcfilters-filter-newpages-label',
430  'description' => 'rcfilters-filter-newpages-description',
431  'default' => false,
432  'priority' => -3,
433  'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
434  &$query_options, &$join_conds
435  ) {
436  $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_NEW );
437  },
438  'cssClassSuffix' => 'src-mw-new',
439  'isRowApplicableCallable' => function ( $ctx, $rc ) {
440  return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_NEW;
441  },
442  ],
443 
444  // hidecategorization
445 
446  [
447  'name' => 'hidelog',
448  'label' => 'rcfilters-filter-logactions-label',
449  'description' => 'rcfilters-filter-logactions-description',
450  'default' => false,
451  'priority' => -5,
452  'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
453  &$query_options, &$join_conds
454  ) {
455  $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_LOG );
456  },
457  'cssClassSuffix' => 'src-mw-log',
458  'isRowApplicableCallable' => function ( $ctx, $rc ) {
459  return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_LOG;
460  }
461  ],
462  ],
463  ],
464 
465  ];
466 
467  $this->legacyReviewStatusFilterGroupDefinition = [
468  [
469  'name' => 'legacyReviewStatus',
470  'title' => 'rcfilters-filtergroup-reviewstatus',
471  'class' => ChangesListBooleanFilterGroup::class,
472  'filters' => [
473  [
474  'name' => 'hidepatrolled',
475  // rcshowhidepatr-show, rcshowhidepatr-hide
476  // wlshowhidepatr
477  'showHideSuffix' => 'showhidepatr',
478  'default' => false,
479  'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
480  &$query_options, &$join_conds
481  ) {
482  $conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED;
483  },
484  'isReplacedInStructuredUi' => true,
485  ],
486  [
487  'name' => 'hideunpatrolled',
488  'default' => false,
489  'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
490  &$query_options, &$join_conds
491  ) {
492  $conds[] = 'rc_patrolled != ' . RecentChange::PRC_UNPATROLLED;
493  },
494  'isReplacedInStructuredUi' => true,
495  ],
496  ],
497  ]
498  ];
499 
500  $this->reviewStatusFilterGroupDefinition = [
501  [
502  'name' => 'reviewStatus',
503  'title' => 'rcfilters-filtergroup-reviewstatus',
504  'class' => ChangesListStringOptionsFilterGroup::class,
505  'isFullCoverage' => true,
506  'priority' => -5,
507  'filters' => [
508  [
509  'name' => 'unpatrolled',
510  'label' => 'rcfilters-filter-reviewstatus-unpatrolled-label',
511  'description' => 'rcfilters-filter-reviewstatus-unpatrolled-description',
512  'cssClassSuffix' => 'reviewstatus-unpatrolled',
513  'isRowApplicableCallable' => function ( $ctx, $rc ) {
514  return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED;
515  },
516  ],
517  [
518  'name' => 'manual',
519  'label' => 'rcfilters-filter-reviewstatus-manual-label',
520  'description' => 'rcfilters-filter-reviewstatus-manual-description',
521  'cssClassSuffix' => 'reviewstatus-manual',
522  'isRowApplicableCallable' => function ( $ctx, $rc ) {
523  return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_PATROLLED;
524  },
525  ],
526  [
527  'name' => 'auto',
528  'label' => 'rcfilters-filter-reviewstatus-auto-label',
529  'description' => 'rcfilters-filter-reviewstatus-auto-description',
530  'cssClassSuffix' => 'reviewstatus-auto',
531  'isRowApplicableCallable' => function ( $ctx, $rc ) {
532  return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_AUTOPATROLLED;
533  },
534  ],
535  ],
537  'queryCallable' => function ( $specialPageClassName, $ctx, $dbr,
538  &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selected
539  ) {
540  if ( $selected === [] ) {
541  return;
542  }
543  $rcPatrolledValues = [
544  'unpatrolled' => RecentChange::PRC_UNPATROLLED,
545  'manual' => RecentChange::PRC_PATROLLED,
547  ];
548  // e.g. rc_patrolled IN (0, 2)
549  $conds['rc_patrolled'] = array_map( function ( $s ) use ( $rcPatrolledValues ) {
550  return $rcPatrolledValues[ $s ];
551  }, $selected );
552  }
553  ]
554  ];
555 
556  $this->hideCategorizationFilterDefinition = [
557  'name' => 'hidecategorization',
558  'label' => 'rcfilters-filter-categorization-label',
559  'description' => 'rcfilters-filter-categorization-description',
560  // rcshowhidecategorization-show, rcshowhidecategorization-hide.
561  // wlshowhidecategorization
562  'showHideSuffix' => 'showhidecategorization',
563  'default' => false,
564  'priority' => -4,
565  'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
566  &$query_options, &$join_conds
567  ) {
568  $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_CATEGORIZE );
569  },
570  'cssClassSuffix' => 'src-mw-categorize',
571  'isRowApplicableCallable' => function ( $ctx, $rc ) {
572  return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_CATEGORIZE;
573  },
574  ];
575  }
576 
582  protected function areFiltersInConflict() {
583  $opts = $this->getOptions();
585  foreach ( $this->getFilterGroups() as $group ) {
586  if ( $group->getConflictingGroups() ) {
587  wfLogWarning(
588  $group->getName() .
589  " specifies conflicts with other groups but these are not supported yet."
590  );
591  }
592 
594  foreach ( $group->getConflictingFilters() as $conflictingFilter ) {
595  if ( $conflictingFilter->activelyInConflictWithGroup( $group, $opts ) ) {
596  return true;
597  }
598  }
599 
601  foreach ( $group->getFilters() as $filter ) {
603  foreach ( $filter->getConflictingFilters() as $conflictingFilter ) {
604  if (
605  $conflictingFilter->activelyInConflictWithFilter( $filter, $opts ) &&
606  $filter->activelyInConflictWithFilter( $conflictingFilter, $opts )
607  ) {
608  return true;
609  }
610  }
611 
612  }
613 
614  }
615 
616  return false;
617  }
618 
622  public function execute( $subpage ) {
623  $this->rcSubpage = $subpage;
624 
625  $this->considerActionsForDefaultSavedQuery( $subpage );
626 
627  $opts = $this->getOptions();
628  try {
629  $rows = $this->getRows();
630  if ( $rows === false ) {
631  $rows = new FakeResultWrapper( [] );
632  }
633 
634  // Used by Structured UI app to get results without MW chrome
635  if ( $this->getRequest()->getVal( 'action' ) === 'render' ) {
636  $this->getOutput()->setArticleBodyOnly( true );
637  }
638 
639  // Used by "live update" and "view newest" to check
640  // if there's new changes with minimal data transfer
641  if ( $this->getRequest()->getBool( 'peek' ) ) {
642  $code = $rows->numRows() > 0 ? 200 : 204;
643  $this->getOutput()->setStatusCode( $code );
644 
645  if ( $this->getUser()->isAnon() !==
646  $this->getRequest()->getFuzzyBool( 'isAnon' )
647  ) {
648  $this->getOutput()->setStatusCode( 205 );
649  }
650 
651  return;
652  }
653 
654  $batch = new LinkBatch;
655  foreach ( $rows as $row ) {
656  $batch->add( NS_USER, $row->rc_user_text );
657  $batch->add( NS_USER_TALK, $row->rc_user_text );
658  $batch->add( $row->rc_namespace, $row->rc_title );
659  if ( $row->rc_source === RecentChange::SRC_LOG ) {
660  $formatter = LogFormatter::newFromRow( $row );
661  foreach ( $formatter->getPreloadTitles() as $title ) {
662  $batch->addObj( $title );
663  }
664  }
665  }
666  $batch->execute();
667 
668  $this->setHeaders();
669  $this->outputHeader();
670  $this->addModules();
671  $this->webOutput( $rows, $opts );
672 
673  $rows->free();
674  } catch ( DBQueryTimeoutError $timeoutException ) {
675  MWExceptionHandler::logException( $timeoutException );
676 
677  $this->setHeaders();
678  $this->outputHeader();
679  $this->addModules();
680 
681  $this->getOutput()->setStatusCode( 500 );
682  $this->webOutputHeader( 0, $opts );
683  $this->outputTimeout();
684  }
685 
686  if ( $this->getConfig()->get( 'EnableWANCacheReaper' ) ) {
687  // Clean up any bad page entries for titles showing up in RC
689  $this->getDB(),
690  LoggerFactory::getInstance( 'objectcache' )
691  ) );
692  }
693 
694  $this->includeRcFiltersApp();
695  }
696 
704  protected function considerActionsForDefaultSavedQuery( $subpage ) {
705  if ( !$this->isStructuredFilterUiEnabled() || $this->including() ) {
706  return;
707  }
708 
709  $knownParams = $this->getRequest()->getValues(
710  ...array_keys( $this->getOptions()->getAllValues() )
711  );
712 
713  // HACK: Temporarily until we can properly define "sticky" filters and parameters,
714  // we need to exclude several parameters we know should not be counted towards preventing
715  // the loading of defaults.
716  $excludedParams = [ 'limit' => '', 'days' => '', 'enhanced' => '', 'from' => '' ];
717  $knownParams = array_diff_key( $knownParams, $excludedParams );
718 
719  if (
720  // If there are NO known parameters in the URL request
721  // (that are not excluded) then we need to check into loading
722  // the default saved query
723  count( $knownParams ) === 0
724  ) {
725  // Get the saved queries data and parse it
726  $savedQueries = FormatJson::decode(
727  $this->getUser()->getOption( static::$savedQueriesPreferenceName ),
728  true
729  );
730 
731  if ( $savedQueries && isset( $savedQueries[ 'default' ] ) ) {
732  // Only load queries that are 'version' 2, since those
733  // have parameter representation
734  if ( isset( $savedQueries[ 'version' ] ) && $savedQueries[ 'version' ] === '2' ) {
735  $savedQueryDefaultID = $savedQueries[ 'default' ];
736  $defaultQuery = $savedQueries[ 'queries' ][ $savedQueryDefaultID ][ 'data' ];
737 
738  // Build the entire parameter list
739  $query = array_merge(
740  $defaultQuery[ 'params' ],
741  $defaultQuery[ 'highlights' ],
742  [
743  'urlversion' => '2',
744  ]
745  );
746  // Add to the query any parameters that we may have ignored before
747  // but are still valid and requested in the URL
748  $query = array_merge( $this->getRequest()->getValues(), $query );
749  unset( $query[ 'title' ] );
750  $this->getOutput()->redirect( $this->getPageTitle( $subpage )->getCanonicalURL( $query ) );
751  } else {
752  // There's a default, but the version is not 2, and the server can't
753  // actually recognize the query itself. This happens if it is before
754  // the conversion, so we need to tell the UI to reload saved query as
755  // it does the conversion to version 2
756  $this->getOutput()->addJsConfigVars(
757  'wgStructuredChangeFiltersDefaultSavedQueryExists',
758  true
759  );
760 
761  // Add the class that tells the frontend it is still loading
762  // another query
763  $this->getOutput()->addBodyClasses( 'mw-rcfilters-ui-loading' );
764  }
765  }
766  }
767  }
768 
774  protected function getLinkDays() {
775  $linkDays = $this->getConfig()->get( 'RCLinkDays' );
776  $filterByAge = $this->getConfig()->get( 'RCFilterByAge' );
777  $maxAge = $this->getConfig()->get( 'RCMaxAge' );
778  if ( $filterByAge ) {
779  // Trim it to only links which are within $wgRCMaxAge.
780  // Note that we allow one link higher than the max for things like
781  // "age 56 days" being accessible through the "60 days" link.
782  sort( $linkDays );
783 
784  $maxAgeDays = $maxAge / ( 3600 * 24 );
785  foreach ( $linkDays as $i => $days ) {
786  if ( $days >= $maxAgeDays ) {
787  array_splice( $linkDays, $i + 1 );
788  break;
789  }
790  }
791  }
792 
793  return $linkDays;
794  }
795 
802  protected function includeRcFiltersApp() {
803  $out = $this->getOutput();
804  if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
805  $jsData = $this->getStructuredFilterJsData();
806  $messages = [];
807  foreach ( $jsData['messageKeys'] as $key ) {
808  $messages[$key] = $this->msg( $key )->plain();
809  }
810 
811  $out->addBodyClasses( 'mw-rcfilters-enabled' );
812  $collapsed = $this->getUser()->getBoolOption( static::$collapsedPreferenceName );
813  if ( $collapsed ) {
814  $out->addBodyClasses( 'mw-rcfilters-collapsed' );
815  }
816 
817  // These config and message exports should be moved into a ResourceLoader data module (T201574)
818  $out->addJsConfigVars( 'wgStructuredChangeFilters', $jsData['groups'] );
819  $out->addJsConfigVars( 'wgStructuredChangeFiltersMessages', $messages );
820  $out->addJsConfigVars( 'wgStructuredChangeFiltersCollapsedState', $collapsed );
821 
822  $out->addJsConfigVars(
823  'StructuredChangeFiltersDisplayConfig',
824  [
825  'maxDays' => (int)$this->getConfig()->get( 'RCMaxAge' ) / ( 24 * 3600 ), // Translate to days
826  'limitArray' => $this->getConfig()->get( 'RCLinkLimits' ),
827  'limitDefault' => $this->getDefaultLimit(),
828  'daysArray' => $this->getLinkDays(),
829  'daysDefault' => $this->getDefaultDays(),
830  ]
831  );
832 
833  $out->addJsConfigVars(
834  'wgStructuredChangeFiltersSavedQueriesPreferenceName',
835  static::$savedQueriesPreferenceName
836  );
837  $out->addJsConfigVars(
838  'wgStructuredChangeFiltersLimitPreferenceName',
839  static::$limitPreferenceName
840  );
841  $out->addJsConfigVars(
842  'wgStructuredChangeFiltersDaysPreferenceName',
843  static::$daysPreferenceName
844  );
845  $out->addJsConfigVars(
846  'wgStructuredChangeFiltersCollapsedPreferenceName',
847  static::$collapsedPreferenceName
848  );
849  } else {
850  $out->addBodyClasses( 'mw-rcfilters-disabled' );
851  }
852  }
853 
863  return [
864  // Reduce version computation by avoiding Message parsing
865  'RCFiltersChangeTags' => self::getChangeTagListSummary( $context ),
866  'StructuredChangeFiltersEditWatchlistUrl' =>
867  SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
868  ];
869  }
870 
879  return [
880  'RCFiltersChangeTags' => self::getChangeTagList( $context ),
881  'StructuredChangeFiltersEditWatchlistUrl' =>
882  SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
883  ];
884  }
885 
907  $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
908  return $cache->getWithSetCallback(
909  $cache->makeKey( 'ChangesListSpecialPage-changeTagListSummary', $context->getLanguage() ),
911  function ( $oldValue, &$ttl, array &$setOpts ) use ( $context ) {
912  $explicitlyDefinedTags = array_fill_keys( ChangeTags::listExplicitlyDefinedTags(), 0 );
913  $softwareActivatedTags = array_fill_keys( ChangeTags::listSoftwareActivatedTags(), 0 );
914 
915  $tagStats = ChangeTags::tagUsageStatistics();
916  $tagHitCounts = array_merge( $explicitlyDefinedTags, $softwareActivatedTags, $tagStats );
917 
918  $result = [];
919  foreach ( $tagHitCounts as $tagName => $hits ) {
920  if (
921  (
922  // Only get active tags
923  isset( $explicitlyDefinedTags[ $tagName ] ) ||
924  isset( $softwareActivatedTags[ $tagName ] )
925  ) &&
926  // Only get tags with more than 0 hits
927  $hits > 0
928  ) {
929  $labelMsg = ChangeTags::tagShortDescriptionMessage( $tagName, $context );
930  if ( $labelMsg === false ) {
931  // Tag is hidden, skip it
932  continue;
933  }
934  $descriptionMsg = ChangeTags::tagLongDescriptionMessage( $tagName, $context );
935  $result[] = [
936  'name' => $tagName,
937  'labelMsg' => $labelMsg,
938  'label' => $labelMsg->plain(),
939  'descriptionMsg' => $descriptionMsg,
940  'description' => $descriptionMsg ? $descriptionMsg->plain() : '',
941  'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ),
942  'hits' => $hits,
943  ];
944  }
945  }
946  return $result;
947  }
948  );
949  }
950 
964  protected static function getChangeTagList( ResourceLoaderContext $context ) {
965  $tags = self::getChangeTagListSummary( $context );
966  $language = Language::factory( $context->getLanguage() );
967  foreach ( $tags as &$tagInfo ) {
968  $tagInfo['label'] = Sanitizer::stripAllTags( $tagInfo['labelMsg']->parse() );
969  $tagInfo['description'] = $tagInfo['descriptionMsg'] ?
970  $language->truncateForVisual(
971  Sanitizer::stripAllTags( $tagInfo['descriptionMsg']->parse() ),
972  self::TAG_DESC_CHARACTER_LIMIT
973  ) :
974  '';
975  unset( $tagInfo['labelMsg'] );
976  unset( $tagInfo['descriptionMsg'] );
977  }
978 
979  // Instead of sorting by hit count (disabled for now), sort by display name
980  usort( $tags, function ( $a, $b ) {
981  return strcasecmp( $a['label'], $b['label'] );
982  } );
983  return $tags;
984  }
985 
989  protected function outputNoResults() {
990  $this->getOutput()->addHTML(
991  '<div class="mw-changeslist-empty">' .
992  $this->msg( 'recentchanges-noresult' )->parse() .
993  '</div>'
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  Hooks::run( 'ChangesListSpecialPageStructuredFilters', [ $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 
1240  public function registerFilterGroup( ChangesListFilterGroup $group ) {
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, function ( $a, $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  function ( $carry, $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 
1425  private function fixBackwardsCompatibilityOptions( FormOptions $opts ) {
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  if ( $opts[ 'associated' ] ) {
1534  $namespaceInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
1535  $associatedNamespaces = array_map(
1536  function ( $ns ) use ( $namespaceInfo ){
1537  return $namespaceInfo->getAssociated( $ns );
1538  },
1539  array_filter(
1540  $namespaces,
1541  function ( $ns ) use ( $namespaceInfo ) {
1542  return $namespaceInfo->hasTalkNamespace( $ns );
1543  }
1544  )
1545  );
1546  $namespaces = array_unique( array_merge( $namespaces, $associatedNamespaces ) );
1547  }
1548 
1549  if ( count( $namespaces ) === 1 ) {
1550  $operator = $opts[ 'invert' ] ? '!=' : '=';
1551  $value = $dbr->addQuotes( reset( $namespaces ) );
1552  } else {
1553  $operator = $opts[ 'invert' ] ? 'NOT IN' : 'IN';
1554  sort( $namespaces );
1555  $value = '(' . $dbr->makeList( $namespaces ) . ')';
1556  }
1557  $conds[] = "rc_namespace $operator $value";
1558  }
1559 
1560  // Calculate cutoff
1561  $cutoff_unixtime = time() - $opts['days'] * 3600 * 24;
1562  $cutoff = $dbr->timestamp( $cutoff_unixtime );
1563 
1564  $fromValid = preg_match( '/^[0-9]{14}$/', $opts['from'] );
1565  if ( $fromValid && $opts['from'] > wfTimestamp( TS_MW, $cutoff ) ) {
1566  $cutoff = $dbr->timestamp( $opts['from'] );
1567  } else {
1568  $opts->reset( 'from' );
1569  }
1570 
1571  $conds[] = 'rc_timestamp >= ' . $dbr->addQuotes( $cutoff );
1572  }
1573 
1585  protected function doMainQuery( $tables, $fields, $conds,
1586  $query_options, $join_conds, FormOptions $opts
1587  ) {
1588  $rcQuery = RecentChange::getQueryInfo();
1589  $tables = array_merge( $tables, $rcQuery['tables'] );
1590  $fields = array_merge( $rcQuery['fields'], $fields );
1591  $join_conds = array_merge( $join_conds, $rcQuery['joins'] );
1592 
1594  $tables,
1595  $fields,
1596  $conds,
1597  $join_conds,
1598  $query_options,
1599  ''
1600  );
1601 
1602  if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
1603  $opts )
1604  ) {
1605  return false;
1606  }
1607 
1608  $dbr = $this->getDB();
1609 
1610  return $dbr->select(
1611  $tables,
1612  $fields,
1613  $conds,
1614  __METHOD__,
1615  $query_options,
1616  $join_conds
1617  );
1618  }
1619 
1620  protected function runMainQueryHook( &$tables, &$fields, &$conds,
1621  &$query_options, &$join_conds, $opts
1622  ) {
1623  return Hooks::run(
1624  'ChangesListSpecialPageQuery',
1625  [ $this->getName(), &$tables, &$fields, &$conds, &$query_options, &$join_conds, $opts ]
1626  );
1627  }
1628 
1634  protected function getDB() {
1635  return wfGetDB( DB_REPLICA );
1636  }
1637 
1644  private function webOutputHeader( $rowCount, $opts ) {
1645  if ( !$this->including() ) {
1646  $this->outputFeedLinks();
1647  $this->doHeader( $opts, $rowCount );
1648  }
1649  }
1650 
1657  public function webOutput( $rows, $opts ) {
1658  $this->webOutputHeader( $rows->numRows(), $opts );
1659 
1660  $this->outputChangesList( $rows, $opts );
1661  }
1662 
1666  public function outputFeedLinks() {
1667  // nothing by default
1668  }
1669 
1676  abstract public function outputChangesList( $rows, $opts );
1677 
1684  public function doHeader( $opts, $numRows ) {
1685  $this->setTopText( $opts );
1686 
1687  // @todo Lots of stuff should be done here.
1688 
1689  $this->setBottomText( $opts );
1690  }
1691 
1699  public function setTopText( FormOptions $opts ) {
1700  // nothing by default
1701  }
1702 
1710  public function setBottomText( FormOptions $opts ) {
1711  // nothing by default
1712  }
1713 
1723  public function getExtraOptions( $opts ) {
1724  return [];
1725  }
1726 
1732  public function makeLegend() {
1733  $context = $this->getContext();
1734  $user = $context->getUser();
1735  # The legend showing what the letters and stuff mean
1736  $legend = Html::openElement( 'dl' ) . "\n";
1737  # Iterates through them and gets the messages for both letter and tooltip
1738  $legendItems = $context->getConfig()->get( 'RecentChangesFlags' );
1739  if ( !( $user->useRCPatrol() || $user->useNPPatrol() ) ) {
1740  unset( $legendItems['unpatrolled'] );
1741  }
1742  foreach ( $legendItems as $key => $item ) { # generate items of the legend
1743  $label = $item['legend'] ?? $item['title'];
1744  $letter = $item['letter'];
1745  $cssClass = $item['class'] ?? $key;
1746 
1747  $legend .= Html::element( 'dt',
1748  [ 'class' => $cssClass ], $context->msg( $letter )->text()
1749  ) . "\n" .
1750  Html::rawElement( 'dd',
1751  [ 'class' => Sanitizer::escapeClass( 'mw-changeslist-legend-' . $key ) ],
1752  $context->msg( $label )->parse()
1753  ) . "\n";
1754  }
1755  # (+-123)
1756  $legend .= Html::rawElement( 'dt',
1757  [ 'class' => 'mw-plusminus-pos' ],
1758  $context->msg( 'recentchanges-legend-plusminus' )->parse()
1759  ) . "\n";
1760  $legend .= Html::element(
1761  'dd',
1762  [ 'class' => 'mw-changeslist-legend-plusminus' ],
1763  $context->msg( 'recentchanges-label-plusminus' )->text()
1764  ) . "\n";
1765  $legend .= Html::closeElement( 'dl' ) . "\n";
1766 
1767  $legendHeading = $this->isStructuredFilterUiEnabled() ?
1768  $context->msg( 'rcfilters-legend-heading' )->parse() :
1769  $context->msg( 'recentchanges-legend-heading' )->parse();
1770 
1771  # Collapsible
1772  $collapsedState = $this->getRequest()->getCookie( 'changeslist-state' );
1773  $collapsedClass = $collapsedState === 'collapsed' ? ' mw-collapsed' : '';
1774 
1775  $legend =
1776  '<div class="mw-changeslist-legend mw-collapsible' . $collapsedClass . '">' .
1777  $legendHeading .
1778  '<div class="mw-collapsible-content">' . $legend . '</div>' .
1779  '</div>';
1780 
1781  return $legend;
1782  }
1783 
1787  protected function addModules() {
1788  $out = $this->getOutput();
1789  // Styles and behavior for the legend box (see makeLegend())
1790  $out->addModuleStyles( [
1791  'mediawiki.interface.helpers.styles',
1792  'mediawiki.special.changeslist.legend',
1793  'mediawiki.special.changeslist',
1794  ] );
1795  $out->addModules( 'mediawiki.special.changeslist.legend.js' );
1796 
1797  if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
1798  $out->addModules( 'mediawiki.rcfilters.filters.ui' );
1799  $out->addModuleStyles( 'mediawiki.rcfilters.filters.base.styles' );
1800  }
1801  }
1802 
1803  protected function getGroupName() {
1804  return 'changes';
1805  }
1806 
1823  public function filterOnUserExperienceLevel( $specialPageClassName, $context, $dbr,
1824  &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedExpLevels, $now = 0
1825  ) {
1826  global $wgLearnerEdits,
1830 
1831  $LEVEL_COUNT = 5;
1832 
1833  // If all levels are selected, don't filter
1834  if ( count( $selectedExpLevels ) === $LEVEL_COUNT ) {
1835  return;
1836  }
1837 
1838  // both 'registered' and 'unregistered', experience levels, if any, are included in 'registered'
1839  if (
1840  in_array( 'registered', $selectedExpLevels ) &&
1841  in_array( 'unregistered', $selectedExpLevels )
1842  ) {
1843  return;
1844  }
1845 
1846  $actorMigration = ActorMigration::newMigration();
1847  $actorQuery = $actorMigration->getJoin( 'rc_user' );
1848  $tables += $actorQuery['tables'];
1849  $join_conds += $actorQuery['joins'];
1850 
1851  // 'registered' but not 'unregistered', experience levels, if any, are included in 'registered'
1852  if (
1853  in_array( 'registered', $selectedExpLevels ) &&
1854  !in_array( 'unregistered', $selectedExpLevels )
1855  ) {
1856  $conds[] = $actorMigration->isNotAnon( $actorQuery['fields']['rc_user'] );
1857  return;
1858  }
1859 
1860  if ( $selectedExpLevels === [ 'unregistered' ] ) {
1861  $conds[] = $actorMigration->isAnon( $actorQuery['fields']['rc_user'] );
1862  return;
1863  }
1864 
1865  $tables[] = 'user';
1866  $join_conds['user'] = [ 'LEFT JOIN', $actorQuery['fields']['rc_user'] . ' = user_id' ];
1867 
1868  if ( $now === 0 ) {
1869  $now = time();
1870  }
1871  $secondsPerDay = 86400;
1872  $learnerCutoff = $now - $wgLearnerMemberSince * $secondsPerDay;
1873  $experiencedUserCutoff = $now - $wgExperiencedUserMemberSince * $secondsPerDay;
1874 
1875  $aboveNewcomer = $dbr->makeList(
1876  [
1877  'user_editcount >= ' . intval( $wgLearnerEdits ),
1878  'user_registration <= ' . $dbr->addQuotes( $dbr->timestamp( $learnerCutoff ) ),
1879  ],
1881  );
1882 
1883  $aboveLearner = $dbr->makeList(
1884  [
1885  'user_editcount >= ' . intval( $wgExperiencedUserEdits ),
1886  'user_registration <= ' .
1887  $dbr->addQuotes( $dbr->timestamp( $experiencedUserCutoff ) ),
1888  ],
1890  );
1891 
1892  $conditions = [];
1893 
1894  if ( in_array( 'unregistered', $selectedExpLevels ) ) {
1895  $selectedExpLevels = array_diff( $selectedExpLevels, [ 'unregistered' ] );
1896  $conditions[] = $actorMigration->isAnon( $actorQuery['fields']['rc_user'] );
1897  }
1898 
1899  if ( $selectedExpLevels === [ 'newcomer' ] ) {
1900  $conditions[] = "NOT ( $aboveNewcomer )";
1901  } elseif ( $selectedExpLevels === [ 'learner' ] ) {
1902  $conditions[] = $dbr->makeList(
1903  [ $aboveNewcomer, "NOT ( $aboveLearner )" ],
1905  );
1906  } elseif ( $selectedExpLevels === [ 'experienced' ] ) {
1907  $conditions[] = $aboveLearner;
1908  } elseif ( $selectedExpLevels === [ 'learner', 'newcomer' ] ) {
1909  $conditions[] = "NOT ( $aboveLearner )";
1910  } elseif ( $selectedExpLevels === [ 'experienced', 'newcomer' ] ) {
1911  $conditions[] = $dbr->makeList(
1912  [ "NOT ( $aboveNewcomer )", $aboveLearner ],
1914  );
1915  } elseif ( $selectedExpLevels === [ 'experienced', 'learner' ] ) {
1916  $conditions[] = $aboveNewcomer;
1917  } elseif ( $selectedExpLevels === [ 'experienced', 'learner', 'newcomer' ] ) {
1918  $conditions[] = $actorMigration->isNotAnon( $actorQuery['fields']['rc_user'] );
1919  }
1920 
1921  if ( count( $conditions ) > 1 ) {
1922  $conds[] = $dbr->makeList( $conditions, IDatabase::LIST_OR );
1923  } elseif ( count( $conditions ) === 1 ) {
1924  $conds[] = reset( $conditions );
1925  }
1926  }
1927 
1933  public function isStructuredFilterUiEnabled() {
1934  if ( $this->getRequest()->getBool( 'rcfilters' ) ) {
1935  return true;
1936  }
1937 
1938  return static::checkStructuredFilterUiEnabled( $this->getUser() );
1939  }
1940 
1948  public static function checkStructuredFilterUiEnabled( $user ) {
1949  if ( $user instanceof Config ) {
1950  wfDeprecated( __METHOD__ . ' with Config argument', '1.34' );
1951  $user = func_get_arg( 1 );
1952  }
1953  return !$user->getOption( 'rcenhancedfilters-disable' );
1954  }
1955 
1963  public function getDefaultLimit() {
1964  return $this->getUser()->getIntOption( static::$limitPreferenceName );
1965  }
1966 
1975  public function getDefaultDays() {
1976  return floatval( $this->getUser()->getOption( static::$daysPreferenceName ) );
1977  }
1978 
1979  private function expandSymbolicNamespaceFilters( array $namespaces ) {
1980  $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
1981  $symbolicFilters = [
1982  'all-contents' => $nsInfo->getSubjectNamespaces(),
1983  'all-discussions' => $nsInfo->getTalkNamespaces(),
1984  ];
1985  $additionalNamespaces = [];
1986  foreach ( $symbolicFilters as $name => $values ) {
1987  if ( in_array( $name, $namespaces ) ) {
1988  $additionalNamespaces = array_merge( $additionalNamespaces, $values );
1989  }
1990  }
1991  $namespaces = array_diff( $namespaces, array_keys( $symbolicFilters ) );
1992  $namespaces = array_merge( $namespaces, $additionalNamespaces );
1993  return array_unique( $namespaces );
1994  }
1995 }
static modifyDisplayQuery(&$tables, &$fields, &$conds, &$join_conds, &$options, $filter_tag='')
Applies all tags-related changes to a query.
Definition: ChangeTags.php:772
getFilterGroup( $groupName)
Gets a specified ChangesListFilterGroup by name.
Helper class to keep track of options when mixing links and form elements.
Definition: FormOptions.php:35
const RC_CATEGORIZE
Definition: Defines.php:126
add( $ns, $dbkey)
Definition: LinkBatch.php:83
const FLOAT
Float type, maps guessType() to WebRequest::getFloat()
Definition: FormOptions.php:49
doHeader( $opts, $numRows)
Set the text to be displayed above the changes.
const SRC_CATEGORIZE
static element( $element, $attribs=[], $contents='')
Identical to rawElement(), but HTML-escapes $contents (like Xml::element()).
Definition: Html.php:231
static tagShortDescriptionMessage( $tag, MessageLocalizer $context)
Get the message object for the tag&#39;s short description.
Definition: ChangeTags.php:149
$context
Definition: load.php:45
getContext()
Gets the context this SpecialPage is executed in.
webOutput( $rows, $opts)
Send output to the OutputPage object, only called if not used feeds.
areFiltersInConflict()
Check if filters are in conflict and guaranteed to return no results.
const TAG_DESC_CHARACTER_LIMIT
Maximum length of a tag description in UTF-8 characters.
including( $x=null)
Whether the special page is being evaluated via transclusion.
outputChangesList( $rows, $opts)
Build and output the actual changes list.
replaceOldOptions(FormOptions $opts)
Replace old options with their structured UI equivalents.
$filterGroupDefinitions
Definition information for the filters and their groups.
static openElement( $element, $attribs=[])
Identical to rawElement(), but has no third parameter and omits the end tag (and the self-closing &#39;/&#39;...
Definition: Html.php:251
static rawElement( $element, $attribs=[], $contents='')
Returns an HTML element in a string.
Definition: Html.php:209
wfGetDB( $db, $groups=[], $wiki=false)
Get a Database object.
outputFeedLinks()
Output feed links.
getDefaultLimit()
Get the default value of the number of changes to display when loading the result set...
static string $daysPreferenceName
Preference name for &#39;days&#39;.
reset( $name)
Delete the option value.
static string $limitPreferenceName
Preference name for &#39;limit&#39;.
registerFiltersFromDefinitions(array $definition)
Register filters from a definition object.
wfLogWarning( $msg, $callerOffset=1, $level=E_USER_WARNING)
Send a warning as a PHP error and the debug log.
static listExplicitlyDefinedTags()
Lists tags explicitly defined in the change_tag_def table of the database.
getOutput()
Get the OutputPage being used for this instance.
static getRcFiltersConfigSummary(ResourceLoaderContext $context)
Get essential data about getRcFiltersConfigVars() for change detection.
setTopText(FormOptions $opts)
Send the text to be displayed before the options.
static string $savedQueriesPreferenceName
Preference name for saved queries.
getOptions()
Get the current FormOptions for this request.
validateIntBounds( $name, $min, $max)
wfArrayToCgi( $array1, $array2=null, $prefix='')
This function takes one or two arrays as input, and returns a CGI-style string, e.g.
Class representing a list of titles The execute() method checks them all for existence and adds them ...
Definition: LinkBatch.php:34
static tagLongDescriptionMessage( $tag, MessageLocalizer $context)
Get the message object for the tag&#39;s long description.
Definition: ChangeTags.php:200
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
const PRC_PATROLLED
msg( $key,... $params)
Wrapper around wfMessage that sets the current context.
Class for fixing stale WANObjectCache keys using a purge event source.
static stripAllTags( $html)
Take a fragment of (potentially invalid) HTML and return a version with any tags removed, encoded as plain text.
Definition: Sanitizer.php:2041
__construct( $name, $restriction)
transformFilterDefinition(array $filterDefinition)
Transforms filter definition to prepare it for constructor.
static newMigration()
Static constructor.
Interface for configuration instances.
Definition: Config.php:28
const LIST_AND
Definition: Defines.php:39
parseParameters( $par, FormOptions $opts)
Process $par and put options found in $opts.
$wgExperiencedUserEdits
Name of the external diff engine to use.
fixBackwardsCompatibilityOptions(FormOptions $opts)
Fix a special case (hideanons=1 and hideliu=1) in a special way, for backwards compatibility.
includeRcFiltersApp()
Include the modules and configuration for the RCFilters app.
Represents a filter group (used on ChangesListSpecialPage and descendants)
validateBounds( $name, $min, $max)
Constrain a numeric value for a given option to a given range.
static decode( $value, $assoc=false)
Decodes a JSON string.
Definition: FormatJson.php:174
static listSoftwareActivatedTags()
Lists those tags which core or extensions report as being "active".
getDefaultOptions()
Get a FormOptions object containing the default options.
webOutputHeader( $rowCount, $opts)
Send header output to the OutputPage object, only called if not using feeds.
$cache
Definition: mcc.php:33
static logException( $e, $catcher=self::CAUGHT_BY_OTHER, $extraData=[])
Log an exception to the exception log (if enabled).
static closeElement( $element)
Returns "</$element>".
Definition: Html.php:315
getDB()
Return a IDatabase object for reading.
doMainQuery( $tables, $fields, $conds, $query_options, $join_conds, FormOptions $opts)
Process the query.
array $wgLearnerEdits
The following variables define 3 user experience levels:
setHeaders()
Sets headers - this should be called from the execute() method of all derived classes! ...
static escapeClass( $class)
Given a value, escape it so that it can be used as a CSS class and return it.
Definition: Sanitizer.php:1418
static string $collapsedPreferenceName
Preference name for collapsing the active filter display.
$filter
static factory( $code)
Get a cached or new language object for a given language code.
Definition: Language.php:212
setBottomText(FormOptions $opts)
Send the text to be displayed after the options.
$wgExperiencedUserMemberSince
Name of the external diff engine to use.
static newFromRow( $row)
Handy shortcut for constructing a formatter directly from database row.
static getTitleFor( $name, $subpage=false, $fragment='')
Get a localised Title object for a specified special page name If you don&#39;t need a full Title object...
Definition: SpecialPage.php:83
filterOnUserExperienceLevel( $specialPageClassName, $context, $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedExpLevels, $now=0)
Filter on users&#39; experience levels; this will not be called if nothing is selected.
Represents a filter group with multiple string options.
static getChangeTagListSummary(ResourceLoaderContext $context)
Get information about change tags, without parsing messages, for getRcFiltersConfigSummary().
$wgLearnerMemberSince
Name of the external diff engine to use.
const INT
Integer type, maps guessType() to WebRequest::getInt()
Definition: FormOptions.php:45
const LIST_OR
Definition: Defines.php:42
const PRC_UNPATROLLED
validateOptions(FormOptions $opts)
Validate a FormOptions object generated by getDefaultOptions() with values already populated...
registerFilterGroup(ChangesListFilterGroup $group)
Register a structured changes list filter group.
outputNoResults()
Add the "no results" message to the output.
static checkStructuredFilterUiEnabled( $user)
Static method to check whether StructuredFilter UI is enabled for the given user. ...
Represents a hide-based boolean filter (used on ChangesListSpecialPage and descendants) ...
static getRcFiltersConfigVars(ResourceLoaderContext $context)
Get config vars to export with the mediawiki.rcfilters.filters.ui module.
static addUpdate(DeferrableUpdate $update, $stage=self::POSTSEND)
Add an update to the deferred list to be run later by execute()
getRows()
Get the database result for this special page instance.
convertParamsForLink( $params)
Convert parameters values from true/false to 1/0 so they are not omitted by wfArrayToCgi() T38524...
isStructuredFilterUiEnabled()
Check whether the structured filter UI is enabled.
getName()
Get the name of this Special Page.
const NONE
Signifies that no options in the group are selected, meaning the group has no effect.
Special page which uses a ChangesList to show query results.
getStructuredFilterJsData()
Gets structured filter information needed by JS.
Overloads the relevant methods of the real ResultsWrapper so it doesn&#39;t go anywhere near an actual da...
getUser()
Shortcut to get the User executing this instance.
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Throws a warning that $function is deprecated.
const STRING
String type, maps guessType() to WebRequest::getText()
Definition: FormOptions.php:43
getConfig()
Shortcut to get main config object.
runMainQueryHook(&$tables, &$fields, &$conds, &$query_options, &$join_conds, $opts)
getExtraOptions( $opts)
Get options to be displayed in a form.
buildQuery(&$tables, &$fields, &$conds, &$query_options, &$join_conds, FormOptions $opts)
Sets appropriate tables, fields, conditions, etc.
considerActionsForDefaultSavedQuery( $subpage)
Check whether or not the page should load defaults, and if so, whether a default saved query is relev...
Error thrown when a query times out.
expandSymbolicNamespaceFilters(array $namespaces)
static getQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new recentchanges object...
const RC_NEW
Definition: Defines.php:123
getChangedValues()
Return options modified as an array ( name => value )
const DB_REPLICA
Definition: defines.php:25
getFilterGroups()
Gets the currently registered filters groups.
getRequest()
Get the WebRequest being used for this instance.
registerFilters()
Register all filters and their groups (including those from hooks), plus handle conflicts and default...
fixContradictoryOptions(FormOptions $opts)
Fix invalid options by resetting pairs that should never appear together.
makeLegend()
Return the legend displayed within the fieldset.
getDefaultDays()
Get the default value of the number of days to display when loading the result set.
If the group is active, any unchecked filters will translate to hide parameters in the URL...
const PRC_AUTOPATROLLED
const NS_USER_TALK
Definition: Defines.php:63
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().
outputTimeout()
Add the "timeout" message to the output.
outputHeader( $summaryMessageKey='')
Outputs a summary message on top of special pages Per default the message key is the canonical name o...
getPageTitle( $subpage=false)
Get a self-referential title object.
const RC_EDIT
Definition: Defines.php:122
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:200
addModules()
Add page-specific modules.
const RC_LOG
Definition: Defines.php:124
fetchOptionsFromRequest( $opts)
Fetch values for a FormOptions object from the WebRequest associated with this instance.
Context object that contains information about the state of a specific ResourceLoader web request...
static tagUsageStatistics()
Returns a map of any tags used on the wiki to number of edits tagged with them, ordered descending by...