MediaWiki  master
ChangesListSpecialPage.php
Go to the documentation of this file.
1 <?php
25 use OOUI\IconWidget;
30 
38 abstract class ChangesListSpecialPage extends SpecialPage {
39 
41  protected $rcSubpage;
42 
44  protected $rcOptions;
45 
46  // Order of both groups and filters is significant; first is top-most priority,
47  // descending from there.
48  // 'showHideSuffix' is a shortcut to and avoid spelling out
49  // details specific to subclasses here.
62  private $filterGroupDefinitions;
63 
68  private $legacyReviewStatusFilterGroupDefinition;
69 
71  private $reviewStatusFilterGroupDefinition;
72 
74  private $hideCategorizationFilterDefinition;
75 
82  protected $filterGroups = [];
83 
84  public function __construct( $name, $restriction ) {
85  parent::__construct( $name, $restriction );
86 
87  $nonRevisionTypes = [ RC_LOG ];
88  $this->getHookRunner()->onSpecialWatchlistGetNonRevisionTypes( $nonRevisionTypes );
89 
90  $this->filterGroupDefinitions = [
91  [
92  'name' => 'registration',
93  'title' => 'rcfilters-filtergroup-registration',
94  'class' => ChangesListBooleanFilterGroup::class,
95  'filters' => [
96  [
97  'name' => 'hideliu',
98  // rcshowhideliu-show, rcshowhideliu-hide,
99  // wlshowhideliu
100  'showHideSuffix' => 'showhideliu',
101  'default' => false,
102  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
103  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
104  ) {
105  $conds['actor_user'] = null;
106  $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
107  },
108  'isReplacedInStructuredUi' => true,
109 
110  ],
111  [
112  'name' => 'hideanons',
113  // rcshowhideanons-show, rcshowhideanons-hide,
114  // wlshowhideanons
115  'showHideSuffix' => 'showhideanons',
116  'default' => false,
117  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
118  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
119  ) {
120  $conds[] = 'actor_user IS NOT NULL';
121  $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
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  $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
218  },
219  'cssClassSuffix' => 'self',
220  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
221  return $ctx->getUser()->equals( $rc->getPerformerIdentity() );
222  },
223  ],
224  [
225  'name' => 'hidebyothers',
226  'label' => 'rcfilters-filter-editsbyother-label',
227  'description' => 'rcfilters-filter-editsbyother-description',
228  'default' => false,
229  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
230  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
231  ) {
232  $user = $ctx->getUser();
233  if ( $user->isAnon() ) {
234  $conds['actor_name'] = $user->getName();
235  } else {
236  $conds['actor_user'] = $user->getId();
237  }
238  $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
239  },
240  'cssClassSuffix' => 'others',
241  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
242  return !$ctx->getUser()->equals( $rc->getPerformerIdentity() );
243  },
244  ]
245  ]
246  ],
247 
248  [
249  'name' => 'automated',
250  'title' => 'rcfilters-filtergroup-automated',
251  'class' => ChangesListBooleanFilterGroup::class,
252  'filters' => [
253  [
254  'name' => 'hidebots',
255  'label' => 'rcfilters-filter-bots-label',
256  'description' => 'rcfilters-filter-bots-description',
257  // rcshowhidebots-show, rcshowhidebots-hide,
258  // wlshowhidebots
259  'showHideSuffix' => 'showhidebots',
260  'default' => false,
261  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
262  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
263  ) {
264  $conds['rc_bot'] = 0;
265  },
266  'cssClassSuffix' => 'bot',
267  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
268  return $rc->getAttribute( 'rc_bot' );
269  },
270  ],
271  [
272  'name' => 'hidehumans',
273  'label' => 'rcfilters-filter-humans-label',
274  'description' => 'rcfilters-filter-humans-description',
275  'default' => false,
276  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
277  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
278  ) {
279  $conds['rc_bot'] = 1;
280  },
281  'cssClassSuffix' => 'human',
282  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
283  return !$rc->getAttribute( 'rc_bot' );
284  },
285  ]
286  ]
287  ],
288 
289  // significance (conditional)
290 
291  [
292  'name' => 'significance',
293  'title' => 'rcfilters-filtergroup-significance',
294  'class' => ChangesListBooleanFilterGroup::class,
295  'priority' => -6,
296  'filters' => [
297  [
298  'name' => 'hideminor',
299  'label' => 'rcfilters-filter-minor-label',
300  'description' => 'rcfilters-filter-minor-description',
301  // rcshowhideminor-show, rcshowhideminor-hide,
302  // wlshowhideminor
303  'showHideSuffix' => 'showhideminor',
304  'default' => false,
305  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
306  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
307  ) {
308  $conds[] = 'rc_minor = 0';
309  },
310  'cssClassSuffix' => 'minor',
311  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
312  return $rc->getAttribute( 'rc_minor' );
313  }
314  ],
315  [
316  'name' => 'hidemajor',
317  'label' => 'rcfilters-filter-major-label',
318  'description' => 'rcfilters-filter-major-description',
319  'default' => false,
320  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
321  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
322  ) {
323  $conds[] = 'rc_minor = 1';
324  },
325  'cssClassSuffix' => 'major',
326  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
327  return !$rc->getAttribute( 'rc_minor' );
328  }
329  ]
330  ]
331  ],
332 
333  [
334  'name' => 'lastRevision',
335  'title' => 'rcfilters-filtergroup-lastrevision',
336  'class' => ChangesListBooleanFilterGroup::class,
337  'priority' => -7,
338  'filters' => [
339  [
340  'name' => 'hidelastrevision',
341  'label' => 'rcfilters-filter-lastrevision-label',
342  'description' => 'rcfilters-filter-lastrevision-description',
343  'default' => false,
344  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
345  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
346  ) use ( $nonRevisionTypes ) {
347  $conds[] = $dbr->makeList(
348  [
349  'rc_this_oldid <> page_latest',
350  'rc_type' => $nonRevisionTypes,
351  ],
352  LIST_OR
353  );
354  },
355  'cssClassSuffix' => 'last',
356  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
357  return $rc->getAttribute( 'rc_this_oldid' ) === $rc->getAttribute( 'page_latest' );
358  }
359  ],
360  [
361  'name' => 'hidepreviousrevisions',
362  'label' => 'rcfilters-filter-previousrevision-label',
363  'description' => 'rcfilters-filter-previousrevision-description',
364  'default' => false,
365  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
366  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
367  ) use ( $nonRevisionTypes ) {
368  $conds[] = $dbr->makeList(
369  [
370  'rc_this_oldid = page_latest',
371  'rc_type' => $nonRevisionTypes,
372  ],
373  LIST_OR
374  );
375  },
376  'cssClassSuffix' => 'previous',
377  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
378  return $rc->getAttribute( 'rc_this_oldid' ) !== $rc->getAttribute( 'page_latest' );
379  }
380  ]
381  ]
382  ],
383 
384  // With extensions, there can be change types that will not be hidden by any of these.
385  [
386  'name' => 'changeType',
387  'title' => 'rcfilters-filtergroup-changetype',
388  'class' => ChangesListBooleanFilterGroup::class,
389  'priority' => -8,
390  'filters' => [
391  [
392  'name' => 'hidepageedits',
393  'label' => 'rcfilters-filter-pageedits-label',
394  'description' => 'rcfilters-filter-pageedits-description',
395  'default' => false,
396  'priority' => -2,
397  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
398  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
399  ) {
400  $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_EDIT );
401  },
402  'cssClassSuffix' => 'src-mw-edit',
403  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
404  return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_EDIT;
405  },
406  ],
407  [
408  'name' => 'hidenewpages',
409  'label' => 'rcfilters-filter-newpages-label',
410  'description' => 'rcfilters-filter-newpages-description',
411  'default' => false,
412  'priority' => -3,
413  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
414  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
415  ) {
416  $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_NEW );
417  },
418  'cssClassSuffix' => 'src-mw-new',
419  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
420  return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_NEW;
421  },
422  ],
423 
424  // hidecategorization
425 
426  [
427  'name' => 'hidelog',
428  'label' => 'rcfilters-filter-logactions-label',
429  'description' => 'rcfilters-filter-logactions-description',
430  'default' => false,
431  'priority' => -5,
432  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
433  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
434  ) {
435  $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_LOG );
436  },
437  'cssClassSuffix' => 'src-mw-log',
438  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
439  return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_LOG;
440  }
441  ],
442  [
443  'name' => 'hidenewuserlog',
444  'label' => 'rcfilters-filter-newuserlogactions-label',
445  'description' => 'rcfilters-filter-newuserlogactions-description',
446  'default' => false,
447  'priority' => -6,
448  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
449  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
450  ) {
451  $conds[] = $dbr->makeList(
452  [
453  'rc_log_type != ' . $dbr->addQuotes( 'newusers' ),
454  'rc_log_type' => null
455  ],
457  );
458  },
459  'cssClassSuffix' => 'src-mw-newuserlog',
460  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
461  return $rc->getAttribute( 'rc_log_type' ) === "newusers";
462  },
463  ],
464  ],
465  ],
466 
467  ];
468 
469  $this->legacyReviewStatusFilterGroupDefinition = [
470  [
471  'name' => 'legacyReviewStatus',
472  'title' => 'rcfilters-filtergroup-reviewstatus',
473  'class' => ChangesListBooleanFilterGroup::class,
474  'filters' => [
475  [
476  'name' => 'hidepatrolled',
477  // rcshowhidepatr-show, rcshowhidepatr-hide
478  // wlshowhidepatr
479  'showHideSuffix' => 'showhidepatr',
480  'default' => false,
481  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
482  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
483  ) {
484  $conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED;
485  },
486  'isReplacedInStructuredUi' => true,
487  ],
488  [
489  'name' => 'hideunpatrolled',
490  'default' => false,
491  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
492  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
493  ) {
494  $conds[] = 'rc_patrolled != ' . RecentChange::PRC_UNPATROLLED;
495  },
496  'isReplacedInStructuredUi' => true,
497  ],
498  ],
499  ]
500  ];
501 
502  $this->reviewStatusFilterGroupDefinition = [
503  [
504  'name' => 'reviewStatus',
505  'title' => 'rcfilters-filtergroup-reviewstatus',
506  'class' => ChangesListStringOptionsFilterGroup::class,
507  'isFullCoverage' => true,
508  'priority' => -5,
509  'filters' => [
510  [
511  'name' => 'unpatrolled',
512  'label' => 'rcfilters-filter-reviewstatus-unpatrolled-label',
513  'description' => 'rcfilters-filter-reviewstatus-unpatrolled-description',
514  'cssClassSuffix' => 'reviewstatus-unpatrolled',
515  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
516  return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED;
517  },
518  ],
519  [
520  'name' => 'manual',
521  'label' => 'rcfilters-filter-reviewstatus-manual-label',
522  'description' => 'rcfilters-filter-reviewstatus-manual-description',
523  'cssClassSuffix' => 'reviewstatus-manual',
524  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
525  return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_PATROLLED;
526  },
527  ],
528  [
529  'name' => 'auto',
530  'label' => 'rcfilters-filter-reviewstatus-auto-label',
531  'description' => 'rcfilters-filter-reviewstatus-auto-description',
532  'cssClassSuffix' => 'reviewstatus-auto',
533  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
534  return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_AUTOPATROLLED;
535  },
536  ],
537  ],
539  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
540  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selected
541  ) {
542  if ( $selected === [] ) {
543  return;
544  }
545  $rcPatrolledValues = [
546  'unpatrolled' => RecentChange::PRC_UNPATROLLED,
547  'manual' => RecentChange::PRC_PATROLLED,
549  ];
550  // e.g. rc_patrolled IN (0, 2)
551  $conds['rc_patrolled'] = array_map( static function ( $s ) use ( $rcPatrolledValues ) {
552  return $rcPatrolledValues[ $s ];
553  }, $selected );
554  }
555  ]
556  ];
557 
558  $this->hideCategorizationFilterDefinition = [
559  'name' => 'hidecategorization',
560  'label' => 'rcfilters-filter-categorization-label',
561  'description' => 'rcfilters-filter-categorization-description',
562  // rcshowhidecategorization-show, rcshowhidecategorization-hide.
563  // wlshowhidecategorization
564  'showHideSuffix' => 'showhidecategorization',
565  'default' => false,
566  'priority' => -4,
567  'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
568  IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
569  ) {
570  $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_CATEGORIZE );
571  },
572  'cssClassSuffix' => 'src-mw-categorize',
573  'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
574  return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_CATEGORIZE;
575  },
576  ];
577  }
578 
584  protected function areFiltersInConflict() {
585  $opts = $this->getOptions();
586  foreach ( $this->getFilterGroups() as $group ) {
587  if ( $group->getConflictingGroups() ) {
588  wfLogWarning(
589  $group->getName() .
590  " specifies conflicts with other groups but these are not supported yet."
591  );
592  }
593 
594  foreach ( $group->getConflictingFilters() as $conflictingFilter ) {
595  if ( $conflictingFilter->activelyInConflictWithGroup( $group, $opts ) ) {
596  return true;
597  }
598  }
599 
600  foreach ( $group->getFilters() as $filter ) {
601  foreach ( $filter->getConflictingFilters() as $conflictingFilter ) {
602  if (
603  $conflictingFilter->activelyInConflictWithFilter( $filter, $opts ) &&
604  $filter->activelyInConflictWithFilter( $conflictingFilter, $opts )
605  ) {
606  return true;
607  }
608  }
609 
610  }
611 
612  }
613 
614  return false;
615  }
616 
620  public function execute( $subpage ) {
621  $this->rcSubpage = $subpage;
622 
623  $this->considerActionsForDefaultSavedQuery( $subpage );
624 
625  // Enable OOUI and module for the clock icon.
626  if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) ) {
627  $this->getOutput()->enableOOUI();
628  $this->getOutput()->addModules( 'mediawiki.special.changeslist.watchlistexpiry' );
629  }
630 
631  $opts = $this->getOptions();
632  try {
633  $rows = $this->getRows();
634  if ( $rows === false ) {
635  $rows = new FakeResultWrapper( [] );
636  }
637 
638  // Used by Structured UI app to get results without MW chrome
639  if ( $this->getRequest()->getRawVal( 'action' ) === 'render' ) {
640  $this->getOutput()->setArticleBodyOnly( true );
641  }
642 
643  // Used by "live update" and "view newest" to check
644  // if there's new changes with minimal data transfer
645  if ( $this->getRequest()->getBool( 'peek' ) ) {
646  $code = $rows->numRows() > 0 ? 200 : 204;
647  $this->getOutput()->setStatusCode( $code );
648 
649  if ( $this->getUser()->isAnon() !==
650  $this->getRequest()->getFuzzyBool( 'isAnon' )
651  ) {
652  $this->getOutput()->setStatusCode( 205 );
653  }
654 
655  return;
656  }
657 
658  $linkBatchFactory = MediaWikiServices::getInstance()->getLinkBatchFactory();
659  $batch = $linkBatchFactory->newLinkBatch();
660  foreach ( $rows as $row ) {
661  $batch->add( NS_USER, $row->rc_user_text );
662  $batch->add( NS_USER_TALK, $row->rc_user_text );
663  $batch->add( $row->rc_namespace, $row->rc_title );
664  if ( $row->rc_source === RecentChange::SRC_LOG ) {
665  $formatter = LogFormatter::newFromRow( $row );
666  foreach ( $formatter->getPreloadTitles() as $title ) {
667  $batch->addObj( $title );
668  }
669  }
670  }
671  $batch->execute();
672 
673  $this->setHeaders();
674  $this->outputHeader();
675  $this->addModules();
676  $this->webOutput( $rows, $opts );
677 
678  $rows->free();
679  } catch ( DBQueryTimeoutError $timeoutException ) {
680  MWExceptionHandler::logException( $timeoutException );
681 
682  $this->setHeaders();
683  $this->outputHeader();
684  $this->addModules();
685 
686  $this->getOutput()->setStatusCode( 500 );
687  $this->webOutputHeader( 0, $opts );
688  $this->outputTimeout();
689  }
690 
691  $this->includeRcFiltersApp();
692  }
693 
701  protected function considerActionsForDefaultSavedQuery( $subpage ) {
702  if ( !$this->isStructuredFilterUiEnabled() || $this->including() ) {
703  return;
704  }
705 
706  $knownParams = $this->getRequest()->getValues(
707  ...array_keys( $this->getOptions()->getAllValues() )
708  );
709 
710  // HACK: Temporarily until we can properly define "sticky" filters and parameters,
711  // we need to exclude several parameters we know should not be counted towards preventing
712  // the loading of defaults.
713  $excludedParams = [ 'limit' => '', 'days' => '', 'enhanced' => '', 'from' => '' ];
714  $knownParams = array_diff_key( $knownParams, $excludedParams );
715 
716  if (
717  // If there are NO known parameters in the URL request
718  // (that are not excluded) then we need to check into loading
719  // the default saved query
720  count( $knownParams ) === 0
721  ) {
722  $prefJson = MediaWikiServices::getInstance()
723  ->getUserOptionsLookup()
724  ->getOption( $this->getUser(), $this->getSavedQueriesPreferenceName() );
725 
726  // Get the saved queries data and parse it
727  $savedQueries = $prefJson ? FormatJson::decode( $prefJson, true ) : false;
728 
729  if ( $savedQueries && isset( $savedQueries[ 'default' ] ) ) {
730  // Only load queries that are 'version' 2, since those
731  // have parameter representation
732  if ( isset( $savedQueries[ 'version' ] ) && $savedQueries[ 'version' ] === '2' ) {
733  $savedQueryDefaultID = $savedQueries[ 'default' ];
734  $defaultQuery = $savedQueries[ 'queries' ][ $savedQueryDefaultID ][ 'data' ];
735 
736  // Build the entire parameter list
737  $query = array_merge(
738  $defaultQuery[ 'params' ],
739  $defaultQuery[ 'highlights' ],
740  [
741  'urlversion' => '2',
742  ]
743  );
744  // Add to the query any parameters that we may have ignored before
745  // but are still valid and requested in the URL
746  $query = array_merge( $this->getRequest()->getValues(), $query );
747  unset( $query[ 'title' ] );
748  $this->getOutput()->redirect( $this->getPageTitle( $subpage )->getCanonicalURL( $query ) );
749  } else {
750  // There's a default, but the version is not 2, and the server can't
751  // actually recognize the query itself. This happens if it is before
752  // the conversion, so we need to tell the UI to reload saved query as
753  // it does the conversion to version 2
754  $this->getOutput()->addJsConfigVars(
755  'wgStructuredChangeFiltersDefaultSavedQueryExists',
756  true
757  );
758 
759  // Add the class that tells the frontend it is still loading
760  // another query
761  $this->getOutput()->addBodyClasses( 'mw-rcfilters-ui-loading' );
762  }
763  }
764  }
765  }
766 
771  protected function getLinkDays() {
772  $linkDays = $this->getConfig()->get( MainConfigNames::RCLinkDays );
773  $filterByAge = $this->getConfig()->get( MainConfigNames::RCFilterByAge );
774  $maxAge = $this->getConfig()->get( MainConfigNames::RCMaxAge );
775  if ( $filterByAge ) {
776  // Trim it to only links which are within $wgRCMaxAge.
777  // Note that we allow one link higher than the max for things like
778  // "age 56 days" being accessible through the "60 days" link.
779  sort( $linkDays );
780 
781  $maxAgeDays = $maxAge / ( 3600 * 24 );
782  foreach ( $linkDays as $i => $days ) {
783  if ( $days >= $maxAgeDays ) {
784  array_splice( $linkDays, $i + 1 );
785  break;
786  }
787  }
788  }
789 
790  return $linkDays;
791  }
792 
799  protected function includeRcFiltersApp() {
800  $out = $this->getOutput();
801  if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
802  $jsData = $this->getStructuredFilterJsData();
803  $messages = [];
804  foreach ( $jsData['messageKeys'] as $key ) {
805  $messages[$key] = $this->msg( $key )->plain();
806  }
807 
808  $out->addBodyClasses( 'mw-rcfilters-enabled' );
809  $collapsed = MediaWikiServices::getInstance()->getUserOptionsLookup()
810  ->getBoolOption( $this->getUser(), $this->getCollapsedPreferenceName() );
811  if ( $collapsed ) {
812  $out->addBodyClasses( 'mw-rcfilters-collapsed' );
813  }
814 
815  // These config and message exports should be moved into a ResourceLoader data module (T201574)
816  $out->addJsConfigVars( 'wgStructuredChangeFilters', $jsData['groups'] );
817  $out->addJsConfigVars( 'wgStructuredChangeFiltersMessages', $messages );
818  $out->addJsConfigVars( 'wgStructuredChangeFiltersCollapsedState', $collapsed );
819 
820  $out->addJsConfigVars(
821  'StructuredChangeFiltersDisplayConfig',
822  [
823  'maxDays' => // Translate to days
824  (int)$this->getConfig()->get( MainConfigNames::RCMaxAge ) / ( 24 * 3600 ),
825  'limitArray' => $this->getConfig()->get( MainConfigNames::RCLinkLimits ),
826  'limitDefault' => $this->getDefaultLimit(),
827  'daysArray' => $this->getLinkDays(),
828  'daysDefault' => $this->getDefaultDays(),
829  ]
830  );
831 
832  $out->addJsConfigVars(
833  'wgStructuredChangeFiltersSavedQueriesPreferenceName',
835  );
836  $out->addJsConfigVars(
837  'wgStructuredChangeFiltersLimitPreferenceName',
838  $this->getLimitPreferenceName()
839  );
840  $out->addJsConfigVars(
841  'wgStructuredChangeFiltersDaysPreferenceName',
843  );
844  $out->addJsConfigVars(
845  'wgStructuredChangeFiltersCollapsedPreferenceName',
847  );
848  } else {
849  $out->addBodyClasses( 'mw-rcfilters-disabled' );
850  }
851  }
852 
861  public static function getRcFiltersConfigSummary( RL\Context $context ) {
862  $lang = MediaWikiServices::getInstance()->getLanguageFactory()
863  ->getLanguage( $context->getLanguage() );
864  return [
865  // Reduce version computation by avoiding Message parsing
866  'RCFiltersChangeTags' => ChangeTags::getChangeTagListSummary( $context, $lang ),
867  'StructuredChangeFiltersEditWatchlistUrl' =>
868  SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
869  ];
870  }
871 
879  public static function getRcFiltersConfigVars( RL\Context $context ) {
880  $lang = MediaWikiServices::getInstance()->getLanguageFactory()
881  ->getLanguage( $context->getLanguage() );
882  return [
883  'RCFiltersChangeTags' => ChangeTags::getChangeTagList( $context, $lang ),
884  'StructuredChangeFiltersEditWatchlistUrl' =>
885  SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
886  ];
887  }
888 
892  protected function outputNoResults() {
893  $this->getOutput()->addHTML(
895  'div',
896  [ 'class' => 'mw-changeslist-empty' ],
897  $this->msg( 'recentchanges-noresult' )->parse()
898  )
899  );
900  }
901 
905  protected function outputTimeout() {
906  $this->getOutput()->addHTML(
907  '<div class="mw-changeslist-empty mw-changeslist-timeout">' .
908  $this->msg( 'recentchanges-timeout' )->parse() .
909  '</div>'
910  );
911  }
912 
918  public function getRows() {
919  $opts = $this->getOptions();
920 
921  $tables = [];
922  $fields = [];
923  $conds = [];
924  $query_options = [];
925  $join_conds = [];
926  $this->buildQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
927 
928  return $this->doMainQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
929  }
930 
936  public function getOptions() {
937  if ( $this->rcOptions === null ) {
938  $this->rcOptions = $this->setup( $this->rcSubpage );
939  }
940 
941  return $this->rcOptions;
942  }
943 
953  protected function registerFilters() {
954  $this->registerFiltersFromDefinitions( $this->filterGroupDefinitions );
955 
956  // Make sure this is not being transcluded (we don't want to show this
957  // information to all users just because the user that saves the edit can
958  // patrol or is logged in)
959  if ( !$this->including() && $this->getUser()->useRCPatrol() ) {
960  $this->registerFiltersFromDefinitions( $this->legacyReviewStatusFilterGroupDefinition );
961  $this->registerFiltersFromDefinitions( $this->reviewStatusFilterGroupDefinition );
962  }
963 
964  $changeTypeGroup = $this->getFilterGroup( 'changeType' );
965 
966  if ( $this->getConfig()->get( MainConfigNames::RCWatchCategoryMembership ) ) {
967  $transformedHideCategorizationDef = $this->transformFilterDefinition(
968  $this->hideCategorizationFilterDefinition
969  );
970 
971  $transformedHideCategorizationDef['group'] = $changeTypeGroup;
972 
973  $hideCategorization = new ChangesListBooleanFilter(
974  $transformedHideCategorizationDef
975  );
976  }
977 
978  $this->getHookRunner()->onChangesListSpecialPageStructuredFilters( $this );
979 
980  $this->registerFiltersFromDefinitions( [] );
981 
982  $userExperienceLevel = $this->getFilterGroup( 'userExpLevel' );
983  $registered = $userExperienceLevel->getFilter( 'registered' );
984  $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'newcomer' ) );
985  $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'learner' ) );
986  $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'experienced' ) );
987 
988  $categoryFilter = $changeTypeGroup->getFilter( 'hidecategorization' );
989  $logactionsFilter = $changeTypeGroup->getFilter( 'hidelog' );
990  $lognewuserFilter = $changeTypeGroup->getFilter( 'hidenewuserlog' );
991  $pagecreationFilter = $changeTypeGroup->getFilter( 'hidenewpages' );
992 
993  $significanceTypeGroup = $this->getFilterGroup( 'significance' );
994  $hideMinorFilter = $significanceTypeGroup->getFilter( 'hideminor' );
995 
996  // categoryFilter is conditional; see registerFilters
997  if ( $categoryFilter !== null ) {
998  $hideMinorFilter->conflictsWith(
999  $categoryFilter,
1000  'rcfilters-hideminor-conflicts-typeofchange-global',
1001  'rcfilters-hideminor-conflicts-typeofchange',
1002  'rcfilters-typeofchange-conflicts-hideminor'
1003  );
1004  }
1005  $hideMinorFilter->conflictsWith(
1006  $logactionsFilter,
1007  'rcfilters-hideminor-conflicts-typeofchange-global',
1008  'rcfilters-hideminor-conflicts-typeofchange',
1009  'rcfilters-typeofchange-conflicts-hideminor'
1010  );
1011  $hideMinorFilter->conflictsWith(
1012  $lognewuserFilter,
1013  'rcfilters-hideminor-conflicts-typeofchange-global',
1014  'rcfilters-hideminor-conflicts-typeofchange',
1015  'rcfilters-typeofchange-conflicts-hideminor'
1016  );
1017  $hideMinorFilter->conflictsWith(
1018  $pagecreationFilter,
1019  'rcfilters-hideminor-conflicts-typeofchange-global',
1020  'rcfilters-hideminor-conflicts-typeofchange',
1021  'rcfilters-typeofchange-conflicts-hideminor'
1022  );
1023  }
1024 
1034  protected function transformFilterDefinition( array $filterDefinition ) {
1035  return $filterDefinition;
1036  }
1037 
1048  protected function registerFiltersFromDefinitions( array $definition ) {
1049  $autoFillPriority = -1;
1050  foreach ( $definition as $groupDefinition ) {
1051  if ( !isset( $groupDefinition['priority'] ) ) {
1052  $groupDefinition['priority'] = $autoFillPriority;
1053  } else {
1054  // If it's explicitly specified, start over the auto-fill
1055  $autoFillPriority = $groupDefinition['priority'];
1056  }
1057 
1058  $autoFillPriority--;
1059 
1060  $className = $groupDefinition['class'];
1061  unset( $groupDefinition['class'] );
1062 
1063  foreach ( $groupDefinition['filters'] as &$filterDefinition ) {
1064  $filterDefinition = $this->transformFilterDefinition( $filterDefinition );
1065  }
1066 
1067  $this->registerFilterGroup( new $className( $groupDefinition ) );
1068  }
1069  }
1070 
1074  protected function getLegacyShowHideFilters() {
1075  $filters = [];
1076  foreach ( $this->filterGroups as $group ) {
1077  if ( $group instanceof ChangesListBooleanFilterGroup ) {
1078  foreach ( $group->getFilters() as $key => $filter ) {
1079  if ( $filter->displaysOnUnstructuredUi() ) {
1080  $filters[ $key ] = $filter;
1081  }
1082  }
1083  }
1084  }
1085  return $filters;
1086  }
1087 
1096  public function setup( $parameters ) {
1097  $this->registerFilters();
1098 
1099  $opts = $this->getDefaultOptions();
1100 
1101  $opts = $this->fetchOptionsFromRequest( $opts );
1102 
1103  // Give precedence to subpage syntax
1104  if ( $parameters !== null ) {
1105  $this->parseParameters( $parameters, $opts );
1106  }
1107 
1108  $this->validateOptions( $opts );
1109 
1110  return $opts;
1111  }
1112 
1122  public function getDefaultOptions() {
1123  $opts = new FormOptions();
1124  $structuredUI = $this->isStructuredFilterUiEnabled();
1125  // If urlversion=2 is set, ignore the filter defaults and set them all to false/empty
1126  $useDefaults = $this->getRequest()->getInt( 'urlversion' ) !== 2;
1127 
1129  foreach ( $this->filterGroups as $filterGroup ) {
1130  $filterGroup->addOptions( $opts, $useDefaults, $structuredUI );
1131  }
1132 
1133  $opts->add( 'namespace', '', FormOptions::STRING );
1134  // TODO: Rename this option to 'invertnamespaces'?
1135  $opts->add( 'invert', false );
1136  $opts->add( 'associated', false );
1137  $opts->add( 'urlversion', 1 );
1138  $opts->add( 'tagfilter', '' );
1139  $opts->add( 'inverttags', false );
1140 
1141  $opts->add( 'days', $this->getDefaultDays(), FormOptions::FLOAT );
1142  $opts->add( 'limit', $this->getDefaultLimit(), FormOptions::INT );
1143 
1144  $opts->add( 'from', '' );
1145 
1146  return $opts;
1147  }
1148 
1154  public function registerFilterGroup( ChangesListFilterGroup $group ) {
1155  $groupName = $group->getName();
1156 
1157  $this->filterGroups[$groupName] = $group;
1158  }
1159 
1165  protected function getFilterGroups() {
1166  return $this->filterGroups;
1167  }
1168 
1176  public function getFilterGroup( $groupName ) {
1177  return $this->filterGroups[$groupName] ?? null;
1178  }
1179 
1180  // Currently, this intentionally only includes filters that display
1181  // in the structured UI. This can be changed easily, though, if we want
1182  // to include data on filters that use the unstructured UI. messageKeys is a
1183  // special top-level value, with the value being an array of the message keys to
1184  // send to the client.
1185 
1193  public function getStructuredFilterJsData() {
1194  $output = [
1195  'groups' => [],
1196  'messageKeys' => [],
1197  ];
1198 
1199  usort( $this->filterGroups, static function ( ChangesListFilterGroup $a, ChangesListFilterGroup $b ) {
1200  return $b->getPriority() <=> $a->getPriority();
1201  } );
1202 
1203  foreach ( $this->filterGroups as $group ) {
1204  $groupOutput = $group->getJsData();
1205  if ( $groupOutput !== null ) {
1206  $output['messageKeys'] = array_merge(
1207  $output['messageKeys'],
1208  $groupOutput['messageKeys']
1209  );
1210 
1211  unset( $groupOutput['messageKeys'] );
1212  $output['groups'][] = $groupOutput;
1213  }
1214  }
1215 
1216  return $output;
1217  }
1218 
1227  protected function fetchOptionsFromRequest( $opts ) {
1228  $opts->fetchValuesFromRequest( $this->getRequest() );
1229 
1230  return $opts;
1231  }
1232 
1239  public function parseParameters( $par, FormOptions $opts ) {
1240  $stringParameterNameSet = [];
1241  $hideParameterNameSet = [];
1242 
1243  // URL parameters can be per-group, like 'userExpLevel',
1244  // or per-filter, like 'hideminor'.
1245 
1246  foreach ( $this->filterGroups as $filterGroup ) {
1247  if ( $filterGroup instanceof ChangesListStringOptionsFilterGroup ) {
1248  $stringParameterNameSet[$filterGroup->getName()] = true;
1249  } elseif ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
1250  foreach ( $filterGroup->getFilters() as $filter ) {
1251  $hideParameterNameSet[$filter->getName()] = true;
1252  }
1253  }
1254  }
1255 
1256  $bits = preg_split( '/\s*,\s*/', trim( $par ) );
1257  foreach ( $bits as $bit ) {
1258  $m = [];
1259  if ( isset( $hideParameterNameSet[$bit] ) ) {
1260  // hidefoo => hidefoo=true
1261  $opts[$bit] = true;
1262  } elseif ( isset( $hideParameterNameSet["hide$bit"] ) ) {
1263  // foo => hidefoo=false
1264  $opts["hide$bit"] = false;
1265  } elseif ( preg_match( '/^(.*)=(.*)$/', $bit, $m ) ) {
1266  if ( isset( $stringParameterNameSet[$m[1]] ) ) {
1267  $opts[$m[1]] = $m[2];
1268  }
1269  }
1270  }
1271  }
1272 
1278  public function validateOptions( FormOptions $opts ) {
1279  $isContradictory = $this->fixContradictoryOptions( $opts );
1280  $isReplaced = $this->replaceOldOptions( $opts );
1281 
1282  if ( $isContradictory || $isReplaced ) {
1283  $query = wfArrayToCgi( $this->convertParamsForLink( $opts->getChangedValues() ) );
1284  $this->getOutput()->redirect( $this->getPageTitle()->getCanonicalURL( $query ) );
1285  }
1286 
1287  $opts->validateIntBounds( 'limit', 0, 5000 );
1288  $opts->validateBounds( 'days', 0,
1289  $this->getConfig()->get( MainConfigNames::RCMaxAge ) / ( 3600 * 24 ) );
1290  }
1291 
1298  private function fixContradictoryOptions( FormOptions $opts ) {
1299  $fixed = $this->fixBackwardsCompatibilityOptions( $opts );
1300 
1301  foreach ( $this->filterGroups as $filterGroup ) {
1302  if ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
1303  $filters = $filterGroup->getFilters();
1304 
1305  if ( count( $filters ) === 1 ) {
1306  // legacy boolean filters should not be considered
1307  continue;
1308  }
1309 
1310  $allInGroupEnabled = array_reduce(
1311  $filters,
1312  static function ( bool $carry, ChangesListBooleanFilter $filter ) use ( $opts ) {
1313  return $carry && $opts[ $filter->getName() ];
1314  },
1315  /* initialValue */ count( $filters ) > 0
1316  );
1317 
1318  if ( $allInGroupEnabled ) {
1319  foreach ( $filters as $filter ) {
1320  $opts[ $filter->getName() ] = false;
1321  }
1322 
1323  $fixed = true;
1324  }
1325  }
1326  }
1327 
1328  return $fixed;
1329  }
1330 
1340  private function fixBackwardsCompatibilityOptions( FormOptions $opts ) {
1341  if ( $opts['hideanons'] && $opts['hideliu'] ) {
1342  $opts->reset( 'hideanons' );
1343  if ( !$opts['hidebots'] ) {
1344  $opts->reset( 'hideliu' );
1345  $opts['hidehumans'] = 1;
1346  }
1347 
1348  return true;
1349  }
1350 
1351  return false;
1352  }
1353 
1360  public function replaceOldOptions( FormOptions $opts ) {
1361  if ( !$this->isStructuredFilterUiEnabled() ) {
1362  return false;
1363  }
1364 
1365  $changed = false;
1366 
1367  // At this point 'hideanons' and 'hideliu' cannot be both true,
1368  // because fixBackwardsCompatibilityOptions resets (at least) 'hideanons' in such case
1369  if ( $opts[ 'hideanons' ] ) {
1370  $opts->reset( 'hideanons' );
1371  $opts[ 'userExpLevel' ] = 'registered';
1372  $changed = true;
1373  }
1374 
1375  if ( $opts[ 'hideliu' ] ) {
1376  $opts->reset( 'hideliu' );
1377  $opts[ 'userExpLevel' ] = 'unregistered';
1378  $changed = true;
1379  }
1380 
1381  if ( $this->getFilterGroup( 'legacyReviewStatus' ) ) {
1382  if ( $opts[ 'hidepatrolled' ] ) {
1383  $opts->reset( 'hidepatrolled' );
1384  $opts[ 'reviewStatus' ] = 'unpatrolled';
1385  $changed = true;
1386  }
1387 
1388  if ( $opts[ 'hideunpatrolled' ] ) {
1389  $opts->reset( 'hideunpatrolled' );
1390  $opts[ 'reviewStatus' ] = implode(
1392  [ 'manual', 'auto' ]
1393  );
1394  $changed = true;
1395  }
1396  }
1397 
1398  return $changed;
1399  }
1400 
1409  protected function convertParamsForLink( $params ) {
1410  foreach ( $params as &$value ) {
1411  if ( $value === false ) {
1412  $value = '0';
1413  }
1414  }
1415  unset( $value );
1416  return $params;
1417  }
1418 
1430  protected function buildQuery( &$tables, &$fields, &$conds, &$query_options,
1431  &$join_conds, FormOptions $opts
1432  ) {
1433  $dbr = $this->getDB();
1434  $isStructuredUI = $this->isStructuredFilterUiEnabled();
1435 
1437  foreach ( $this->filterGroups as $filterGroup ) {
1438  $filterGroup->modifyQuery( $dbr, $this, $tables, $fields, $conds,
1439  $query_options, $join_conds, $opts, $isStructuredUI );
1440  }
1441 
1442  // Namespace filtering
1443  if ( $opts[ 'namespace' ] !== '' ) {
1444  $namespaces = explode( ';', $opts[ 'namespace' ] );
1445 
1446  $namespaces = $this->expandSymbolicNamespaceFilters( $namespaces );
1447 
1448  $namespaceInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
1449  $namespaces = array_filter( $namespaces, [ $namespaceInfo, 'exists' ] );
1450 
1451  if ( $namespaces !== [] ) {
1452  // Namespaces are just ints, use them as int when acting with the database
1453  $namespaces = array_map( 'intval', $namespaces );
1454 
1455  if ( $opts[ 'associated' ] ) {
1456  $associatedNamespaces = array_map(
1457  [ $namespaceInfo, 'getAssociated' ],
1458  array_filter( $namespaces, [ $namespaceInfo, 'hasTalkNamespace' ] )
1459  );
1460  $namespaces = array_unique( array_merge( $namespaces, $associatedNamespaces ) );
1461  }
1462 
1463  if ( count( $namespaces ) === 1 ) {
1464  $operator = $opts[ 'invert' ] ? '!=' : '=';
1465  $value = $dbr->addQuotes( reset( $namespaces ) );
1466  } else {
1467  $operator = $opts[ 'invert' ] ? 'NOT IN' : 'IN';
1468  sort( $namespaces );
1469  $value = '(' . $dbr->makeList( $namespaces ) . ')';
1470  }
1471  $conds[] = "rc_namespace $operator $value";
1472  }
1473  }
1474 
1475  // Calculate cutoff
1476  $cutoff_unixtime = time() - $opts['days'] * 3600 * 24;
1477  $cutoff = $dbr->timestamp( $cutoff_unixtime );
1478 
1479  $fromValid = preg_match( '/^[0-9]{14}$/', $opts['from'] );
1480  if ( $fromValid && $opts['from'] > wfTimestamp( TS_MW, $cutoff ) ) {
1481  $cutoff = $dbr->timestamp( $opts['from'] );
1482  } else {
1483  $opts->reset( 'from' );
1484  }
1485 
1486  $conds[] = 'rc_timestamp >= ' . $dbr->addQuotes( $cutoff );
1487  }
1488 
1500  protected function doMainQuery( $tables, $fields, $conds,
1501  $query_options, $join_conds, FormOptions $opts
1502  ) {
1503  $rcQuery = RecentChange::getQueryInfo();
1504  $tables = array_merge( $tables, $rcQuery['tables'] );
1505  $fields = array_merge( $rcQuery['fields'], $fields );
1506  $join_conds = array_merge( $join_conds, $rcQuery['joins'] );
1507 
1509  $tables,
1510  $fields,
1511  $conds,
1512  $join_conds,
1513  $query_options,
1514  '',
1515  $opts[ 'inverttags' ]
1516  );
1517 
1518  if (
1519  !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds, $opts )
1520  ) {
1521  return false;
1522  }
1523 
1524  $dbr = $this->getDB();
1525 
1526  return $dbr->select(
1527  $tables,
1528  $fields,
1529  $conds,
1530  __METHOD__,
1531  $query_options,
1532  $join_conds
1533  );
1534  }
1535 
1536  protected function runMainQueryHook( &$tables, &$fields, &$conds,
1537  &$query_options, &$join_conds, $opts
1538  ) {
1539  return $this->getHookRunner()->onChangesListSpecialPageQuery(
1540  $this->getName(), $tables, $fields, $conds, $query_options, $join_conds, $opts );
1541  }
1542 
1548  protected function getDB() {
1549  return wfGetDB( DB_REPLICA );
1550  }
1551 
1558  private function webOutputHeader( $rowCount, $opts ) {
1559  if ( !$this->including() ) {
1560  $this->outputFeedLinks();
1561  $this->doHeader( $opts, $rowCount );
1562  }
1563  }
1564 
1571  public function webOutput( $rows, $opts ) {
1572  $this->webOutputHeader( $rows->numRows(), $opts );
1573 
1574  $this->outputChangesList( $rows, $opts );
1575  }
1576 
1577  public function outputFeedLinks() {
1578  // nothing by default
1579  }
1580 
1587  abstract public function outputChangesList( $rows, $opts );
1588 
1595  public function doHeader( $opts, $numRows ) {
1596  $this->setTopText( $opts );
1597 
1598  // @todo Lots of stuff should be done here.
1599 
1600  $this->setBottomText( $opts );
1601  }
1602 
1610  public function setTopText( FormOptions $opts ) {
1611  // nothing by default
1612  }
1613 
1621  public function setBottomText( FormOptions $opts ) {
1622  // nothing by default
1623  }
1624 
1634  public function getExtraOptions( $opts ) {
1635  return [];
1636  }
1637 
1643  public function makeLegend() {
1644  $context = $this->getContext();
1645  $user = $context->getUser();
1646  # The legend showing what the letters and stuff mean
1647  $legend = Html::openElement( 'dl' ) . "\n";
1648  # Iterates through them and gets the messages for both letter and tooltip
1649  $legendItems = $context->getConfig()->get( MainConfigNames::RecentChangesFlags );
1650  if ( !( $user->useRCPatrol() || $user->useNPPatrol() ) ) {
1651  unset( $legendItems['unpatrolled'] );
1652  }
1653  foreach ( $legendItems as $key => $item ) { # generate items of the legend
1654  $label = $item['legend'] ?? $item['title'];
1655  $letter = $item['letter'];
1656  $cssClass = $item['class'] ?? $key;
1657 
1658  $legend .= Html::element( 'dt',
1659  [ 'class' => $cssClass ], $context->msg( $letter )->text()
1660  ) . "\n" .
1661  Html::rawElement( 'dd',
1662  [ 'class' => Sanitizer::escapeClass( 'mw-changeslist-legend-' . $key ) ],
1663  $context->msg( $label )->parse()
1664  ) . "\n";
1665  }
1666  # (+-123)
1667  $legend .= Html::rawElement( 'dt',
1668  [ 'class' => 'mw-plusminus-pos' ],
1669  $context->msg( 'recentchanges-legend-plusminus' )->parse()
1670  ) . "\n";
1671  $legend .= Html::element(
1672  'dd',
1673  [ 'class' => 'mw-changeslist-legend-plusminus' ],
1674  $context->msg( 'recentchanges-label-plusminus' )->text()
1675  ) . "\n";
1676  // Watchlist expiry clock icon.
1677  if ( $context->getConfig()->get( MainConfigNames::WatchlistExpiry ) ) {
1678  $widget = new IconWidget( [
1679  'icon' => 'clock',
1680  'classes' => [ 'mw-changesList-watchlistExpiry' ],
1681  ] );
1682  // Link the image to its label for assistive technologies.
1683  $watchlistLabelId = 'mw-changeslist-watchlistExpiry-label';
1684  $widget->getIconElement()->setAttributes( [
1685  'role' => 'img',
1686  'aria-labelledby' => $watchlistLabelId,
1687  ] );
1688  $legend .= Html::rawElement(
1689  'dt',
1690  [ 'class' => 'mw-changeslist-legend-watchlistexpiry' ],
1691  $widget
1692  );
1693  $legend .= Html::element(
1694  'dd',
1695  [ 'class' => 'mw-changeslist-legend-watchlistexpiry', 'id' => $watchlistLabelId ],
1696  $context->msg( 'recentchanges-legend-watchlistexpiry' )->text()
1697  );
1698  }
1699  $legend .= Html::closeElement( 'dl' ) . "\n";
1700 
1701  $legendHeading = $this->isStructuredFilterUiEnabled() ?
1702  $context->msg( 'rcfilters-legend-heading' )->parse() :
1703  $context->msg( 'recentchanges-legend-heading' )->parse();
1704 
1705  # Collapsible
1706  $collapsedState = $this->getRequest()->getCookie( 'changeslist-state' );
1707  $collapsedClass = $collapsedState === 'collapsed' ? 'mw-collapsed' : '';
1708 
1709  $legend = Html::rawElement(
1710  'div',
1711  [ 'class' => [ 'mw-changeslist-legend', 'mw-collapsible', $collapsedClass ] ],
1712  $legendHeading .
1713  Html::rawElement( 'div', [ 'class' => 'mw-collapsible-content' ], $legend )
1714  );
1715 
1716  return $legend;
1717  }
1718 
1722  protected function addModules() {
1723  $out = $this->getOutput();
1724  // Styles and behavior for the legend box (see makeLegend())
1725  $out->addModuleStyles( [
1726  'mediawiki.interface.helpers.styles',
1727  'mediawiki.special.changeslist.legend',
1728  'mediawiki.special.changeslist',
1729  ] );
1730  $out->addModules( 'mediawiki.special.changeslist.legend.js' );
1731 
1732  if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
1733  $out->addModules( 'mediawiki.rcfilters.filters.ui' );
1734  $out->addModuleStyles( 'mediawiki.rcfilters.filters.base.styles' );
1735  }
1736  }
1737 
1738  protected function getGroupName() {
1739  return 'changes';
1740  }
1741 
1758  public function filterOnUserExperienceLevel( $specialPageClassName, $context, $dbr,
1759  &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedExpLevels, $now = 0
1760  ) {
1761  $LEVEL_COUNT = 5;
1762 
1763  // If all levels are selected, don't filter
1764  if ( count( $selectedExpLevels ) === $LEVEL_COUNT ) {
1765  return;
1766  }
1767 
1768  // both 'registered' and 'unregistered', experience levels, if any, are included in 'registered'
1769  if (
1770  in_array( 'registered', $selectedExpLevels ) &&
1771  in_array( 'unregistered', $selectedExpLevels )
1772  ) {
1773  return;
1774  }
1775 
1776  // 'registered' but not 'unregistered', experience levels, if any, are included in 'registered'
1777  if (
1778  in_array( 'registered', $selectedExpLevels ) &&
1779  !in_array( 'unregistered', $selectedExpLevels )
1780  ) {
1781  $conds[] = 'actor_user IS NOT NULL';
1782  $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
1783  return;
1784  }
1785 
1786  if ( $selectedExpLevels === [ 'unregistered' ] ) {
1787  $conds['actor_user'] = null;
1788  $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
1789  return;
1790  }
1791 
1792  $tables[] = 'user';
1793  $join_conds['user'] = [ 'LEFT JOIN', 'actor_user=user_id' ];
1794 
1795  if ( $now === 0 ) {
1796  $now = time();
1797  }
1798  $secondsPerDay = 86400;
1799  $config = $this->getConfig();
1800  $learnerCutoff =
1801  $now - $config->get( MainConfigNames::LearnerMemberSince ) * $secondsPerDay;
1802  $experiencedUserCutoff =
1803  $now - $config->get( MainConfigNames::ExperiencedUserMemberSince ) * $secondsPerDay;
1804 
1805  $aboveNewcomer = $dbr->makeList(
1806  [
1807  'user_editcount >= ' . intval( $config->get( MainConfigNames::LearnerEdits ) ),
1808  $dbr->makeList( [
1809  'user_registration IS NULL',
1810  'user_registration <= ' . $dbr->addQuotes( $dbr->timestamp( $learnerCutoff ) ),
1811  ], IDatabase::LIST_OR ),
1812  ],
1814  );
1815 
1816  $aboveLearner = $dbr->makeList(
1817  [
1818  'user_editcount >= ' . intval( $config->get( MainConfigNames::ExperiencedUserEdits ) ),
1819  $dbr->makeList( [
1820  'user_registration IS NULL',
1821  'user_registration <= ' .
1822  $dbr->addQuotes( $dbr->timestamp( $experiencedUserCutoff ) ),
1823  ], IDatabase::LIST_OR ),
1824  ],
1826  );
1827 
1828  $conditions = [];
1829 
1830  if ( in_array( 'unregistered', $selectedExpLevels ) ) {
1831  $selectedExpLevels = array_diff( $selectedExpLevels, [ 'unregistered' ] );
1832  $conditions['actor_user'] = null;
1833  $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
1834  }
1835 
1836  if ( $selectedExpLevels === [ 'newcomer' ] ) {
1837  $conditions[] = "NOT ( $aboveNewcomer )";
1838  } elseif ( $selectedExpLevels === [ 'learner' ] ) {
1839  $conditions[] = $dbr->makeList(
1840  [ $aboveNewcomer, "NOT ( $aboveLearner )" ],
1842  );
1843  } elseif ( $selectedExpLevels === [ 'experienced' ] ) {
1844  $conditions[] = $aboveLearner;
1845  } elseif ( $selectedExpLevels === [ 'learner', 'newcomer' ] ) {
1846  $conditions[] = "NOT ( $aboveLearner )";
1847  } elseif ( $selectedExpLevels === [ 'experienced', 'newcomer' ] ) {
1848  $conditions[] = $dbr->makeList(
1849  [ "NOT ( $aboveNewcomer )", $aboveLearner ],
1851  );
1852  } elseif ( $selectedExpLevels === [ 'experienced', 'learner' ] ) {
1853  $conditions[] = $aboveNewcomer;
1854  } elseif ( $selectedExpLevels === [ 'experienced', 'learner', 'newcomer' ] ) {
1855  $conditions[] = 'actor_user IS NOT NULL';
1856  $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
1857  }
1858 
1859  if ( count( $conditions ) > 1 ) {
1860  $conds[] = $dbr->makeList( $conditions, IDatabase::LIST_OR );
1861  } elseif ( count( $conditions ) === 1 ) {
1862  $conds[] = reset( $conditions );
1863  }
1864  }
1865 
1871  public function isStructuredFilterUiEnabled() {
1872  if ( $this->getRequest()->getBool( 'rcfilters' ) ) {
1873  return true;
1874  }
1875 
1876  return static::checkStructuredFilterUiEnabled( $this->getUser() );
1877  }
1878 
1886  public static function checkStructuredFilterUiEnabled( UserIdentity $user ) {
1887  return !MediaWikiServices::getInstance()
1888  ->getUserOptionsLookup()
1889  ->getOption( $user, 'rcenhancedfilters-disable' );
1890  }
1891 
1899  public function getDefaultLimit() {
1900  return MediaWikiServices::getInstance()
1901  ->getUserOptionsLookup()
1902  ->getIntOption( $this->getUser(), $this->getLimitPreferenceName() );
1903  }
1904 
1913  public function getDefaultDays() {
1914  return floatval( MediaWikiServices::getInstance()
1915  ->getUserOptionsLookup()
1916  ->getOption( $this->getUser(), $this->getDefaultDaysPreferenceName() ) );
1917  }
1918 
1925  abstract protected function getLimitPreferenceName(): string;
1926 
1933  abstract protected function getSavedQueriesPreferenceName(): string;
1934 
1941  abstract protected function getDefaultDaysPreferenceName(): string;
1942 
1949  abstract protected function getCollapsedPreferenceName(): string;
1950 
1955  private function expandSymbolicNamespaceFilters( array $namespaces ) {
1956  $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
1957  $symbolicFilters = [
1958  'all-contents' => $nsInfo->getSubjectNamespaces(),
1959  'all-discussions' => $nsInfo->getTalkNamespaces(),
1960  ];
1961  $additionalNamespaces = [];
1962  foreach ( $symbolicFilters as $name => $values ) {
1963  if ( in_array( $name, $namespaces ) ) {
1964  $additionalNamespaces = array_merge( $additionalNamespaces, $values );
1965  }
1966  }
1967  $namespaces = array_diff( $namespaces, array_keys( $symbolicFilters ) );
1968  $namespaces = array_merge( $namespaces, $additionalNamespaces );
1969  return array_unique( $namespaces );
1970  }
1971 }
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:902
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.
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).
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: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.
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: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:26
if(!isset( $args[0])) $lang