MediaWiki  master
ChangesListSpecialPage.php
Go to the documentation of this file.
1 <?php
28 use OOUI\IconWidget;
33 
40 abstract class ChangesListSpecialPage extends SpecialPage {
41 
43  protected $rcSubpage;
44 
46  protected $rcOptions;
47 
48  // Order of both groups and filters is significant; first is top-most priority,
49  // descending from there.
50  // 'showHideSuffix' is a shortcut to and avoid spelling out
51  // details specific to subclasses here.
65 
71 
74 
77 
84  protected $filterGroups = [];
85 
86  public function __construct( $name, $restriction ) {
87  parent::__construct( $name, $restriction );
88 
89  $nonRevisionTypes = [ RC_LOG ];
90  $this->getHookRunner()->onSpecialWatchlistGetNonRevisionTypes( $nonRevisionTypes );
91 
92  $this->filterGroupDefinitions = [
93  [
94  'name' => 'registration',
95  'title' => 'rcfilters-filtergroup-registration',
96  'class' => ChangesListBooleanFilterGroup::class,
97  'filters' => [
98  [
99  'name' => 'hideliu',
100  // rcshowhideliu-show, rcshowhideliu-hide,
101  // wlshowhideliu
102  'showHideSuffix' => 'showhideliu',
103  'default' => false,
104  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
105  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
106  ) {
107  $conds['actor_user'] = null;
108  },
109  'isReplacedInStructuredUi' => true,
110 
111  ],
112  [
113  'name' => 'hideanons',
114  // rcshowhideanons-show, rcshowhideanons-hide,
115  // wlshowhideanons
116  'showHideSuffix' => 'showhideanons',
117  'default' => false,
118  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
119  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
120  ) {
121  $conds[] = 'actor_user IS NOT NULL';
122  },
123  'isReplacedInStructuredUi' => true,
124  ]
125  ],
126  ],
127 
128  [
129  'name' => 'userExpLevel',
130  'title' => 'rcfilters-filtergroup-user-experience-level',
131  'class' => ChangesListStringOptionsFilterGroup::class,
132  'isFullCoverage' => true,
133  'filters' => [
134  [
135  'name' => 'unregistered',
136  'label' => 'rcfilters-filter-user-experience-level-unregistered-label',
137  'description' => 'rcfilters-filter-user-experience-level-unregistered-description',
138  'cssClassSuffix' => 'user-unregistered',
139  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
140  return !$rc->getAttribute( 'rc_user' );
141  }
142  ],
143  [
144  'name' => 'registered',
145  'label' => 'rcfilters-filter-user-experience-level-registered-label',
146  'description' => 'rcfilters-filter-user-experience-level-registered-description',
147  'cssClassSuffix' => 'user-registered',
148  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
149  return $rc->getAttribute( 'rc_user' );
150  }
151  ],
152  [
153  'name' => 'newcomer',
154  'label' => 'rcfilters-filter-user-experience-level-newcomer-label',
155  'description' => 'rcfilters-filter-user-experience-level-newcomer-description',
156  'cssClassSuffix' => 'user-newcomer',
157  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
158  $performer = $rc->getPerformerIdentity();
159  return $performer->isRegistered() &&
160  MediaWikiServices::getInstance()
161  ->getUserFactory()
162  ->newFromUserIdentity( $performer )
163  ->getExperienceLevel() === 'newcomer';
164  }
165  ],
166  [
167  'name' => 'learner',
168  'label' => 'rcfilters-filter-user-experience-level-learner-label',
169  'description' => 'rcfilters-filter-user-experience-level-learner-description',
170  'cssClassSuffix' => 'user-learner',
171  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
172  $performer = $rc->getPerformerIdentity();
173  return $performer->isRegistered() &&
174  MediaWikiServices::getInstance()
175  ->getUserFactory()
176  ->newFromUserIdentity( $performer )
177  ->getExperienceLevel() === 'learner';
178  },
179  ],
180  [
181  'name' => 'experienced',
182  'label' => 'rcfilters-filter-user-experience-level-experienced-label',
183  'description' => 'rcfilters-filter-user-experience-level-experienced-description',
184  'cssClassSuffix' => 'user-experienced',
185  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
186  $performer = $rc->getPerformerIdentity();
187  return $performer->isRegistered() &&
188  MediaWikiServices::getInstance()
189  ->getUserFactory()
190  ->newFromUserIdentity( $performer )
191  ->getExperienceLevel() === 'experienced';
192  },
193  ]
194  ],
196  'queryCallable' => [ $this, 'filterOnUserExperienceLevel' ],
197  ],
198 
199  [
200  'name' => 'authorship',
201  'title' => 'rcfilters-filtergroup-authorship',
202  'class' => ChangesListBooleanFilterGroup::class,
203  'filters' => [
204  [
205  'name' => 'hidemyself',
206  'label' => 'rcfilters-filter-editsbyself-label',
207  'description' => 'rcfilters-filter-editsbyself-description',
208  // rcshowhidemine-show, rcshowhidemine-hide,
209  // wlshowhidemine
210  'showHideSuffix' => 'showhidemine',
211  'default' => false,
212  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
213  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
214  ) {
215  $user = $ctx->getUser();
216  $conds[] = 'actor_name<>' . $dbr->addQuotes( $user->getName() );
217  },
218  'cssClassSuffix' => 'self',
219  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
220  return $ctx->getUser()->equals( $rc->getPerformerIdentity() );
221  },
222  ],
223  [
224  'name' => 'hidebyothers',
225  'label' => 'rcfilters-filter-editsbyother-label',
226  'description' => 'rcfilters-filter-editsbyother-description',
227  'default' => false,
228  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
229  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
230  ) {
231  $user = $ctx->getUser();
232  if ( $user->isAnon() ) {
233  $conds['actor_name'] = $user->getName();
234  } else {
235  $conds['actor_user'] = $user->getId();
236  }
237  },
238  'cssClassSuffix' => 'others',
239  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
240  return !$ctx->getUser()->equals( $rc->getPerformerIdentity() );
241  },
242  ]
243  ]
244  ],
245 
246  [
247  'name' => 'automated',
248  'title' => 'rcfilters-filtergroup-automated',
249  'class' => ChangesListBooleanFilterGroup::class,
250  'filters' => [
251  [
252  'name' => 'hidebots',
253  'label' => 'rcfilters-filter-bots-label',
254  'description' => 'rcfilters-filter-bots-description',
255  // rcshowhidebots-show, rcshowhidebots-hide,
256  // wlshowhidebots
257  'showHideSuffix' => 'showhidebots',
258  'default' => false,
259  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
260  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
261  ) {
262  $conds['rc_bot'] = 0;
263  },
264  'cssClassSuffix' => 'bot',
265  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
266  return $rc->getAttribute( 'rc_bot' );
267  },
268  ],
269  [
270  'name' => 'hidehumans',
271  'label' => 'rcfilters-filter-humans-label',
272  'description' => 'rcfilters-filter-humans-description',
273  'default' => false,
274  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
275  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
276  ) {
277  $conds['rc_bot'] = 1;
278  },
279  'cssClassSuffix' => 'human',
280  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
281  return !$rc->getAttribute( 'rc_bot' );
282  },
283  ]
284  ]
285  ],
286 
287  // significance (conditional)
288 
289  [
290  'name' => 'significance',
291  'title' => 'rcfilters-filtergroup-significance',
292  'class' => ChangesListBooleanFilterGroup::class,
293  'priority' => -6,
294  'filters' => [
295  [
296  'name' => 'hideminor',
297  'label' => 'rcfilters-filter-minor-label',
298  'description' => 'rcfilters-filter-minor-description',
299  // rcshowhideminor-show, rcshowhideminor-hide,
300  // wlshowhideminor
301  'showHideSuffix' => 'showhideminor',
302  'default' => false,
303  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
304  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
305  ) {
306  $conds[] = 'rc_minor = 0';
307  },
308  'cssClassSuffix' => 'minor',
309  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
310  return $rc->getAttribute( 'rc_minor' );
311  }
312  ],
313  [
314  'name' => 'hidemajor',
315  'label' => 'rcfilters-filter-major-label',
316  'description' => 'rcfilters-filter-major-description',
317  'default' => false,
318  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
319  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
320  ) {
321  $conds[] = 'rc_minor = 1';
322  },
323  'cssClassSuffix' => 'major',
324  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
325  return !$rc->getAttribute( 'rc_minor' );
326  }
327  ]
328  ]
329  ],
330 
331  [
332  'name' => 'lastRevision',
333  'title' => 'rcfilters-filtergroup-lastrevision',
334  'class' => ChangesListBooleanFilterGroup::class,
335  'priority' => -7,
336  'filters' => [
337  [
338  'name' => 'hidelastrevision',
339  'label' => 'rcfilters-filter-lastrevision-label',
340  'description' => 'rcfilters-filter-lastrevision-description',
341  'default' => false,
342  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
343  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
344  ) use ( $nonRevisionTypes ) {
345  $conds[] = $dbr->makeList(
346  [
347  'rc_this_oldid <> page_latest',
348  'rc_type' => $nonRevisionTypes,
349  ],
350  LIST_OR
351  );
352  },
353  'cssClassSuffix' => 'last',
354  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
355  return $rc->getAttribute( 'rc_this_oldid' ) === $rc->getAttribute( 'page_latest' );
356  }
357  ],
358  [
359  'name' => 'hidepreviousrevisions',
360  'label' => 'rcfilters-filter-previousrevision-label',
361  'description' => 'rcfilters-filter-previousrevision-description',
362  'default' => false,
363  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
364  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
365  ) use ( $nonRevisionTypes ) {
366  $conds[] = $dbr->makeList(
367  [
368  'rc_this_oldid = page_latest',
369  'rc_type' => $nonRevisionTypes,
370  ],
371  LIST_OR
372  );
373  },
374  'cssClassSuffix' => 'previous',
375  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
376  return $rc->getAttribute( 'rc_this_oldid' ) !== $rc->getAttribute( 'page_latest' );
377  }
378  ]
379  ]
380  ],
381 
382  // With extensions, there can be change types that will not be hidden by any of these.
383  [
384  'name' => 'changeType',
385  'title' => 'rcfilters-filtergroup-changetype',
386  'class' => ChangesListBooleanFilterGroup::class,
387  'priority' => -8,
388  'filters' => [
389  [
390  'name' => 'hidepageedits',
391  'label' => 'rcfilters-filter-pageedits-label',
392  'description' => 'rcfilters-filter-pageedits-description',
393  'default' => false,
394  'priority' => -2,
395  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
396  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
397  ) {
398  $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_EDIT );
399  },
400  'cssClassSuffix' => 'src-mw-edit',
401  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
402  return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_EDIT;
403  },
404  ],
405  [
406  'name' => 'hidenewpages',
407  'label' => 'rcfilters-filter-newpages-label',
408  'description' => 'rcfilters-filter-newpages-description',
409  'default' => false,
410  'priority' => -3,
411  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
412  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
413  ) {
414  $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_NEW );
415  },
416  'cssClassSuffix' => 'src-mw-new',
417  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
418  return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_NEW;
419  },
420  ],
421 
422  // hidecategorization
423 
424  [
425  'name' => 'hidelog',
426  'label' => 'rcfilters-filter-logactions-label',
427  'description' => 'rcfilters-filter-logactions-description',
428  'default' => false,
429  'priority' => -5,
430  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
431  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
432  ) {
433  $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_LOG );
434  },
435  'cssClassSuffix' => 'src-mw-log',
436  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
437  return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_LOG;
438  }
439  ],
440  ],
441  ],
442 
443  ];
444 
445  $this->legacyReviewStatusFilterGroupDefinition = [
446  [
447  'name' => 'legacyReviewStatus',
448  'title' => 'rcfilters-filtergroup-reviewstatus',
449  'class' => ChangesListBooleanFilterGroup::class,
450  'filters' => [
451  [
452  'name' => 'hidepatrolled',
453  // rcshowhidepatr-show, rcshowhidepatr-hide
454  // wlshowhidepatr
455  'showHideSuffix' => 'showhidepatr',
456  'default' => false,
457  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
458  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
459  ) {
460  $conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED;
461  },
462  'isReplacedInStructuredUi' => true,
463  ],
464  [
465  'name' => 'hideunpatrolled',
466  'default' => false,
467  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
468  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
469  ) {
470  $conds[] = 'rc_patrolled != ' . RecentChange::PRC_UNPATROLLED;
471  },
472  'isReplacedInStructuredUi' => true,
473  ],
474  ],
475  ]
476  ];
477 
478  $this->reviewStatusFilterGroupDefinition = [
479  [
480  'name' => 'reviewStatus',
481  'title' => 'rcfilters-filtergroup-reviewstatus',
482  'class' => ChangesListStringOptionsFilterGroup::class,
483  'isFullCoverage' => true,
484  'priority' => -5,
485  'filters' => [
486  [
487  'name' => 'unpatrolled',
488  'label' => 'rcfilters-filter-reviewstatus-unpatrolled-label',
489  'description' => 'rcfilters-filter-reviewstatus-unpatrolled-description',
490  'cssClassSuffix' => 'reviewstatus-unpatrolled',
491  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
492  return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED;
493  },
494  ],
495  [
496  'name' => 'manual',
497  'label' => 'rcfilters-filter-reviewstatus-manual-label',
498  'description' => 'rcfilters-filter-reviewstatus-manual-description',
499  'cssClassSuffix' => 'reviewstatus-manual',
500  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
501  return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_PATROLLED;
502  },
503  ],
504  [
505  'name' => 'auto',
506  'label' => 'rcfilters-filter-reviewstatus-auto-label',
507  'description' => 'rcfilters-filter-reviewstatus-auto-description',
508  'cssClassSuffix' => 'reviewstatus-auto',
509  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
510  return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_AUTOPATROLLED;
511  },
512  ],
513  ],
515  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
516  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selected
517  ) {
518  if ( $selected === [] ) {
519  return;
520  }
521  $rcPatrolledValues = [
522  'unpatrolled' => RecentChange::PRC_UNPATROLLED,
523  'manual' => RecentChange::PRC_PATROLLED,
525  ];
526  // e.g. rc_patrolled IN (0, 2)
527  $conds['rc_patrolled'] = array_map( static function ( $s ) use ( $rcPatrolledValues ) {
528  return $rcPatrolledValues[ $s ];
529  }, $selected );
530  }
531  ]
532  ];
533 
534  $this->hideCategorizationFilterDefinition = [
535  'name' => 'hidecategorization',
536  'label' => 'rcfilters-filter-categorization-label',
537  'description' => 'rcfilters-filter-categorization-description',
538  // rcshowhidecategorization-show, rcshowhidecategorization-hide.
539  // wlshowhidecategorization
540  'showHideSuffix' => 'showhidecategorization',
541  'default' => false,
542  'priority' => -4,
543  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
544  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
545  ) {
546  $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_CATEGORIZE );
547  },
548  'cssClassSuffix' => 'src-mw-categorize',
549  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
550  return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_CATEGORIZE;
551  },
552  ];
553  }
554 
560  protected function areFiltersInConflict() {
561  $opts = $this->getOptions();
563  foreach ( $this->getFilterGroups() as $group ) {
564  if ( $group->getConflictingGroups() ) {
565  wfLogWarning(
566  $group->getName() .
567  " specifies conflicts with other groups but these are not supported yet."
568  );
569  }
570 
572  foreach ( $group->getConflictingFilters() as $conflictingFilter ) {
573  if ( $conflictingFilter->activelyInConflictWithGroup( $group, $opts ) ) {
574  return true;
575  }
576  }
577 
579  foreach ( $group->getFilters() as $filter ) {
581  foreach ( $filter->getConflictingFilters() as $conflictingFilter ) {
582  if (
583  $conflictingFilter->activelyInConflictWithFilter( $filter, $opts ) &&
584  $filter->activelyInConflictWithFilter( $conflictingFilter, $opts )
585  ) {
586  return true;
587  }
588  }
589 
590  }
591 
592  }
593 
594  return false;
595  }
596 
600  public function execute( $subpage ) {
601  $this->rcSubpage = $subpage;
602 
603  $this->considerActionsForDefaultSavedQuery( $subpage );
604 
605  // Enable OOUI and module for the clock icon.
606  if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) ) {
607  $this->getOutput()->enableOOUI();
608  $this->getOutput()->addModules( 'mediawiki.special.changeslist.watchlistexpiry' );
609  }
610 
611  $opts = $this->getOptions();
612  try {
613  $rows = $this->getRows();
614  if ( $rows === false ) {
615  $rows = new FakeResultWrapper( [] );
616  }
617 
618  // Used by Structured UI app to get results without MW chrome
619  if ( $this->getRequest()->getRawVal( 'action' ) === 'render' ) {
620  $this->getOutput()->setArticleBodyOnly( true );
621  }
622 
623  // Used by "live update" and "view newest" to check
624  // if there's new changes with minimal data transfer
625  if ( $this->getRequest()->getBool( 'peek' ) ) {
626  $code = $rows->numRows() > 0 ? 200 : 204;
627  $this->getOutput()->setStatusCode( $code );
628 
629  if ( $this->getUser()->isAnon() !==
630  $this->getRequest()->getFuzzyBool( 'isAnon' )
631  ) {
632  $this->getOutput()->setStatusCode( 205 );
633  }
634 
635  return;
636  }
637 
638  $linkBatchFactory = MediaWikiServices::getInstance()->getLinkBatchFactory();
639  $batch = $linkBatchFactory->newLinkBatch();
640  foreach ( $rows as $row ) {
641  $batch->add( NS_USER, $row->rc_user_text );
642  $batch->add( NS_USER_TALK, $row->rc_user_text );
643  $batch->add( $row->rc_namespace, $row->rc_title );
644  if ( $row->rc_source === RecentChange::SRC_LOG ) {
645  $formatter = LogFormatter::newFromRow( $row );
646  foreach ( $formatter->getPreloadTitles() as $title ) {
647  $batch->addObj( $title );
648  }
649  }
650  }
651  $batch->execute();
652 
653  $this->setHeaders();
654  $this->outputHeader();
655  $this->addModules();
656  $this->webOutput( $rows, $opts );
657 
658  $rows->free();
659  } catch ( DBQueryTimeoutError $timeoutException ) {
660  MWExceptionHandler::logException( $timeoutException );
661 
662  $this->setHeaders();
663  $this->outputHeader();
664  $this->addModules();
665 
666  $this->getOutput()->setStatusCode( 500 );
667  $this->webOutputHeader( 0, $opts );
668  $this->outputTimeout();
669  }
670 
671  if ( $this->getConfig()->get( MainConfigNames::EnableWANCacheReaper ) ) {
672  // Clean up any bad page entries for titles showing up in RC
674  $this->getDB(),
675  LoggerFactory::getInstance( 'objectcache' )
676  ) );
677  }
678 
679  $this->includeRcFiltersApp();
680  }
681 
689  protected function considerActionsForDefaultSavedQuery( $subpage ) {
690  if ( !$this->isStructuredFilterUiEnabled() || $this->including() ) {
691  return;
692  }
693 
694  $knownParams = $this->getRequest()->getValues(
695  ...array_keys( $this->getOptions()->getAllValues() )
696  );
697 
698  // HACK: Temporarily until we can properly define "sticky" filters and parameters,
699  // we need to exclude several parameters we know should not be counted towards preventing
700  // the loading of defaults.
701  $excludedParams = [ 'limit' => '', 'days' => '', 'enhanced' => '', 'from' => '' ];
702  $knownParams = array_diff_key( $knownParams, $excludedParams );
703 
704  if (
705  // If there are NO known parameters in the URL request
706  // (that are not excluded) then we need to check into loading
707  // the default saved query
708  count( $knownParams ) === 0
709  ) {
710  // Get the saved queries data and parse it
711  $savedQueries = FormatJson::decode(
712  MediaWikiServices::getInstance()
713  ->getUserOptionsLookup()
714  ->getOption( $this->getUser(), $this->getSavedQueriesPreferenceName() ),
715  true
716  );
717 
718  if ( $savedQueries && isset( $savedQueries[ 'default' ] ) ) {
719  // Only load queries that are 'version' 2, since those
720  // have parameter representation
721  if ( isset( $savedQueries[ 'version' ] ) && $savedQueries[ 'version' ] === '2' ) {
722  $savedQueryDefaultID = $savedQueries[ 'default' ];
723  $defaultQuery = $savedQueries[ 'queries' ][ $savedQueryDefaultID ][ 'data' ];
724 
725  // Build the entire parameter list
726  $query = array_merge(
727  $defaultQuery[ 'params' ],
728  $defaultQuery[ 'highlights' ],
729  [
730  'urlversion' => '2',
731  ]
732  );
733  // Add to the query any parameters that we may have ignored before
734  // but are still valid and requested in the URL
735  $query = array_merge( $this->getRequest()->getValues(), $query );
736  unset( $query[ 'title' ] );
737  $this->getOutput()->redirect( $this->getPageTitle( $subpage )->getCanonicalURL( $query ) );
738  } else {
739  // There's a default, but the version is not 2, and the server can't
740  // actually recognize the query itself. This happens if it is before
741  // the conversion, so we need to tell the UI to reload saved query as
742  // it does the conversion to version 2
743  $this->getOutput()->addJsConfigVars(
744  'wgStructuredChangeFiltersDefaultSavedQueryExists',
745  true
746  );
747 
748  // Add the class that tells the frontend it is still loading
749  // another query
750  $this->getOutput()->addBodyClasses( 'mw-rcfilters-ui-loading' );
751  }
752  }
753  }
754  }
755 
760  protected function getLinkDays() {
761  $linkDays = $this->getConfig()->get( MainConfigNames::RCLinkDays );
762  $filterByAge = $this->getConfig()->get( MainConfigNames::RCFilterByAge );
763  $maxAge = $this->getConfig()->get( MainConfigNames::RCMaxAge );
764  if ( $filterByAge ) {
765  // Trim it to only links which are within $wgRCMaxAge.
766  // Note that we allow one link higher than the max for things like
767  // "age 56 days" being accessible through the "60 days" link.
768  sort( $linkDays );
769 
770  $maxAgeDays = $maxAge / ( 3600 * 24 );
771  foreach ( $linkDays as $i => $days ) {
772  if ( $days >= $maxAgeDays ) {
773  array_splice( $linkDays, $i + 1 );
774  break;
775  }
776  }
777  }
778 
779  return $linkDays;
780  }
781 
788  protected function includeRcFiltersApp() {
789  $out = $this->getOutput();
790  if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
791  $jsData = $this->getStructuredFilterJsData();
792  $messages = [];
793  foreach ( $jsData['messageKeys'] as $key ) {
794  $messages[$key] = $this->msg( $key )->plain();
795  }
796 
797  $out->addBodyClasses( 'mw-rcfilters-enabled' );
798  $collapsed = MediaWikiServices::getInstance()->getUserOptionsLookup()
799  ->getBoolOption( $this->getUser(), $this->getCollapsedPreferenceName() );
800  if ( $collapsed ) {
801  $out->addBodyClasses( 'mw-rcfilters-collapsed' );
802  }
803 
804  // These config and message exports should be moved into a ResourceLoader data module (T201574)
805  $out->addJsConfigVars( 'wgStructuredChangeFilters', $jsData['groups'] );
806  $out->addJsConfigVars( 'wgStructuredChangeFiltersMessages', $messages );
807  $out->addJsConfigVars( 'wgStructuredChangeFiltersCollapsedState', $collapsed );
808 
809  $out->addJsConfigVars(
810  'StructuredChangeFiltersDisplayConfig',
811  [
812  'maxDays' => // Translate to days
813  (int)$this->getConfig()->get( MainConfigNames::RCMaxAge ) / ( 24 * 3600 ),
814  'limitArray' => $this->getConfig()->get( MainConfigNames::RCLinkLimits ),
815  'limitDefault' => $this->getDefaultLimit(),
816  'daysArray' => $this->getLinkDays(),
817  'daysDefault' => $this->getDefaultDays(),
818  ]
819  );
820 
821  $out->addJsConfigVars(
822  'wgStructuredChangeFiltersSavedQueriesPreferenceName',
824  );
825  $out->addJsConfigVars(
826  'wgStructuredChangeFiltersLimitPreferenceName',
827  $this->getLimitPreferenceName()
828  );
829  $out->addJsConfigVars(
830  'wgStructuredChangeFiltersDaysPreferenceName',
832  );
833  $out->addJsConfigVars(
834  'wgStructuredChangeFiltersCollapsedPreferenceName',
836  );
837  } else {
838  $out->addBodyClasses( 'mw-rcfilters-disabled' );
839  }
840  }
841 
850  public static function getRcFiltersConfigSummary( ResourceLoaderContext $context ) {
851  $lang = MediaWikiServices::getInstance()->getLanguageFactory()
852  ->getLanguage( $context->getLanguage() );
853  return [
854  // Reduce version computation by avoiding Message parsing
855  'RCFiltersChangeTags' => ChangeTags::getChangeTagListSummary( $context, $lang ),
856  'StructuredChangeFiltersEditWatchlistUrl' =>
857  SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
858  ];
859  }
860 
868  public static function getRcFiltersConfigVars( ResourceLoaderContext $context ) {
869  $lang = MediaWikiServices::getInstance()->getLanguageFactory()
870  ->getLanguage( $context->getLanguage() );
871  return [
872  'RCFiltersChangeTags' => ChangeTags::getChangeTagList( $context, $lang ),
873  'StructuredChangeFiltersEditWatchlistUrl' =>
874  SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
875  ];
876  }
877 
881  protected function outputNoResults() {
882  $this->getOutput()->addHTML(
884  'div',
885  [ 'class' => 'mw-changeslist-empty' ],
886  $this->msg( 'recentchanges-noresult' )->parse()
887  )
888  );
889  }
890 
894  protected function outputTimeout() {
895  $this->getOutput()->addHTML(
896  '<div class="mw-changeslist-empty mw-changeslist-timeout">' .
897  $this->msg( 'recentchanges-timeout' )->parse() .
898  '</div>'
899  );
900  }
901 
907  public function getRows() {
908  $opts = $this->getOptions();
909 
910  $tables = [];
911  $fields = [];
912  $conds = [];
913  $query_options = [];
914  $join_conds = [];
915  $this->buildQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
916 
917  return $this->doMainQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
918  }
919 
925  public function getOptions() {
926  if ( $this->rcOptions === null ) {
927  $this->rcOptions = $this->setup( $this->rcSubpage );
928  }
929 
930  return $this->rcOptions;
931  }
932 
942  protected function registerFilters() {
943  $this->registerFiltersFromDefinitions( $this->filterGroupDefinitions );
944 
945  // Make sure this is not being transcluded (we don't want to show this
946  // information to all users just because the user that saves the edit can
947  // patrol or is logged in)
948  if ( !$this->including() && $this->getUser()->useRCPatrol() ) {
949  $this->registerFiltersFromDefinitions( $this->legacyReviewStatusFilterGroupDefinition );
950  $this->registerFiltersFromDefinitions( $this->reviewStatusFilterGroupDefinition );
951  }
952 
953  $changeTypeGroup = $this->getFilterGroup( 'changeType' );
954 
955  if ( $this->getConfig()->get( MainConfigNames::RCWatchCategoryMembership ) ) {
956  $transformedHideCategorizationDef = $this->transformFilterDefinition(
957  $this->hideCategorizationFilterDefinition
958  );
959 
960  $transformedHideCategorizationDef['group'] = $changeTypeGroup;
961 
962  $hideCategorization = new ChangesListBooleanFilter(
963  $transformedHideCategorizationDef
964  );
965  }
966 
967  $this->getHookRunner()->onChangesListSpecialPageStructuredFilters( $this );
968 
969  $this->registerFiltersFromDefinitions( [] );
970 
971  $userExperienceLevel = $this->getFilterGroup( 'userExpLevel' );
972  $registered = $userExperienceLevel->getFilter( 'registered' );
973  $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'newcomer' ) );
974  $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'learner' ) );
975  $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'experienced' ) );
976 
977  $categoryFilter = $changeTypeGroup->getFilter( 'hidecategorization' );
978  $logactionsFilter = $changeTypeGroup->getFilter( 'hidelog' );
979  $pagecreationFilter = $changeTypeGroup->getFilter( 'hidenewpages' );
980 
981  $significanceTypeGroup = $this->getFilterGroup( 'significance' );
982  $hideMinorFilter = $significanceTypeGroup->getFilter( 'hideminor' );
983 
984  // categoryFilter is conditional; see registerFilters
985  if ( $categoryFilter !== null ) {
986  $hideMinorFilter->conflictsWith(
987  $categoryFilter,
988  'rcfilters-hideminor-conflicts-typeofchange-global',
989  'rcfilters-hideminor-conflicts-typeofchange',
990  'rcfilters-typeofchange-conflicts-hideminor'
991  );
992  }
993  $hideMinorFilter->conflictsWith(
994  $logactionsFilter,
995  'rcfilters-hideminor-conflicts-typeofchange-global',
996  'rcfilters-hideminor-conflicts-typeofchange',
997  'rcfilters-typeofchange-conflicts-hideminor'
998  );
999  $hideMinorFilter->conflictsWith(
1000  $pagecreationFilter,
1001  'rcfilters-hideminor-conflicts-typeofchange-global',
1002  'rcfilters-hideminor-conflicts-typeofchange',
1003  'rcfilters-typeofchange-conflicts-hideminor'
1004  );
1005  }
1006 
1016  protected function transformFilterDefinition( array $filterDefinition ) {
1017  return $filterDefinition;
1018  }
1019 
1030  protected function registerFiltersFromDefinitions( array $definition ) {
1031  $autoFillPriority = -1;
1032  foreach ( $definition as $groupDefinition ) {
1033  if ( !isset( $groupDefinition['priority'] ) ) {
1034  $groupDefinition['priority'] = $autoFillPriority;
1035  } else {
1036  // If it's explicitly specified, start over the auto-fill
1037  $autoFillPriority = $groupDefinition['priority'];
1038  }
1039 
1040  $autoFillPriority--;
1041 
1042  $className = $groupDefinition['class'];
1043  unset( $groupDefinition['class'] );
1044 
1045  foreach ( $groupDefinition['filters'] as &$filterDefinition ) {
1046  $filterDefinition = $this->transformFilterDefinition( $filterDefinition );
1047  }
1048 
1049  $this->registerFilterGroup( new $className( $groupDefinition ) );
1050  }
1051  }
1052 
1056  protected function getLegacyShowHideFilters() {
1057  $filters = [];
1058  foreach ( $this->filterGroups as $group ) {
1059  if ( $group instanceof ChangesListBooleanFilterGroup ) {
1060  foreach ( $group->getFilters() as $key => $filter ) {
1061  if ( $filter->displaysOnUnstructuredUi() ) {
1062  $filters[ $key ] = $filter;
1063  }
1064  }
1065  }
1066  }
1067  return $filters;
1068  }
1069 
1078  public function setup( $parameters ) {
1079  $this->registerFilters();
1080 
1081  $opts = $this->getDefaultOptions();
1082 
1083  $opts = $this->fetchOptionsFromRequest( $opts );
1084 
1085  // Give precedence to subpage syntax
1086  if ( $parameters !== null ) {
1087  $this->parseParameters( $parameters, $opts );
1088  }
1089 
1090  $this->validateOptions( $opts );
1091 
1092  return $opts;
1093  }
1094 
1104  public function getDefaultOptions() {
1105  $opts = new FormOptions();
1106  $structuredUI = $this->isStructuredFilterUiEnabled();
1107  // If urlversion=2 is set, ignore the filter defaults and set them all to false/empty
1108  $useDefaults = $this->getRequest()->getInt( 'urlversion' ) !== 2;
1109 
1111  foreach ( $this->filterGroups as $filterGroup ) {
1112  $filterGroup->addOptions( $opts, $useDefaults, $structuredUI );
1113  }
1114 
1115  $opts->add( 'namespace', '', FormOptions::STRING );
1116  $opts->add( 'invert', false );
1117  $opts->add( 'associated', false );
1118  $opts->add( 'urlversion', 1 );
1119  $opts->add( 'tagfilter', '' );
1120 
1121  $opts->add( 'days', $this->getDefaultDays(), FormOptions::FLOAT );
1122  $opts->add( 'limit', $this->getDefaultLimit(), FormOptions::INT );
1123 
1124  $opts->add( 'from', '' );
1125 
1126  return $opts;
1127  }
1128 
1134  public function registerFilterGroup( ChangesListFilterGroup $group ) {
1135  $groupName = $group->getName();
1136 
1137  $this->filterGroups[$groupName] = $group;
1138  }
1139 
1145  protected function getFilterGroups() {
1146  return $this->filterGroups;
1147  }
1148 
1156  public function getFilterGroup( $groupName ) {
1157  return $this->filterGroups[$groupName] ?? null;
1158  }
1159 
1160  // Currently, this intentionally only includes filters that display
1161  // in the structured UI. This can be changed easily, though, if we want
1162  // to include data on filters that use the unstructured UI. messageKeys is a
1163  // special top-level value, with the value being an array of the message keys to
1164  // send to the client.
1165 
1173  public function getStructuredFilterJsData() {
1174  $output = [
1175  'groups' => [],
1176  'messageKeys' => [],
1177  ];
1178 
1179  usort( $this->filterGroups, static function ( ChangesListFilterGroup $a, ChangesListFilterGroup $b ) {
1180  return $b->getPriority() <=> $a->getPriority();
1181  } );
1182 
1183  foreach ( $this->filterGroups as $groupName => $group ) {
1184  $groupOutput = $group->getJsData();
1185  if ( $groupOutput !== null ) {
1186  $output['messageKeys'] = array_merge(
1187  $output['messageKeys'],
1188  $groupOutput['messageKeys']
1189  );
1190 
1191  unset( $groupOutput['messageKeys'] );
1192  $output['groups'][] = $groupOutput;
1193  }
1194  }
1195 
1196  return $output;
1197  }
1198 
1207  protected function fetchOptionsFromRequest( $opts ) {
1208  $opts->fetchValuesFromRequest( $this->getRequest() );
1209 
1210  return $opts;
1211  }
1212 
1219  public function parseParameters( $par, FormOptions $opts ) {
1220  $stringParameterNameSet = [];
1221  $hideParameterNameSet = [];
1222 
1223  // URL parameters can be per-group, like 'userExpLevel',
1224  // or per-filter, like 'hideminor'.
1225 
1226  foreach ( $this->filterGroups as $filterGroup ) {
1227  if ( $filterGroup instanceof ChangesListStringOptionsFilterGroup ) {
1228  $stringParameterNameSet[$filterGroup->getName()] = true;
1229  } elseif ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
1230  foreach ( $filterGroup->getFilters() as $filter ) {
1231  $hideParameterNameSet[$filter->getName()] = true;
1232  }
1233  }
1234  }
1235 
1236  $bits = preg_split( '/\s*,\s*/', trim( $par ) );
1237  foreach ( $bits as $bit ) {
1238  $m = [];
1239  if ( isset( $hideParameterNameSet[$bit] ) ) {
1240  // hidefoo => hidefoo=true
1241  $opts[$bit] = true;
1242  } elseif ( isset( $hideParameterNameSet["hide$bit"] ) ) {
1243  // foo => hidefoo=false
1244  $opts["hide$bit"] = false;
1245  } elseif ( preg_match( '/^(.*)=(.*)$/', $bit, $m ) ) {
1246  if ( isset( $stringParameterNameSet[$m[1]] ) ) {
1247  $opts[$m[1]] = $m[2];
1248  }
1249  }
1250  }
1251  }
1252 
1258  public function validateOptions( FormOptions $opts ) {
1259  $isContradictory = $this->fixContradictoryOptions( $opts );
1260  $isReplaced = $this->replaceOldOptions( $opts );
1261 
1262  if ( $isContradictory || $isReplaced ) {
1263  $query = wfArrayToCgi( $this->convertParamsForLink( $opts->getChangedValues() ) );
1264  $this->getOutput()->redirect( $this->getPageTitle()->getCanonicalURL( $query ) );
1265  }
1266 
1267  $opts->validateIntBounds( 'limit', 0, 5000 );
1268  $opts->validateBounds( 'days', 0,
1269  $this->getConfig()->get( MainConfigNames::RCMaxAge ) / ( 3600 * 24 ) );
1270  }
1271 
1278  private function fixContradictoryOptions( FormOptions $opts ) {
1279  $fixed = $this->fixBackwardsCompatibilityOptions( $opts );
1280 
1281  foreach ( $this->filterGroups as $filterGroup ) {
1282  if ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
1283  $filters = $filterGroup->getFilters();
1284 
1285  if ( count( $filters ) === 1 ) {
1286  // legacy boolean filters should not be considered
1287  continue;
1288  }
1289 
1290  $allInGroupEnabled = array_reduce(
1291  $filters,
1292  static function ( bool $carry, ChangesListBooleanFilter $filter ) use ( $opts ) {
1293  return $carry && $opts[ $filter->getName() ];
1294  },
1295  /* initialValue */ count( $filters ) > 0
1296  );
1297 
1298  if ( $allInGroupEnabled ) {
1299  foreach ( $filters as $filter ) {
1300  $opts[ $filter->getName() ] = false;
1301  }
1302 
1303  $fixed = true;
1304  }
1305  }
1306  }
1307 
1308  return $fixed;
1309  }
1310 
1320  private function fixBackwardsCompatibilityOptions( FormOptions $opts ) {
1321  if ( $opts['hideanons'] && $opts['hideliu'] ) {
1322  $opts->reset( 'hideanons' );
1323  if ( !$opts['hidebots'] ) {
1324  $opts->reset( 'hideliu' );
1325  $opts['hidehumans'] = 1;
1326  }
1327 
1328  return true;
1329  }
1330 
1331  return false;
1332  }
1333 
1340  public function replaceOldOptions( FormOptions $opts ) {
1341  if ( !$this->isStructuredFilterUiEnabled() ) {
1342  return false;
1343  }
1344 
1345  $changed = false;
1346 
1347  // At this point 'hideanons' and 'hideliu' cannot be both true,
1348  // because fixBackwardsCompatibilityOptions resets (at least) 'hideanons' in such case
1349  if ( $opts[ 'hideanons' ] ) {
1350  $opts->reset( 'hideanons' );
1351  $opts[ 'userExpLevel' ] = 'registered';
1352  $changed = true;
1353  }
1354 
1355  if ( $opts[ 'hideliu' ] ) {
1356  $opts->reset( 'hideliu' );
1357  $opts[ 'userExpLevel' ] = 'unregistered';
1358  $changed = true;
1359  }
1360 
1361  if ( $this->getFilterGroup( 'legacyReviewStatus' ) ) {
1362  if ( $opts[ 'hidepatrolled' ] ) {
1363  $opts->reset( 'hidepatrolled' );
1364  $opts[ 'reviewStatus' ] = 'unpatrolled';
1365  $changed = true;
1366  }
1367 
1368  if ( $opts[ 'hideunpatrolled' ] ) {
1369  $opts->reset( 'hideunpatrolled' );
1370  $opts[ 'reviewStatus' ] = implode(
1372  [ 'manual', 'auto' ]
1373  );
1374  $changed = true;
1375  }
1376  }
1377 
1378  return $changed;
1379  }
1380 
1389  protected function convertParamsForLink( $params ) {
1390  foreach ( $params as &$value ) {
1391  if ( $value === false ) {
1392  $value = '0';
1393  }
1394  }
1395  unset( $value );
1396  return $params;
1397  }
1398 
1410  protected function buildQuery( &$tables, &$fields, &$conds, &$query_options,
1411  &$join_conds, FormOptions $opts
1412  ) {
1413  $dbr = $this->getDB();
1414  $isStructuredUI = $this->isStructuredFilterUiEnabled();
1415 
1417  foreach ( $this->filterGroups as $filterGroup ) {
1418  $filterGroup->modifyQuery( $dbr, $this, $tables, $fields, $conds,
1419  $query_options, $join_conds, $opts, $isStructuredUI );
1420  }
1421 
1422  // Namespace filtering
1423  if ( $opts[ 'namespace' ] !== '' ) {
1424  $namespaces = explode( ';', $opts[ 'namespace' ] );
1425 
1426  $namespaces = $this->expandSymbolicNamespaceFilters( $namespaces );
1427 
1428  $namespaceInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
1429  $namespaces = array_filter( $namespaces, [ $namespaceInfo, 'exists' ] );
1430 
1431  if ( $namespaces !== [] ) {
1432  // Namespaces are just ints, use them as int when acting with the database
1433  $namespaces = array_map( 'intval', $namespaces );
1434 
1435  if ( $opts[ 'associated' ] ) {
1436  $associatedNamespaces = array_map(
1437  [ $namespaceInfo, 'getAssociated' ],
1438  array_filter( $namespaces, [ $namespaceInfo, 'hasTalkNamespace' ] )
1439  );
1440  $namespaces = array_unique( array_merge( $namespaces, $associatedNamespaces ) );
1441  }
1442 
1443  if ( count( $namespaces ) === 1 ) {
1444  $operator = $opts[ 'invert' ] ? '!=' : '=';
1445  $value = $dbr->addQuotes( reset( $namespaces ) );
1446  } else {
1447  $operator = $opts[ 'invert' ] ? 'NOT IN' : 'IN';
1448  sort( $namespaces );
1449  $value = '(' . $dbr->makeList( $namespaces ) . ')';
1450  }
1451  $conds[] = "rc_namespace $operator $value";
1452  }
1453  }
1454 
1455  // Calculate cutoff
1456  $cutoff_unixtime = time() - $opts['days'] * 3600 * 24;
1457  $cutoff = $dbr->timestamp( $cutoff_unixtime );
1458 
1459  $fromValid = preg_match( '/^[0-9]{14}$/', $opts['from'] );
1460  if ( $fromValid && $opts['from'] > wfTimestamp( TS_MW, $cutoff ) ) {
1461  $cutoff = $dbr->timestamp( $opts['from'] );
1462  } else {
1463  $opts->reset( 'from' );
1464  }
1465 
1466  $conds[] = 'rc_timestamp >= ' . $dbr->addQuotes( $cutoff );
1467  }
1468 
1480  protected function doMainQuery( $tables, $fields, $conds,
1481  $query_options, $join_conds, FormOptions $opts
1482  ) {
1483  $rcQuery = RecentChange::getQueryInfo();
1484  $tables = array_merge( $tables, $rcQuery['tables'] );
1485  $fields = array_merge( $rcQuery['fields'], $fields );
1486  $join_conds = array_merge( $join_conds, $rcQuery['joins'] );
1487 
1489  $tables,
1490  $fields,
1491  $conds,
1492  $join_conds,
1493  $query_options,
1494  ''
1495  );
1496 
1497  if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
1498  $opts )
1499  ) {
1500  return false;
1501  }
1502 
1503  $dbr = $this->getDB();
1504 
1505  return $dbr->select(
1506  $tables,
1507  $fields,
1508  $conds,
1509  __METHOD__,
1510  $query_options,
1511  $join_conds
1512  );
1513  }
1514 
1515  protected function runMainQueryHook( &$tables, &$fields, &$conds,
1516  &$query_options, &$join_conds, $opts
1517  ) {
1518  return $this->getHookRunner()->onChangesListSpecialPageQuery(
1519  $this->getName(), $tables, $fields, $conds, $query_options, $join_conds, $opts );
1520  }
1521 
1527  protected function getDB() {
1528  return wfGetDB( DB_REPLICA );
1529  }
1530 
1537  private function webOutputHeader( $rowCount, $opts ) {
1538  if ( !$this->including() ) {
1539  $this->outputFeedLinks();
1540  $this->doHeader( $opts, $rowCount );
1541  }
1542  }
1543 
1550  public function webOutput( $rows, $opts ) {
1551  $this->webOutputHeader( $rows->numRows(), $opts );
1552 
1553  $this->outputChangesList( $rows, $opts );
1554  }
1555 
1556  public function outputFeedLinks() {
1557  // nothing by default
1558  }
1559 
1566  abstract public function outputChangesList( $rows, $opts );
1567 
1574  public function doHeader( $opts, $numRows ) {
1575  $this->setTopText( $opts );
1576 
1577  // @todo Lots of stuff should be done here.
1578 
1579  $this->setBottomText( $opts );
1580  }
1581 
1589  public function setTopText( FormOptions $opts ) {
1590  // nothing by default
1591  }
1592 
1600  public function setBottomText( FormOptions $opts ) {
1601  // nothing by default
1602  }
1603 
1613  public function getExtraOptions( $opts ) {
1614  return [];
1615  }
1616 
1622  public function makeLegend() {
1623  $context = $this->getContext();
1624  $user = $context->getUser();
1625  # The legend showing what the letters and stuff mean
1626  $legend = Html::openElement( 'dl' ) . "\n";
1627  # Iterates through them and gets the messages for both letter and tooltip
1628  $legendItems = $context->getConfig()->get( MainConfigNames::RecentChangesFlags );
1629  if ( !( $user->useRCPatrol() || $user->useNPPatrol() ) ) {
1630  unset( $legendItems['unpatrolled'] );
1631  }
1632  foreach ( $legendItems as $key => $item ) { # generate items of the legend
1633  $label = $item['legend'] ?? $item['title'];
1634  $letter = $item['letter'];
1635  $cssClass = $item['class'] ?? $key;
1636 
1637  $legend .= Html::element( 'dt',
1638  [ 'class' => $cssClass ], $context->msg( $letter )->text()
1639  ) . "\n" .
1640  Html::rawElement( 'dd',
1641  [ 'class' => Sanitizer::escapeClass( 'mw-changeslist-legend-' . $key ) ],
1642  $context->msg( $label )->parse()
1643  ) . "\n";
1644  }
1645  # (+-123)
1646  $legend .= Html::rawElement( 'dt',
1647  [ 'class' => 'mw-plusminus-pos' ],
1648  $context->msg( 'recentchanges-legend-plusminus' )->parse()
1649  ) . "\n";
1650  $legend .= Html::element(
1651  'dd',
1652  [ 'class' => 'mw-changeslist-legend-plusminus' ],
1653  $context->msg( 'recentchanges-label-plusminus' )->text()
1654  ) . "\n";
1655  // Watchlist expiry clock icon.
1656  if ( $context->getConfig()->get( MainConfigNames::WatchlistExpiry ) ) {
1657  $widget = new IconWidget( [
1658  'icon' => 'clock',
1659  'classes' => [ 'mw-changesList-watchlistExpiry' ],
1660  ] );
1661  // Link the image to its label for assistive technologies.
1662  $watchlistLabelId = 'mw-changeslist-watchlistExpiry-label';
1663  $widget->getIconElement()->setAttributes( [
1664  'role' => 'img',
1665  'aria-labelledby' => $watchlistLabelId,
1666  ] );
1667  $legend .= Html::rawElement(
1668  'dt',
1669  [ 'class' => 'mw-changeslist-legend-watchlistexpiry' ],
1670  $widget
1671  );
1672  $legend .= Html::element(
1673  'dd',
1674  [ 'class' => 'mw-changeslist-legend-watchlistexpiry', 'id' => $watchlistLabelId ],
1675  $context->msg( 'recentchanges-legend-watchlistexpiry' )->text()
1676  );
1677  }
1678  $legend .= Html::closeElement( 'dl' ) . "\n";
1679 
1680  $legendHeading = $this->isStructuredFilterUiEnabled() ?
1681  $context->msg( 'rcfilters-legend-heading' )->parse() :
1682  $context->msg( 'recentchanges-legend-heading' )->parse();
1683 
1684  # Collapsible
1685  $collapsedState = $this->getRequest()->getCookie( 'changeslist-state' );
1686  $collapsedClass = $collapsedState === 'collapsed' ? 'mw-collapsed' : '';
1687 
1688  $legend = Html::rawElement(
1689  'div',
1690  [ 'class' => [ 'mw-changeslist-legend', 'mw-collapsible', $collapsedClass ] ],
1691  $legendHeading .
1692  Html::rawElement( 'div', [ 'class' => 'mw-collapsible-content' ], $legend )
1693  );
1694 
1695  return $legend;
1696  }
1697 
1701  protected function addModules() {
1702  $out = $this->getOutput();
1703  // Styles and behavior for the legend box (see makeLegend())
1704  $out->addModuleStyles( [
1705  'mediawiki.interface.helpers.styles',
1706  'mediawiki.special.changeslist.legend',
1707  'mediawiki.special.changeslist',
1708  ] );
1709  $out->addModules( 'mediawiki.special.changeslist.legend.js' );
1710 
1711  if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
1712  $out->addModules( 'mediawiki.rcfilters.filters.ui' );
1713  $out->addModuleStyles( 'mediawiki.rcfilters.filters.base.styles' );
1714  }
1715  }
1716 
1717  protected function getGroupName() {
1718  return 'changes';
1719  }
1720 
1737  public function filterOnUserExperienceLevel( $specialPageClassName, $context, $dbr,
1738  &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedExpLevels, $now = 0
1739  ) {
1740  $LEVEL_COUNT = 5;
1741 
1742  // If all levels are selected, don't filter
1743  if ( count( $selectedExpLevels ) === $LEVEL_COUNT ) {
1744  return;
1745  }
1746 
1747  // both 'registered' and 'unregistered', experience levels, if any, are included in 'registered'
1748  if (
1749  in_array( 'registered', $selectedExpLevels ) &&
1750  in_array( 'unregistered', $selectedExpLevels )
1751  ) {
1752  return;
1753  }
1754 
1755  // 'registered' but not 'unregistered', experience levels, if any, are included in 'registered'
1756  if (
1757  in_array( 'registered', $selectedExpLevels ) &&
1758  !in_array( 'unregistered', $selectedExpLevels )
1759  ) {
1760  $conds[] = 'actor_user IS NOT NULL';
1761  return;
1762  }
1763 
1764  if ( $selectedExpLevels === [ 'unregistered' ] ) {
1765  $conds['actor_user'] = null;
1766  return;
1767  }
1768 
1769  $tables[] = 'user';
1770  $join_conds['user'] = [ 'LEFT JOIN', 'actor_user=user_id' ];
1771 
1772  if ( $now === 0 ) {
1773  $now = time();
1774  }
1775  $secondsPerDay = 86400;
1776  $config = $this->getConfig();
1777  $learnerCutoff =
1778  $now - $config->get( MainConfigNames::LearnerMemberSince ) * $secondsPerDay;
1779  $experiencedUserCutoff =
1780  $now - $config->get( MainConfigNames::ExperiencedUserMemberSince ) * $secondsPerDay;
1781 
1782  $aboveNewcomer = $dbr->makeList(
1783  [
1784  'user_editcount >= ' . intval( $config->get( MainConfigNames::LearnerEdits ) ),
1785  $dbr->makeList( [
1786  'user_registration IS NULL',
1787  'user_registration <= ' . $dbr->addQuotes( $dbr->timestamp( $learnerCutoff ) ),
1788  ], IDatabase::LIST_OR ),
1789  ],
1791  );
1792 
1793  $aboveLearner = $dbr->makeList(
1794  [
1795  'user_editcount >= ' . intval( $config->get( MainConfigNames::ExperiencedUserEdits ) ),
1796  $dbr->makeList( [
1797  'user_registration IS NULL',
1798  'user_registration <= ' .
1799  $dbr->addQuotes( $dbr->timestamp( $experiencedUserCutoff ) ),
1800  ], IDatabase::LIST_OR ),
1801  ],
1803  );
1804 
1805  $conditions = [];
1806 
1807  if ( in_array( 'unregistered', $selectedExpLevels ) ) {
1808  $selectedExpLevels = array_diff( $selectedExpLevels, [ 'unregistered' ] );
1809  $conditions['actor_user'] = null;
1810  }
1811 
1812  if ( $selectedExpLevels === [ 'newcomer' ] ) {
1813  $conditions[] = "NOT ( $aboveNewcomer )";
1814  } elseif ( $selectedExpLevels === [ 'learner' ] ) {
1815  $conditions[] = $dbr->makeList(
1816  [ $aboveNewcomer, "NOT ( $aboveLearner )" ],
1818  );
1819  } elseif ( $selectedExpLevels === [ 'experienced' ] ) {
1820  $conditions[] = $aboveLearner;
1821  } elseif ( $selectedExpLevels === [ 'learner', 'newcomer' ] ) {
1822  $conditions[] = "NOT ( $aboveLearner )";
1823  } elseif ( $selectedExpLevels === [ 'experienced', 'newcomer' ] ) {
1824  $conditions[] = $dbr->makeList(
1825  [ "NOT ( $aboveNewcomer )", $aboveLearner ],
1827  );
1828  } elseif ( $selectedExpLevels === [ 'experienced', 'learner' ] ) {
1829  $conditions[] = $aboveNewcomer;
1830  } elseif ( $selectedExpLevels === [ 'experienced', 'learner', 'newcomer' ] ) {
1831  $conditions[] = 'actor_user IS NOT NULL';
1832  }
1833 
1834  if ( count( $conditions ) > 1 ) {
1835  $conds[] = $dbr->makeList( $conditions, IDatabase::LIST_OR );
1836  } elseif ( count( $conditions ) === 1 ) {
1837  $conds[] = reset( $conditions );
1838  }
1839  }
1840 
1846  public function isStructuredFilterUiEnabled() {
1847  if ( $this->getRequest()->getBool( 'rcfilters' ) ) {
1848  return true;
1849  }
1850 
1851  return static::checkStructuredFilterUiEnabled( $this->getUser() );
1852  }
1853 
1861  public static function checkStructuredFilterUiEnabled( $user ) {
1862  if ( $user instanceof Config ) {
1863  wfDeprecated( __METHOD__ . ' with Config argument', '1.34' );
1864  $user = func_get_arg( 1 );
1865  }
1866  return !MediaWikiServices::getInstance()
1867  ->getUserOptionsLookup()
1868  ->getOption( $user, 'rcenhancedfilters-disable' );
1869  }
1870 
1878  public function getDefaultLimit() {
1879  return MediaWikiServices::getInstance()
1880  ->getUserOptionsLookup()
1881  ->getIntOption( $this->getUser(), $this->getLimitPreferenceName() );
1882  }
1883 
1892  public function getDefaultDays() {
1893  return floatval( MediaWikiServices::getInstance()
1894  ->getUserOptionsLookup()
1895  ->getOption( $this->getUser(), $this->getDefaultDaysPreferenceName() ) );
1896  }
1897 
1904  abstract protected function getLimitPreferenceName(): string;
1905 
1912  abstract protected function getSavedQueriesPreferenceName(): string;
1913 
1920  abstract protected function getDefaultDaysPreferenceName(): string;
1921 
1928  abstract protected function getCollapsedPreferenceName(): string;
1929 
1934  private function expandSymbolicNamespaceFilters( array $namespaces ) {
1935  $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
1936  $symbolicFilters = [
1937  'all-contents' => $nsInfo->getSubjectNamespaces(),
1938  'all-discussions' => $nsInfo->getTalkNamespaces(),
1939  ];
1940  $additionalNamespaces = [];
1941  foreach ( $symbolicFilters as $name => $values ) {
1942  if ( in_array( $name, $namespaces ) ) {
1943  $additionalNamespaces = array_merge( $additionalNamespaces, $values );
1944  }
1945  }
1946  $namespaces = array_diff( $namespaces, array_keys( $symbolicFilters ) );
1947  $namespaces = array_merge( $namespaces, $additionalNamespaces );
1948  return array_unique( $namespaces );
1949  }
1950 }
const NS_USER
Definition: Defines.php:66
const RC_NEW
Definition: Defines.php:116
const LIST_OR
Definition: Defines.php:46
const RC_LOG
Definition: Defines.php:117
const LIST_AND
Definition: Defines.php:43
const NS_USER_TALK
Definition: Defines.php:67
const RC_EDIT
Definition: Defines.php:115
const RC_CATEGORIZE
Definition: Defines.php:119
wfGetDB( $db, $groups=[], $wiki=false)
Get a Database object.
wfLogWarning( $msg, $callerOffset=1, $level=E_USER_WARNING)
Send a warning as a PHP error and the debug log.
wfArrayToCgi( $array1, $array2=null, $prefix='')
This function takes one or two arrays as input, and returns a CGI-style string, e....
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
static modifyDisplayQuery(&$tables, &$fields, &$conds, &$join_conds, &$options, $filter_tag='', bool $exclude=false)
Applies all tags-related changes to a query.
Definition: ChangeTags.php:900
static getChangeTagListSummary(MessageLocalizer $localizer, Language $lang)
Get information about change tags, without parsing messages, for tag filter dropdown menus.
static getChangeTagList(MessageLocalizer $localizer, Language $lang)
Get information about change tags for tag filter dropdown menus.
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)
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.
getSavedQueriesPreferenceName()
Preference name for saved queries.
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.
getDefaultDaysPreferenceName()
Preference name for 'days'.
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.
getFilterGroup( $groupName)
Gets a specified ChangesListFilterGroup by name.
replaceOldOptions(FormOptions $opts)
Replace old options with their structured UI equivalents.
isStructuredFilterUiEnabled()
Check whether the structured filter UI is enabled.
array $hideCategorizationFilterDefinition
Single filter group registered conditionally.
getExtraOptions( $opts)
Get options to be displayed in a form.
setup( $parameters)
Register all the filters, including legacy hook-driven ones.
registerFilters()
Register all filters and their groups (including those from hooks), plus handle conflicts and default...
areFiltersInConflict()
Check if filters are in conflict and guaranteed to return no results.
array $legacyReviewStatusFilterGroupDefinition
Same format as filterGroupDefinitions, but for a single group (reviewStatus) that is registered condi...
getDefaultDays()
Get the default value of the number of days to display when loading the result set.
fixBackwardsCompatibilityOptions(FormOptions $opts)
Fix a special case (hideanons=1 and hideliu=1) in a special way, for backwards compatibility.
expandSymbolicNamespaceFilters(array $namespaces)
outputNoResults()
Add the "no results" message to the output.
getFilterGroups()
Gets the currently registered filters groups.
registerFilterGroup(ChangesListFilterGroup $group)
Register a structured changes list filter group.
addModules()
Add page-specific modules.
fixContradictoryOptions(FormOptions $opts)
Fix invalid options by resetting pairs that should never appear together.
static getRcFiltersConfigSummary(ResourceLoaderContext $context)
Get essential data about getRcFiltersConfigVars() for change detection.
__construct( $name, $restriction)
outputTimeout()
Add the "timeout" message to the output.
doHeader( $opts, $numRows)
Set the text to be displayed above the changes.
array $reviewStatusFilterGroupDefinition
Single filter group registered conditionally.
fetchOptionsFromRequest( $opts)
Fetch values for a FormOptions object from the WebRequest associated with this instance.
getOptions()
Get the current FormOptions for this request.
doMainQuery( $tables, $fields, $conds, $query_options, $join_conds, FormOptions $opts)
Process the query.
getCollapsedPreferenceName()
Preference name for collapsing the active filter display.
setBottomText(FormOptions $opts)
Send the text to be displayed after the options.
getStructuredFilterJsData()
Gets structured filter information needed by JS.
buildQuery(&$tables, &$fields, &$conds, &$query_options, &$join_conds, FormOptions $opts)
Sets appropriate tables, fields, conditions, etc.
webOutputHeader( $rowCount, $opts)
Send header output to the OutputPage object, only called if not using feeds.
ChangesListFilterGroup[] $filterGroups
Filter groups, and their contained filters This is an associative array (with group name as key) of C...
makeLegend()
Return the legend displayed within the fieldset.
webOutput( $rows, $opts)
Send output to the OutputPage object, only called if not used feeds.
considerActionsForDefaultSavedQuery( $subpage)
Check whether or not the page should load defaults, and if so, whether a default saved query is relev...
transformFilterDefinition(array $filterDefinition)
Transforms filter definition to prepare it for constructor.
getDB()
Return a IDatabase object for reading.
getLimitPreferenceName()
Getting the preference name for 'limit'.
static checkStructuredFilterUiEnabled( $user)
Static method to check whether StructuredFilter UI is enabled for the given user.
outputChangesList( $rows, $opts)
Build and output the actual changes list.
getRows()
Get the database result for this special page instance.
array $filterGroupDefinitions
Definition information for the filters and their groups.
includeRcFiltersApp()
Include the modules and configuration for the RCFilters app.
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.
static addUpdate(DeferrableUpdate $update, $stage=self::POSTSEND)
Add an update to the pending update queue for execution at the appropriate time.
Helper class to keep track of options when mixing links and form elements.
Definition: FormOptions.php:35
reset( $name)
Delete the option value.
const FLOAT
Float type, maps guessType() to WebRequest::getFloat()
Definition: FormOptions.php:49
validateBounds( $name, $min, $max)
Constrain a numeric value for a given option to a given range.
const STRING
String type, maps guessType() to WebRequest::getText()
Definition: FormOptions.php:43
validateIntBounds( $name, $min, $max)
const INT
Integer type, maps guessType() to WebRequest::getInt()
Definition: FormOptions.php:45
getChangedValues()
Return options modified as an array ( name => value )
static decode( $value, $assoc=false)
Decodes a JSON string.
Definition: FormatJson.php:146
static element( $element, $attribs=[], $contents='')
Identical to rawElement(), but HTML-escapes $contents (like Xml::element()).
Definition: Html.php:236
static rawElement( $element, $attribs=[], $contents='')
Returns an HTML element in a string.
Definition: Html.php:214
static openElement( $element, $attribs=[])
Identical to rawElement(), but has no third parameter and omits the end tag (and the self-closing '/'...
Definition: Html.php:256
static closeElement( $element)
Returns "</$element>".
Definition: Html.php:320
static newFromRow( $row)
Handy shortcut for constructing a formatter directly from database row.
static logException(Throwable $e, $catcher=self::CAUGHT_BY_OTHER, $extraData=[])
Log a throwable to the exception log (if enabled).
PSR-3 logger instance factory.
A class containing constants representing the names of configuration variables.
MediaWikiServices is the service locator for the application scope of MediaWiki.
Utility class for creating new RC entries.
const PRC_UNPATROLLED
const SRC_CATEGORIZE
static getQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new recentchanges object.
const PRC_PATROLLED
const PRC_AUTOPATROLLED
Context object that contains information about the state of a specific ResourceLoader web request.
static escapeClass( $class)
Given a value, escape it so that it can be used as a CSS class and return it.
Definition: Sanitizer.php:1107
Parent class for all special pages.
Definition: SpecialPage.php:44
outputHeader( $summaryMessageKey='')
Outputs a summary message on top of special pages Per default the message key is the canonical name o...
getName()
Get the name of this Special Page.
setHeaders()
Sets headers - this should be called from the execute() method of all derived classes!
getOutput()
Get the OutputPage being used for this instance.
getUser()
Shortcut to get the User executing this instance.
static getTitleFor( $name, $subpage=false, $fragment='')
Get a localised Title object for a specified special page name If you don't need a full Title object,...
getContext()
Gets the context this SpecialPage is executed in.
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 ResultWrapper so it doesn't go anywhere near an actual dat...
Interface for configuration instances.
Definition: Config.php:30
Interface for objects which can provide a MediaWiki context on request.
Interface for objects representing user identity.
Basic database interface for live and lazy-loaded relation database handles.
Definition: IDatabase.php:40
Result wrapper for grabbing data queried from an IDatabase object.
foreach( $mmfl['setupFiles'] as $fileName) if( $queue) if(empty( $mmfl['quiet'])) $s
const DB_REPLICA
Definition: defines.php:25
if(!isset( $args[0])) $lang