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