MediaWiki  master
SpecialRecentChanges.php
Go to the documentation of this file.
1 <?php
27 
34 
35  protected static $savedQueriesPreferenceName = 'rcfilters-saved-queries';
36  protected static $daysPreferenceName = 'rcdays'; // Use general RecentChanges preference
37  protected static $limitPreferenceName = 'rcfilters-limit'; // Use RCFilters-specific preference
38  protected static $collapsedPreferenceName = 'rcfilters-rc-collapsed';
39 
41 
42  public function __construct( $name = 'Recentchanges', $restriction = '' ) {
43  parent::__construct( $name, $restriction );
44 
45  $this->watchlistFilterGroupDefinition = [
46  'name' => 'watchlist',
47  'title' => 'rcfilters-filtergroup-watchlist',
48  'class' => ChangesListStringOptionsFilterGroup::class,
49  'priority' => -9,
50  'isFullCoverage' => true,
51  'filters' => [
52  [
53  'name' => 'watched',
54  'label' => 'rcfilters-filter-watchlist-watched-label',
55  'description' => 'rcfilters-filter-watchlist-watched-description',
56  'cssClassSuffix' => 'watched',
57  'isRowApplicableCallable' => function ( $ctx, $rc ) {
58  return $rc->getAttribute( 'wl_user' );
59  }
60  ],
61  [
62  'name' => 'watchednew',
63  'label' => 'rcfilters-filter-watchlist-watchednew-label',
64  'description' => 'rcfilters-filter-watchlist-watchednew-description',
65  'cssClassSuffix' => 'watchednew',
66  'isRowApplicableCallable' => function ( $ctx, $rc ) {
67  return $rc->getAttribute( 'wl_user' ) &&
68  $rc->getAttribute( 'rc_timestamp' ) &&
69  $rc->getAttribute( 'wl_notificationtimestamp' ) &&
70  $rc->getAttribute( 'rc_timestamp' ) >= $rc->getAttribute( 'wl_notificationtimestamp' );
71  },
72  ],
73  [
74  'name' => 'notwatched',
75  'label' => 'rcfilters-filter-watchlist-notwatched-label',
76  'description' => 'rcfilters-filter-watchlist-notwatched-description',
77  'cssClassSuffix' => 'notwatched',
78  'isRowApplicableCallable' => function ( $ctx, $rc ) {
79  return $rc->getAttribute( 'wl_user' ) === null;
80  },
81  ]
82  ],
84  'queryCallable' => function ( $specialPageClassName, $context, $dbr,
85  &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedValues ) {
86  sort( $selectedValues );
87  $notwatchedCond = 'wl_user IS NULL';
88  $watchedCond = 'wl_user IS NOT NULL';
89  $newCond = 'rc_timestamp >= wl_notificationtimestamp';
90 
91  if ( $selectedValues === [ 'notwatched' ] ) {
92  $conds[] = $notwatchedCond;
93  return;
94  }
95 
96  if ( $selectedValues === [ 'watched' ] ) {
97  $conds[] = $watchedCond;
98  return;
99  }
100 
101  if ( $selectedValues === [ 'watchednew' ] ) {
102  $conds[] = $dbr->makeList( [
103  $watchedCond,
104  $newCond
105  ], LIST_AND );
106  return;
107  }
108 
109  if ( $selectedValues === [ 'notwatched', 'watched' ] ) {
110  // no filters
111  return;
112  }
113 
114  if ( $selectedValues === [ 'notwatched', 'watchednew' ] ) {
115  $conds[] = $dbr->makeList( [
116  $notwatchedCond,
117  $dbr->makeList( [
118  $watchedCond,
119  $newCond
120  ], LIST_AND )
121  ], LIST_OR );
122  return;
123  }
124 
125  if ( $selectedValues === [ 'watched', 'watchednew' ] ) {
126  $conds[] = $watchedCond;
127  return;
128  }
129 
130  if ( $selectedValues === [ 'notwatched', 'watched', 'watchednew' ] ) {
131  // no filters
132  return;
133  }
134  }
135  ];
136  }
137 
141  public function execute( $subpage ) {
142  // Backwards-compatibility: redirect to new feed URLs
143  $feedFormat = $this->getRequest()->getVal( 'feed' );
144  if ( !$this->including() && $feedFormat ) {
145  $query = $this->getFeedQuery();
146  $query['feedformat'] = $feedFormat === 'atom' ? 'atom' : 'rss';
147  $this->getOutput()->redirect( wfAppendQuery( wfScript( 'api' ), $query ) );
148 
149  return;
150  }
151 
152  // 10 seconds server-side caching max
153  $out = $this->getOutput();
154  $out->setCdnMaxage( 10 );
155  // Check if the client has a cached version
156  $lastmod = $this->checkLastModified();
157  if ( $lastmod === false ) {
158  return;
159  }
160 
161  $this->addHelpLink(
162  'https://meta.wikimedia.org/wiki/Special:MyLanguage/Help:Recent_changes',
163  true
164  );
165  parent::execute( $subpage );
166  }
167 
171  protected function transformFilterDefinition( array $filterDefinition ) {
172  if ( isset( $filterDefinition['showHideSuffix'] ) ) {
173  $filterDefinition['showHide'] = 'rc' . $filterDefinition['showHideSuffix'];
174  }
175 
176  return $filterDefinition;
177  }
178 
182  protected function registerFilters() {
183  parent::registerFilters();
184 
185  if (
186  !$this->including() &&
187  $this->getUser()->isLoggedIn() &&
188  MediaWikiServices::getInstance()
190  ->userHasRight( $this->getUser(), 'viewmywatchlist' )
191  ) {
192  $this->registerFiltersFromDefinitions( [ $this->watchlistFilterGroupDefinition ] );
193  $watchlistGroup = $this->getFilterGroup( 'watchlist' );
194  $watchlistGroup->getFilter( 'watched' )->setAsSupersetOf(
195  $watchlistGroup->getFilter( 'watchednew' )
196  );
197  }
198 
199  $user = $this->getUser();
200 
201  $significance = $this->getFilterGroup( 'significance' );
203  $hideMinor = $significance->getFilter( 'hideminor' );
204  '@phan-var ChangesListBooleanFilter $hideMinor';
205  $hideMinor->setDefault( $user->getBoolOption( 'hideminor' ) );
206 
207  $automated = $this->getFilterGroup( 'automated' );
209  $hideBots = $automated->getFilter( 'hidebots' );
210  '@phan-var ChangesListBooleanFilter $hideBots';
211  $hideBots->setDefault( true );
212 
214  $reviewStatus = $this->getFilterGroup( 'reviewStatus' );
215  '@phan-var ChangesListStringOptionsFilterGroup|null $reviewStatus';
216  if ( $reviewStatus !== null ) {
217  // Conditional on feature being available and rights
218  if ( $user->getBoolOption( 'hidepatrolled' ) ) {
219  $reviewStatus->setDefault( 'unpatrolled' );
220  $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' );
222  $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' );
223  '@phan-var ChangesListBooleanFilter $legacyHidePatrolled';
224  $legacyHidePatrolled->setDefault( true );
225  }
226  }
227 
228  $changeType = $this->getFilterGroup( 'changeType' );
230  $hideCategorization = $changeType->getFilter( 'hidecategorization' );
231  '@phan-var ChangesListBooleanFilter $hideCategorization';
232  if ( $hideCategorization !== null ) {
233  // Conditional on feature being available
234  $hideCategorization->setDefault( $user->getBoolOption( 'hidecategorization' ) );
235  }
236  }
237 
244  public function parseParameters( $par, FormOptions $opts ) {
245  parent::parseParameters( $par, $opts );
246 
247  $bits = preg_split( '/\s*,\s*/', trim( $par ) );
248  foreach ( $bits as $bit ) {
249  if ( is_numeric( $bit ) ) {
250  $opts['limit'] = $bit;
251  }
252 
253  $m = [];
254  if ( preg_match( '/^limit=(\d+)$/', $bit, $m ) ) {
255  $opts['limit'] = $m[1];
256  }
257  if ( preg_match( '/^days=(\d+(?:\.\d+)?)$/', $bit, $m ) ) {
258  $opts['days'] = $m[1];
259  }
260  if ( preg_match( '/^namespace=(.*)$/', $bit, $m ) ) {
261  $opts['namespace'] = $m[1];
262  }
263  if ( preg_match( '/^tagfilter=(.*)$/', $bit, $m ) ) {
264  $opts['tagfilter'] = $m[1];
265  }
266  }
267  }
268 
272  protected function doMainQuery( $tables, $fields, $conds, $query_options,
273  $join_conds, FormOptions $opts
274  ) {
275  $dbr = $this->getDB();
276  $user = $this->getUser();
277 
278  $rcQuery = RecentChange::getQueryInfo();
279  $tables = array_merge( $tables, $rcQuery['tables'] );
280  $fields = array_merge( $rcQuery['fields'], $fields );
281  $join_conds = array_merge( $join_conds, $rcQuery['joins'] );
282 
283  // JOIN on watchlist for users
284  if ( $user->isLoggedIn() && MediaWikiServices::getInstance()
285  ->getPermissionManager()
286  ->userHasRight( $user, 'viewmywatchlist' )
287  ) {
288  $tables[] = 'watchlist';
289  $fields[] = 'wl_user';
290  $fields[] = 'wl_notificationtimestamp';
291  $join_conds['watchlist'] = [ 'LEFT JOIN', [
292  'wl_user' => $user->getId(),
293  'wl_title=rc_title',
294  'wl_namespace=rc_namespace'
295  ] ];
296  }
297 
298  // JOIN on page, used for 'last revision' filter highlight
299  $tables[] = 'page';
300  $fields[] = 'page_latest';
301  $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
302 
303  $tagFilter = $opts['tagfilter'] ? explode( '|', $opts['tagfilter'] ) : [];
305  $tables,
306  $fields,
307  $conds,
308  $join_conds,
309  $query_options,
310  $tagFilter
311  );
312 
313  if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
314  $opts )
315  ) {
316  return false;
317  }
318 
319  if ( $this->areFiltersInConflict() ) {
320  return false;
321  }
322 
323  $orderByAndLimit = [
324  'ORDER BY' => 'rc_timestamp DESC',
325  'LIMIT' => $opts['limit']
326  ];
327  if ( in_array( 'DISTINCT', $query_options ) ) {
328  // ChangeTags::modifyDisplayQuery() adds DISTINCT when filtering on multiple tags.
329  // In order to prevent DISTINCT from causing query performance problems,
330  // we have to GROUP BY the primary key. This in turn requires us to add
331  // the primary key to the end of the ORDER BY, and the old ORDER BY to the
332  // start of the GROUP BY
333  $orderByAndLimit['ORDER BY'] = 'rc_timestamp DESC, rc_id DESC';
334  $orderByAndLimit['GROUP BY'] = 'rc_timestamp, rc_id';
335  }
336  // array_merge() is used intentionally here so that hooks can, should
337  // they so desire, override the ORDER BY / LIMIT condition(s); prior to
338  // MediaWiki 1.26 this used to use the plus operator instead, which meant
339  // that extensions weren't able to change these conditions
340  $query_options = array_merge( $orderByAndLimit, $query_options );
341  $rows = $dbr->select(
342  $tables,
343  $fields,
344  // rc_new is not an ENUM, but adding a redundant rc_new IN (0,1) gives mysql enough
345  // knowledge to use an index merge if it wants (it may use some other index though).
346  $conds + [ 'rc_new' => [ 0, 1 ] ],
347  __METHOD__,
348  $query_options,
349  $join_conds
350  );
351 
352  return $rows;
353  }
354 
355  protected function getDB() {
356  return wfGetDB( DB_REPLICA, 'recentchanges' );
357  }
358 
359  public function outputFeedLinks() {
360  $this->addFeedLinks( $this->getFeedQuery() );
361  }
362 
368  protected function getFeedQuery() {
369  $query = array_filter( $this->getOptions()->getAllValues(), function ( $value ) {
370  // API handles empty parameters in a different way
371  return $value !== '';
372  } );
373  $query['action'] = 'feedrecentchanges';
374  $feedLimit = $this->getConfig()->get( 'FeedLimit' );
375  if ( $query['limit'] > $feedLimit ) {
376  $query['limit'] = $feedLimit;
377  }
378 
379  return $query;
380  }
381 
388  public function outputChangesList( $rows, $opts ) {
389  $limit = $opts['limit'];
390 
391  $showWatcherCount = $this->getConfig()->get( 'RCShowWatchingUsers' )
392  && $this->getUser()->getOption( 'shownumberswatching' );
393  $watcherCache = [];
394 
395  $counter = 1;
396  $list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups );
397  $list->initChangesListRows( $rows );
398 
399  $userShowHiddenCats = $this->getUser()->getBoolOption( 'showhiddencats' );
400  $rclistOutput = $list->beginRecentChangesList();
401  if ( $this->isStructuredFilterUiEnabled() ) {
402  $rclistOutput .= $this->makeLegend();
403  }
404 
405  foreach ( $rows as $obj ) {
406  if ( $limit == 0 ) {
407  break;
408  }
409  $rc = RecentChange::newFromRow( $obj );
410 
411  # Skip CatWatch entries for hidden cats based on user preference
412  if (
413  $rc->getAttribute( 'rc_type' ) == RC_CATEGORIZE &&
414  !$userShowHiddenCats &&
415  $rc->getParam( 'hidden-cat' )
416  ) {
417  continue;
418  }
419 
420  $rc->counter = $counter++;
421  # Check if the page has been updated since the last visit
422  if ( $this->getConfig()->get( 'ShowUpdatedMarker' )
423  && !empty( $obj->wl_notificationtimestamp )
424  ) {
425  $rc->notificationtimestamp = ( $obj->rc_timestamp >= $obj->wl_notificationtimestamp );
426  } else {
427  $rc->notificationtimestamp = false; // Default
428  }
429  # Check the number of users watching the page
430  $rc->numberofWatchingusers = 0; // Default
431  if ( $showWatcherCount && $obj->rc_namespace >= 0 ) {
432  if ( !isset( $watcherCache[$obj->rc_namespace][$obj->rc_title] ) ) {
433  $watcherCache[$obj->rc_namespace][$obj->rc_title] =
434  MediaWikiServices::getInstance()->getWatchedItemStore()->countWatchers(
435  new TitleValue( (int)$obj->rc_namespace, $obj->rc_title )
436  );
437  }
438  $rc->numberofWatchingusers = $watcherCache[$obj->rc_namespace][$obj->rc_title];
439  }
440 
441  $changeLine = $list->recentChangesLine( $rc, !empty( $obj->wl_user ), $counter );
442  if ( $changeLine !== false ) {
443  $rclistOutput .= $changeLine;
444  --$limit;
445  }
446  }
447  $rclistOutput .= $list->endRecentChangesList();
448 
449  if ( $rows->numRows() === 0 ) {
450  $this->outputNoResults();
451  if ( !$this->including() ) {
452  $this->getOutput()->setStatusCode( 404 );
453  }
454  } else {
455  $this->getOutput()->addHTML( $rclistOutput );
456  }
457  }
458 
465  public function doHeader( $opts, $numRows ) {
466  $this->setTopText( $opts );
467 
468  $defaults = $opts->getAllValues();
469  $nondefaults = $opts->getChangedValues();
470 
471  $panel = [];
472  if ( !$this->isStructuredFilterUiEnabled() ) {
473  $panel[] = $this->makeLegend();
474  }
475  $panel[] = $this->optionsPanel( $defaults, $nondefaults, $numRows );
476  $panel[] = '<hr />';
477 
478  $extraOpts = $this->getExtraOptions( $opts );
479  $extraOptsCount = count( $extraOpts );
480  $count = 0;
481  $submit = ' ' . Xml::submitButton( $this->msg( 'recentchanges-submit' )->text() );
482 
483  $out = Xml::openElement( 'table', [ 'class' => 'mw-recentchanges-table' ] );
484  foreach ( $extraOpts as $name => $optionRow ) {
485  # Add submit button to the last row only
486  ++$count;
487  $addSubmit = ( $count === $extraOptsCount ) ? $submit : '';
488 
489  $out .= Xml::openElement( 'tr', [ 'class' => $name . 'Form' ] );
490  if ( is_array( $optionRow ) ) {
491  $out .= Xml::tags(
492  'td',
493  [ 'class' => 'mw-label mw-' . $name . '-label' ],
494  $optionRow[0]
495  );
496  $out .= Xml::tags(
497  'td',
498  [ 'class' => 'mw-input' ],
499  $optionRow[1] . $addSubmit
500  );
501  } else {
502  $out .= Xml::tags(
503  'td',
504  [ 'class' => 'mw-input', 'colspan' => 2 ],
505  $optionRow . $addSubmit
506  );
507  }
508  $out .= Xml::closeElement( 'tr' );
509  }
510  $out .= Xml::closeElement( 'table' );
511 
512  $unconsumed = $opts->getUnconsumedValues();
513  foreach ( $unconsumed as $key => $value ) {
514  $out .= Html::hidden( $key, $value );
515  }
516 
517  $t = $this->getPageTitle();
518  $out .= Html::hidden( 'title', $t->getPrefixedText() );
519  $form = Xml::tags( 'form', [ 'action' => wfScript() ], $out );
520  $panel[] = $form;
521  $panelString = implode( "\n", $panel );
522 
523  $rcoptions = Xml::fieldset(
524  $this->msg( 'recentchanges-legend' )->text(),
525  $panelString,
526  [ 'class' => 'rcoptions cloptions' ]
527  );
528 
529  // Insert a placeholder for RCFilters
530  if ( $this->isStructuredFilterUiEnabled() ) {
531  $rcfilterContainer = Html::element(
532  'div',
533  // TODO: Remove deprecated rcfilters-container class
534  [ 'class' => 'rcfilters-container mw-rcfilters-container' ]
535  );
536 
537  $loadingContainer = Html::rawElement(
538  'div',
539  [ 'class' => 'mw-rcfilters-spinner' ],
541  'div',
542  [ 'class' => 'mw-rcfilters-spinner-bounce' ]
543  )
544  );
545 
546  // Wrap both with rcfilters-head
547  $this->getOutput()->addHTML(
549  'div',
550  // TODO: Remove deprecated rcfilters-head class
551  [ 'class' => 'rcfilters-head mw-rcfilters-head' ],
552  $rcfilterContainer . $rcoptions
553  )
554  );
555 
556  // Add spinner
557  $this->getOutput()->addHTML( $loadingContainer );
558  } else {
559  $this->getOutput()->addHTML( $rcoptions );
560  }
561 
562  $this->setBottomText( $opts );
563  }
564 
570  function setTopText( FormOptions $opts ) {
571  $message = $this->msg( 'recentchangestext' )->inContentLanguage();
572  if ( !$message->isDisabled() ) {
573  $contLang = MediaWikiServices::getInstance()->getContentLanguage();
574  // Parse the message in this weird ugly way to preserve the ability to include interlanguage
575  // links in it (T172461). In the future when T66969 is resolved, perhaps we can just use
576  // $message->parse() instead. This code is copied from Message::parseText().
577  $parserOutput = MessageCache::singleton()->parse(
578  $message->plain(),
579  $this->getPageTitle(),
580  /*linestart*/true,
581  // Message class sets the interface flag to false when parsing in a language different than
582  // user language, and this is wiki content language
583  /*interface*/false,
584  $contLang
585  );
586  $content = $parserOutput->getText( [
587  'enableSectionEditLinks' => false,
588  ] );
589  // Add only metadata here (including the language links), text is added below
590  $this->getOutput()->addParserOutputMetadata( $parserOutput );
591 
592  $langAttributes = [
593  'lang' => $contLang->getHtmlCode(),
594  'dir' => $contLang->getDir(),
595  ];
596 
597  $topLinksAttributes = [ 'class' => 'mw-recentchanges-toplinks' ];
598 
599  if ( $this->isStructuredFilterUiEnabled() ) {
600  // Check whether the widget is already collapsed or expanded
601  $collapsedState = $this->getRequest()->getCookie( 'rcfilters-toplinks-collapsed-state' );
602  // Note that an empty/unset cookie means collapsed, so check for !== 'expanded'
603  $topLinksAttributes[ 'class' ] .= $collapsedState !== 'expanded' ?
604  ' mw-recentchanges-toplinks-collapsed' : '';
605 
606  $this->getOutput()->enableOOUI();
607  $contentTitle = new OOUI\ButtonWidget( [
608  'classes' => [ 'mw-recentchanges-toplinks-title' ],
609  'label' => new OOUI\HtmlSnippet( $this->msg( 'rcfilters-other-review-tools' )->parse() ),
610  'framed' => false,
611  'indicator' => $collapsedState !== 'expanded' ? 'down' : 'up',
612  'flags' => [ 'progressive' ],
613  ] );
614 
615  $contentWrapper = Html::rawElement( 'div',
616  array_merge(
617  [ 'class' => 'mw-recentchanges-toplinks-content mw-collapsible-content' ],
618  $langAttributes
619  ),
620  $content
621  );
622  $content = $contentTitle . $contentWrapper;
623  } else {
624  // Language direction should be on the top div only
625  // if the title is not there. If it is there, it's
626  // interface direction, and the language/dir attributes
627  // should be on the content itself
628  $topLinksAttributes = array_merge( $topLinksAttributes, $langAttributes );
629  }
630 
631  $this->getOutput()->addHTML(
632  Html::rawElement( 'div', $topLinksAttributes, $content )
633  );
634  }
635  }
636 
643  function getExtraOptions( $opts ) {
644  $opts->consumeValues( [
645  'namespace', 'invert', 'associated', 'tagfilter'
646  ] );
647 
648  $extraOpts = [];
649  $extraOpts['namespace'] = $this->namespaceFilterForm( $opts );
650 
652  $opts['tagfilter'], false, $this->getContext() );
653  if ( count( $tagFilter ) ) {
654  $extraOpts['tagfilter'] = $tagFilter;
655  }
656 
657  // Don't fire the hook for subclasses. (Or should we?)
658  if ( $this->getName() === 'Recentchanges' ) {
659  Hooks::run( 'SpecialRecentChangesPanel', [ &$extraOpts, $opts ] );
660  }
661 
662  return $extraOpts;
663  }
664 
668  protected function addModules() {
669  parent::addModules();
670  $out = $this->getOutput();
671  $out->addModules( 'mediawiki.special.recentchanges' );
672  }
673 
681  public function checkLastModified() {
682  $dbr = $this->getDB();
683  $lastmod = $dbr->selectField( 'recentchanges', 'MAX(rc_timestamp)', '', __METHOD__ );
684 
685  return $lastmod;
686  }
687 
694  protected function namespaceFilterForm( FormOptions $opts ) {
695  $nsSelect = Html::namespaceSelector(
696  [ 'selected' => $opts['namespace'], 'all' => '', 'in-user-lang' => true ],
697  [ 'name' => 'namespace', 'id' => 'namespace' ]
698  );
699  $nsLabel = Xml::label( $this->msg( 'namespace' )->text(), 'namespace' );
700  $attribs = [ 'class' => [ 'mw-input-with-label' ] ];
701  // Hide the checkboxes when the namespace filter is set to 'all'.
702  if ( $opts['namespace'] === '' ) {
703  $attribs['class'][] = 'mw-input-hidden';
704  }
705  $invert = Html::rawElement( 'span', $attribs, Xml::checkLabel(
706  $this->msg( 'invert' )->text(), 'invert', 'nsinvert',
707  $opts['invert'],
708  [ 'title' => $this->msg( 'tooltip-invert' )->text() ]
709  ) );
710  $associated = Html::rawElement( 'span', $attribs, Xml::checkLabel(
711  $this->msg( 'namespace_association' )->text(), 'associated', 'nsassociated',
712  $opts['associated'],
713  [ 'title' => $this->msg( 'tooltip-namespace_association' )->text() ]
714  ) );
715 
716  return [ $nsLabel, "$nsSelect $invert $associated" ];
717  }
718 
727  function filterByCategories( &$rows, FormOptions $opts ) {
728  wfDeprecated( __METHOD__, '1.31' );
729 
730  $categories = array_map( 'trim', explode( '|', $opts['categories'] ) );
731 
732  if ( $categories === [] ) {
733  return;
734  }
735 
736  # Filter categories
737  $cats = [];
738  foreach ( $categories as $cat ) {
739  $cat = trim( $cat );
740  if ( $cat == '' ) {
741  continue;
742  }
743  $cats[] = $cat;
744  }
745 
746  # Filter articles
747  $articles = [];
748  $a2r = [];
749  $rowsarr = [];
750  foreach ( $rows as $k => $r ) {
751  $nt = Title::makeTitle( $r->rc_namespace, $r->rc_title );
752  $id = $nt->getArticleID();
753  if ( $id == 0 ) {
754  continue; # Page might have been deleted...
755  }
756  if ( !in_array( $id, $articles ) ) {
757  $articles[] = $id;
758  }
759  if ( !isset( $a2r[$id] ) ) {
760  $a2r[$id] = [];
761  }
762  $a2r[$id][] = $k;
763  $rowsarr[$k] = $r;
764  }
765 
766  # Shortcut?
767  if ( $articles === [] || $cats === [] ) {
768  return;
769  }
770 
771  # Look up
772  $catFind = new CategoryFinder;
773  $catFind->seed( $articles, $cats, $opts['categories_any'] ? 'OR' : 'AND' );
774  $match = $catFind->run();
775 
776  # Filter
777  $newrows = [];
778  foreach ( $match as $id ) {
779  foreach ( $a2r[$id] as $rev ) {
780  $k = $rev;
781  $newrows[$k] = $rowsarr[$k];
782  }
783  }
784  $rows = new FakeResultWrapper( array_values( $newrows ) );
785  }
786 
796  function makeOptionsLink( $title, $override, $options, $active = false ) {
797  $params = $this->convertParamsForLink( $override + $options );
798 
799  if ( $active ) {
800  $title = new HtmlArmor( '<strong>' . htmlspecialchars( $title ) . '</strong>' );
801  }
802 
803  return $this->getLinkRenderer()->makeKnownLink( $this->getPageTitle(), $title, [
804  'data-params' => json_encode( $override ),
805  'data-keys' => implode( ',', array_keys( $override ) ),
806  ], $params );
807  }
808 
817  function optionsPanel( $defaults, $nondefaults, $numRows ) {
818  $options = $nondefaults + $defaults;
819 
820  $note = '';
821  $msg = $this->msg( 'rclegend' );
822  if ( !$msg->isDisabled() ) {
823  $note .= Html::rawElement(
824  'div',
825  [ 'class' => 'mw-rclegend' ],
826  $msg->parse()
827  );
828  }
829 
830  $lang = $this->getLanguage();
831  $user = $this->getUser();
832  $config = $this->getConfig();
833  if ( $options['from'] ) {
834  $resetLink = $this->makeOptionsLink( $this->msg( 'rclistfromreset' ),
835  [ 'from' => '' ], $nondefaults );
836 
837  $noteFromMsg = $this->msg( 'rcnotefrom' )
838  ->numParams( $options['limit'] )
839  ->params(
840  $lang->userTimeAndDate( $options['from'], $user ),
841  $lang->userDate( $options['from'], $user ),
842  $lang->userTime( $options['from'], $user )
843  )
844  ->numParams( $numRows );
845  $note .= Html::rawElement(
846  'span',
847  [ 'class' => 'rcnotefrom' ],
848  $noteFromMsg->parse()
849  ) .
850  ' ' .
852  'span',
853  [ 'class' => 'rcoptions-listfromreset' ],
854  $this->msg( 'parentheses' )->rawParams( $resetLink )->parse()
855  ) .
856  '<br />';
857  }
858 
859  # Sort data for display and make sure it's unique after we've added user data.
860  $linkLimits = $config->get( 'RCLinkLimits' );
861  $linkLimits[] = $options['limit'];
862  sort( $linkLimits );
863  $linkLimits = array_unique( $linkLimits );
864 
865  $linkDays = $this->getLinkDays();
866  $linkDays[] = $options['days'];
867  sort( $linkDays );
868  $linkDays = array_unique( $linkDays );
869 
870  // limit links
871  $cl = [];
872  foreach ( $linkLimits as $value ) {
873  $cl[] = $this->makeOptionsLink( $lang->formatNum( $value ),
874  [ 'limit' => $value ], $nondefaults, $value == $options['limit'] );
875  }
876  $cl = $lang->pipeList( $cl );
877 
878  // day links, reset 'from' to none
879  $dl = [];
880  foreach ( $linkDays as $value ) {
881  $dl[] = $this->makeOptionsLink( $lang->formatNum( $value ),
882  [ 'days' => $value, 'from' => '' ], $nondefaults, $value == $options['days'] );
883  }
884  $dl = $lang->pipeList( $dl );
885 
886  $showhide = [ 'show', 'hide' ];
887 
888  $links = [];
889 
890  foreach ( $this->getLegacyShowHideFilters() as $key => $filter ) {
891  $msg = $filter->getShowHide();
892  $linkMessage = $this->msg( $msg . '-' . $showhide[1 - $options[$key]] );
893  // Extensions can define additional filters, but don't need to define the corresponding
894  // messages. If they don't exist, just fall back to 'show' and 'hide'.
895  if ( !$linkMessage->exists() ) {
896  $linkMessage = $this->msg( $showhide[1 - $options[$key]] );
897  }
898 
899  $link = $this->makeOptionsLink( $linkMessage->text(),
900  [ $key => 1 - $options[$key] ], $nondefaults );
901 
902  $attribs = [
903  'class' => "$msg rcshowhideoption clshowhideoption",
904  'data-filter-name' => $filter->getName(),
905  ];
906 
907  if ( $filter->isFeatureAvailableOnStructuredUi( $this ) ) {
908  $attribs['data-feature-in-structured-ui'] = true;
909  }
910 
911  $links[] = Html::rawElement(
912  'span',
913  $attribs,
914  $this->msg( $msg )->rawParams( $link )->parse()
915  );
916  }
917 
918  // show from this onward link
919  $timestamp = wfTimestampNow();
920  $now = $lang->userTimeAndDate( $timestamp, $user );
921  $timenow = $lang->userTime( $timestamp, $user );
922  $datenow = $lang->userDate( $timestamp, $user );
923  $pipedLinks = '<span class="rcshowhide">' . $lang->pipeList( $links ) . '</span>';
924 
925  $rclinks = Html::rawElement(
926  'span',
927  [ 'class' => 'rclinks' ],
928  $this->msg( 'rclinks' )->rawParams( $cl, $dl, '' )->parse()
929  );
930 
931  $rclistfrom = Html::rawElement(
932  'span',
933  [ 'class' => 'rclistfrom' ],
934  $this->makeOptionsLink(
935  $this->msg( 'rclistfrom' )->plaintextParams( $now, $timenow, $datenow )->parse(),
936  [ 'from' => $timestamp, 'fromFormatted' => $now ],
937  $nondefaults
938  )
939  );
940 
941  return "{$note}$rclinks<br />$pipedLinks<br />$rclistfrom";
942  }
943 
944  public function isIncludable() {
945  return true;
946  }
947 
948  protected function getCacheTTL() {
949  return 60 * 5;
950  }
951 
952  public function getDefaultLimit() {
953  $systemPrefValue = $this->getUser()->getIntOption( 'rclimit' );
954  // Prefer the RCFilters-specific preference if RCFilters is enabled
955  if ( $this->isStructuredFilterUiEnabled() ) {
956  return $this->getUser()->getIntOption( static::$limitPreferenceName, $systemPrefValue );
957  }
958 
959  // Otherwise, use the system rclimit preference value
960  return $systemPrefValue;
961  }
962 }
static modifyDisplayQuery(&$tables, &$fields, &$conds, &$join_conds, &$options, $filter_tag='')
Applies all tags-related changes to a query.
Definition: ChangeTags.php:772
getFilterGroup( $groupName)
Gets a specified ChangesListFilterGroup by name.
Helper class to keep track of options when mixing links and form elements.
Definition: FormOptions.php:35
namespaceFilterForm(FormOptions $opts)
Creates the choose namespace selection.
const RC_CATEGORIZE
Definition: Defines.php:126
static element( $element, $attribs=[], $contents='')
Identical to rawElement(), but HTML-escapes $contents (like Xml::element()).
Definition: Html.php:231
$context
Definition: load.php:45
getContext()
Gets the context this SpecialPage is executed in.
The "CategoryFinder" class takes a list of articles, creates an internal representation of all their ...
areFiltersInConflict()
Check if filters are in conflict and guaranteed to return no results.
including( $x=null)
Whether the special page is being evaluated via transclusion.
setTopText(FormOptions $opts)
Send the text to be displayed above the options.
doMainQuery( $tables, $fields, $conds, $query_options, $join_conds, FormOptions $opts)
if(!isset( $args[0])) $lang
static rawElement( $element, $attribs=[], $contents='')
Returns an HTML element in a string.
Definition: Html.php:209
wfGetDB( $db, $groups=[], $wiki=false)
Get a Database object.
Represents a page (or page fragment) title within MediaWiki.
Definition: TitleValue.php:36
addHelpLink( $to, $overrideBaseUrl=false)
Adds help link with an icon via page indicators.
A special page that lists last changes made to the wiki.
registerFiltersFromDefinitions(array $definition)
Register filters from a definition object.
getOutput()
Get the OutputPage being used for this instance.
getOptions()
Get the current FormOptions for this request.
optionsPanel( $defaults, $nondefaults, $numRows)
Creates the options panel.
wfScript( $script='index')
Get the path to a specified script file, respecting file extensions; this is a wrapper around $wgScri...
wfAppendQuery( $url, $query)
Append a query string to an existing URL, which may or may not already have query string parameters a...
getPermissionManager()
msg( $key,... $params)
Wrapper around wfMessage that sets the current context.
Interface for type hinting (accepts WikiPage, Article, ImagePage, CategoryPage)
Definition: Page.php:29
static openElement( $element, $attribs=null)
This opens an XML element.
Definition: Xml.php:108
const LIST_AND
Definition: Defines.php:39
static fieldset( $legend=false, $content=false, $attribs=[])
Shortcut for creating fieldsets.
Definition: Xml.php:609
static submitButton( $value, $attribs=[])
Convenience function to build an HTML submit button When $wgUseMediaWikiUIEverywhere is true it will ...
Definition: Xml.php:459
wfTimestampNow()
Convenience function; returns MediaWiki timestamp for the present time.
static tags( $element, $attribs, $contents)
Same as Xml::element(), but does not escape contents.
Definition: Xml.php:130
$filter
setBottomText(FormOptions $opts)
Send the text to be displayed after the options.
static label( $label, $id, $attribs=[])
Convenience function to build an HTML form label.
Definition: Xml.php:358
static newFromContext(IContextSource $context, array $groups=[])
Fetch an appropriate changes list class for the specified context Some users might want to use an enh...
Definition: ChangesList.php:87
parseParameters( $par, FormOptions $opts)
Process $par and put options found in $opts.
const LIST_OR
Definition: Defines.php:42
static makeTitle( $ns, $title, $fragment='', $interwiki='')
Create a new Title from a namespace index and a DB key.
Definition: Title.php:586
addFeedLinks( $params)
Adds RSS/atom links.
outputNoResults()
Add the "no results" message to the output.
outputChangesList( $rows, $opts)
Build and output the actual changes list.
convertParamsForLink( $params)
Convert parameters values from true/false to 1/0 so they are not omitted by wfArrayToCgi() T38524...
static closeElement( $element)
Shortcut to close an XML element.
Definition: Xml.php:117
isStructuredFilterUiEnabled()
Check whether the structured filter UI is enabled.
getName()
Get the name of this Special Page.
const NONE
Signifies that no options in the group are selected, meaning the group has no effect.
Special page which uses a ChangesList to show query results.
seed( $articleIds, $categories, $mode='AND', $maxdepth=-1)
Initializes the instance.
Overloads the relevant methods of the real ResultsWrapper so it doesn&#39;t go anywhere near an actual da...
__construct( $name='Recentchanges', $restriction='')
getUser()
Shortcut to get the User executing this instance.
static hidden( $name, $value, array $attribs=[])
Convenience function to produce an input element with type=hidden.
Definition: Html.php:802
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Throws a warning that $function is deprecated.
getConfig()
Shortcut to get main config object.
runMainQueryHook(&$tables, &$fields, &$conds, &$query_options, &$join_conds, $opts)
getLanguage()
Shortcut to get user&#39;s language.
static getQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new recentchanges object...
const DB_REPLICA
Definition: defines.php:25
$content
Definition: router.php:78
static checkLabel( $label, $name, $id, $checked=false, $attribs=[])
Convenience function to build an HTML checkbox with a label.
Definition: Xml.php:419
getRequest()
Get the WebRequest being used for this instance.
getFeedQuery()
Get URL query parameters for action=feedrecentchanges API feed of current recent changes view...
makeLegend()
Return the legend displayed within the fieldset.
static buildTagFilterSelector( $selected='', $ooui=false, IContextSource $context=null)
Build a text box to select a change tag.
Definition: ChangeTags.php:874
getExtraOptions( $opts)
Get options to be displayed in a form.
filterByCategories(&$rows, FormOptions $opts)
Filter $rows by categories set in $opts.
addModules()
Add page-specific modules.
transformFilterDefinition(array $filterDefinition)
getPageTitle( $subpage=false)
Get a self-referential title object.
static singleton()
Get the singleton instance of this class.
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:200
doHeader( $opts, $numRows)
Set the text to be displayed above the changes.
checkLastModified()
Get last modified date, for client caching Don&#39;t use this if we are using the patrol feature...
makeOptionsLink( $title, $override, $options, $active=false)
Makes change an option link which carries all the other options.
static newFromRow( $row)
static namespaceSelector(array $params=[], array $selectAttribs=[])
Build a drop-down box for selecting a namespace.
Definition: Html.php:892