MediaWiki REL1_34
ChangesListSpecialPage.php
Go to the documentation of this file.
1<?php
30
37abstract class ChangesListSpecialPage extends SpecialPage {
43
49
54 protected static $daysPreferenceName;
55
60 protected static $limitPreferenceName;
61
66 protected static $collapsedPreferenceName;
67
69 protected $rcSubpage;
70
72 protected $rcOptions;
73
74 // Order of both groups and filters is significant; first is top-most priority,
75 // descending from there.
76 // 'showHideSuffix' is a shortcut to and avoid spelling out
77 // details specific to subclasses here.
91
92 // Same format as filterGroupDefinitions, but for a single group (reviewStatus)
93 // that is registered conditionally.
95
96 // Single filter group registered conditionally
98
99 // Single filter group registered conditionally
101
108 protected $filterGroups = [];
109
110 public function __construct( $name, $restriction ) {
111 parent::__construct( $name, $restriction );
112
113 $nonRevisionTypes = [ RC_LOG ];
114 Hooks::run( 'SpecialWatchlistGetNonRevisionTypes', [ &$nonRevisionTypes ] );
115
116 $this->filterGroupDefinitions = [
117 [
118 'name' => 'registration',
119 'title' => 'rcfilters-filtergroup-registration',
120 'class' => ChangesListBooleanFilterGroup::class,
121 'filters' => [
122 [
123 'name' => 'hideliu',
124 // rcshowhideliu-show, rcshowhideliu-hide,
125 // wlshowhideliu
126 'showHideSuffix' => 'showhideliu',
127 'default' => false,
128 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
129 &$query_options, &$join_conds
130 ) {
131 $actorMigration = ActorMigration::newMigration();
132 $actorQuery = $actorMigration->getJoin( 'rc_user' );
133 $tables += $actorQuery['tables'];
134 $join_conds += $actorQuery['joins'];
135 $conds[] = $actorMigration->isAnon( $actorQuery['fields']['rc_user'] );
136 },
137 'isReplacedInStructuredUi' => true,
138
139 ],
140 [
141 'name' => 'hideanons',
142 // rcshowhideanons-show, rcshowhideanons-hide,
143 // wlshowhideanons
144 'showHideSuffix' => 'showhideanons',
145 'default' => false,
146 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
147 &$query_options, &$join_conds
148 ) {
149 $actorMigration = ActorMigration::newMigration();
150 $actorQuery = $actorMigration->getJoin( 'rc_user' );
151 $tables += $actorQuery['tables'];
152 $join_conds += $actorQuery['joins'];
153 $conds[] = $actorMigration->isNotAnon( $actorQuery['fields']['rc_user'] );
154 },
155 'isReplacedInStructuredUi' => true,
156 ]
157 ],
158 ],
159
160 [
161 'name' => 'userExpLevel',
162 'title' => 'rcfilters-filtergroup-user-experience-level',
163 'class' => ChangesListStringOptionsFilterGroup::class,
164 'isFullCoverage' => true,
165 'filters' => [
166 [
167 'name' => 'unregistered',
168 'label' => 'rcfilters-filter-user-experience-level-unregistered-label',
169 'description' => 'rcfilters-filter-user-experience-level-unregistered-description',
170 'cssClassSuffix' => 'user-unregistered',
171 'isRowApplicableCallable' => function ( $ctx, $rc ) {
172 return !$rc->getAttribute( 'rc_user' );
173 }
174 ],
175 [
176 'name' => 'registered',
177 'label' => 'rcfilters-filter-user-experience-level-registered-label',
178 'description' => 'rcfilters-filter-user-experience-level-registered-description',
179 'cssClassSuffix' => 'user-registered',
180 'isRowApplicableCallable' => function ( $ctx, $rc ) {
181 return $rc->getAttribute( 'rc_user' );
182 }
183 ],
184 [
185 'name' => 'newcomer',
186 'label' => 'rcfilters-filter-user-experience-level-newcomer-label',
187 'description' => 'rcfilters-filter-user-experience-level-newcomer-description',
188 'cssClassSuffix' => 'user-newcomer',
189 'isRowApplicableCallable' => function ( $ctx, $rc ) {
190 $performer = $rc->getPerformer();
191 return $performer && $performer->isLoggedIn() &&
192 $performer->getExperienceLevel() === 'newcomer';
193 }
194 ],
195 [
196 'name' => 'learner',
197 'label' => 'rcfilters-filter-user-experience-level-learner-label',
198 'description' => 'rcfilters-filter-user-experience-level-learner-description',
199 'cssClassSuffix' => 'user-learner',
200 'isRowApplicableCallable' => function ( $ctx, $rc ) {
201 $performer = $rc->getPerformer();
202 return $performer && $performer->isLoggedIn() &&
203 $performer->getExperienceLevel() === 'learner';
204 },
205 ],
206 [
207 'name' => 'experienced',
208 'label' => 'rcfilters-filter-user-experience-level-experienced-label',
209 'description' => 'rcfilters-filter-user-experience-level-experienced-description',
210 'cssClassSuffix' => 'user-experienced',
211 'isRowApplicableCallable' => function ( $ctx, $rc ) {
212 $performer = $rc->getPerformer();
213 return $performer && $performer->isLoggedIn() &&
214 $performer->getExperienceLevel() === 'experienced';
215 },
216 ]
217 ],
219 'queryCallable' => [ $this, 'filterOnUserExperienceLevel' ],
220 ],
221
222 [
223 'name' => 'authorship',
224 'title' => 'rcfilters-filtergroup-authorship',
225 'class' => ChangesListBooleanFilterGroup::class,
226 'filters' => [
227 [
228 'name' => 'hidemyself',
229 'label' => 'rcfilters-filter-editsbyself-label',
230 'description' => 'rcfilters-filter-editsbyself-description',
231 // rcshowhidemine-show, rcshowhidemine-hide,
232 // wlshowhidemine
233 'showHideSuffix' => 'showhidemine',
234 'default' => false,
235 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
236 &$query_options, &$join_conds
237 ) {
238 $actorQuery = ActorMigration::newMigration()->getWhere( $dbr, 'rc_user', $ctx->getUser() );
239 $tables += $actorQuery['tables'];
240 $join_conds += $actorQuery['joins'];
241 $conds[] = 'NOT(' . $actorQuery['conds'] . ')';
242 },
243 'cssClassSuffix' => 'self',
244 'isRowApplicableCallable' => function ( $ctx, $rc ) {
245 return $ctx->getUser()->equals( $rc->getPerformer() );
246 },
247 ],
248 [
249 'name' => 'hidebyothers',
250 'label' => 'rcfilters-filter-editsbyother-label',
251 'description' => 'rcfilters-filter-editsbyother-description',
252 'default' => false,
253 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
254 &$query_options, &$join_conds
255 ) {
256 $actorQuery = ActorMigration::newMigration()
257 ->getWhere( $dbr, 'rc_user', $ctx->getUser(), false );
258 $tables += $actorQuery['tables'];
259 $join_conds += $actorQuery['joins'];
260 $conds[] = $actorQuery['conds'];
261 },
262 'cssClassSuffix' => 'others',
263 'isRowApplicableCallable' => function ( $ctx, $rc ) {
264 return !$ctx->getUser()->equals( $rc->getPerformer() );
265 },
266 ]
267 ]
268 ],
269
270 [
271 'name' => 'automated',
272 'title' => 'rcfilters-filtergroup-automated',
273 'class' => ChangesListBooleanFilterGroup::class,
274 'filters' => [
275 [
276 'name' => 'hidebots',
277 'label' => 'rcfilters-filter-bots-label',
278 'description' => 'rcfilters-filter-bots-description',
279 // rcshowhidebots-show, rcshowhidebots-hide,
280 // wlshowhidebots
281 'showHideSuffix' => 'showhidebots',
282 'default' => false,
283 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
284 &$query_options, &$join_conds
285 ) {
286 $conds['rc_bot'] = 0;
287 },
288 'cssClassSuffix' => 'bot',
289 'isRowApplicableCallable' => function ( $ctx, $rc ) {
290 return $rc->getAttribute( 'rc_bot' );
291 },
292 ],
293 [
294 'name' => 'hidehumans',
295 'label' => 'rcfilters-filter-humans-label',
296 'description' => 'rcfilters-filter-humans-description',
297 'default' => false,
298 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
299 &$query_options, &$join_conds
300 ) {
301 $conds['rc_bot'] = 1;
302 },
303 'cssClassSuffix' => 'human',
304 'isRowApplicableCallable' => function ( $ctx, $rc ) {
305 return !$rc->getAttribute( 'rc_bot' );
306 },
307 ]
308 ]
309 ],
310
311 // significance (conditional)
312
313 [
314 'name' => 'significance',
315 'title' => 'rcfilters-filtergroup-significance',
316 'class' => ChangesListBooleanFilterGroup::class,
317 'priority' => -6,
318 'filters' => [
319 [
320 'name' => 'hideminor',
321 'label' => 'rcfilters-filter-minor-label',
322 'description' => 'rcfilters-filter-minor-description',
323 // rcshowhideminor-show, rcshowhideminor-hide,
324 // wlshowhideminor
325 'showHideSuffix' => 'showhideminor',
326 'default' => false,
327 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
328 &$query_options, &$join_conds
329 ) {
330 $conds[] = 'rc_minor = 0';
331 },
332 'cssClassSuffix' => 'minor',
333 'isRowApplicableCallable' => function ( $ctx, $rc ) {
334 return $rc->getAttribute( 'rc_minor' );
335 }
336 ],
337 [
338 'name' => 'hidemajor',
339 'label' => 'rcfilters-filter-major-label',
340 'description' => 'rcfilters-filter-major-description',
341 'default' => false,
342 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
343 &$query_options, &$join_conds
344 ) {
345 $conds[] = 'rc_minor = 1';
346 },
347 'cssClassSuffix' => 'major',
348 'isRowApplicableCallable' => function ( $ctx, $rc ) {
349 return !$rc->getAttribute( 'rc_minor' );
350 }
351 ]
352 ]
353 ],
354
355 [
356 'name' => 'lastRevision',
357 'title' => 'rcfilters-filtergroup-lastrevision',
358 'class' => ChangesListBooleanFilterGroup::class,
359 'priority' => -7,
360 'filters' => [
361 [
362 'name' => 'hidelastrevision',
363 'label' => 'rcfilters-filter-lastrevision-label',
364 'description' => 'rcfilters-filter-lastrevision-description',
365 'default' => false,
366 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
367 &$query_options, &$join_conds ) use ( $nonRevisionTypes ) {
368 $conds[] = $dbr->makeList(
369 [
370 'rc_this_oldid <> page_latest',
371 'rc_type' => $nonRevisionTypes,
372 ],
373 LIST_OR
374 );
375 },
376 'cssClassSuffix' => 'last',
377 'isRowApplicableCallable' => function ( $ctx, $rc ) {
378 return $rc->getAttribute( 'rc_this_oldid' ) === $rc->getAttribute( 'page_latest' );
379 }
380 ],
381 [
382 'name' => 'hidepreviousrevisions',
383 'label' => 'rcfilters-filter-previousrevision-label',
384 'description' => 'rcfilters-filter-previousrevision-description',
385 'default' => false,
386 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
387 &$query_options, &$join_conds ) use ( $nonRevisionTypes ) {
388 $conds[] = $dbr->makeList(
389 [
390 'rc_this_oldid = page_latest',
391 'rc_type' => $nonRevisionTypes,
392 ],
393 LIST_OR
394 );
395 },
396 'cssClassSuffix' => 'previous',
397 'isRowApplicableCallable' => function ( $ctx, $rc ) {
398 return $rc->getAttribute( 'rc_this_oldid' ) !== $rc->getAttribute( 'page_latest' );
399 }
400 ]
401 ]
402 ],
403
404 // With extensions, there can be change types that will not be hidden by any of these.
405 [
406 'name' => 'changeType',
407 'title' => 'rcfilters-filtergroup-changetype',
408 'class' => ChangesListBooleanFilterGroup::class,
409 'priority' => -8,
410 'filters' => [
411 [
412 'name' => 'hidepageedits',
413 'label' => 'rcfilters-filter-pageedits-label',
414 'description' => 'rcfilters-filter-pageedits-description',
415 'default' => false,
416 'priority' => -2,
417 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
418 &$query_options, &$join_conds
419 ) {
420 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_EDIT );
421 },
422 'cssClassSuffix' => 'src-mw-edit',
423 'isRowApplicableCallable' => function ( $ctx, $rc ) {
424 return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_EDIT;
425 },
426 ],
427 [
428 'name' => 'hidenewpages',
429 'label' => 'rcfilters-filter-newpages-label',
430 'description' => 'rcfilters-filter-newpages-description',
431 'default' => false,
432 'priority' => -3,
433 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
434 &$query_options, &$join_conds
435 ) {
436 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_NEW );
437 },
438 'cssClassSuffix' => 'src-mw-new',
439 'isRowApplicableCallable' => function ( $ctx, $rc ) {
440 return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_NEW;
441 },
442 ],
443
444 // hidecategorization
445
446 [
447 'name' => 'hidelog',
448 'label' => 'rcfilters-filter-logactions-label',
449 'description' => 'rcfilters-filter-logactions-description',
450 'default' => false,
451 'priority' => -5,
452 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
453 &$query_options, &$join_conds
454 ) {
455 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_LOG );
456 },
457 'cssClassSuffix' => 'src-mw-log',
458 'isRowApplicableCallable' => function ( $ctx, $rc ) {
459 return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_LOG;
460 }
461 ],
462 ],
463 ],
464
465 ];
466
467 $this->legacyReviewStatusFilterGroupDefinition = [
468 [
469 'name' => 'legacyReviewStatus',
470 'title' => 'rcfilters-filtergroup-reviewstatus',
471 'class' => ChangesListBooleanFilterGroup::class,
472 'filters' => [
473 [
474 'name' => 'hidepatrolled',
475 // rcshowhidepatr-show, rcshowhidepatr-hide
476 // wlshowhidepatr
477 'showHideSuffix' => 'showhidepatr',
478 'default' => false,
479 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
480 &$query_options, &$join_conds
481 ) {
482 $conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED;
483 },
484 'isReplacedInStructuredUi' => true,
485 ],
486 [
487 'name' => 'hideunpatrolled',
488 'default' => false,
489 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
490 &$query_options, &$join_conds
491 ) {
492 $conds[] = 'rc_patrolled != ' . RecentChange::PRC_UNPATROLLED;
493 },
494 'isReplacedInStructuredUi' => true,
495 ],
496 ],
497 ]
498 ];
499
500 $this->reviewStatusFilterGroupDefinition = [
501 [
502 'name' => 'reviewStatus',
503 'title' => 'rcfilters-filtergroup-reviewstatus',
504 'class' => ChangesListStringOptionsFilterGroup::class,
505 'isFullCoverage' => true,
506 'priority' => -5,
507 'filters' => [
508 [
509 'name' => 'unpatrolled',
510 'label' => 'rcfilters-filter-reviewstatus-unpatrolled-label',
511 'description' => 'rcfilters-filter-reviewstatus-unpatrolled-description',
512 'cssClassSuffix' => 'reviewstatus-unpatrolled',
513 'isRowApplicableCallable' => function ( $ctx, $rc ) {
514 return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED;
515 },
516 ],
517 [
518 'name' => 'manual',
519 'label' => 'rcfilters-filter-reviewstatus-manual-label',
520 'description' => 'rcfilters-filter-reviewstatus-manual-description',
521 'cssClassSuffix' => 'reviewstatus-manual',
522 'isRowApplicableCallable' => function ( $ctx, $rc ) {
523 return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_PATROLLED;
524 },
525 ],
526 [
527 'name' => 'auto',
528 'label' => 'rcfilters-filter-reviewstatus-auto-label',
529 'description' => 'rcfilters-filter-reviewstatus-auto-description',
530 'cssClassSuffix' => 'reviewstatus-auto',
531 'isRowApplicableCallable' => function ( $ctx, $rc ) {
532 return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_AUTOPATROLLED;
533 },
534 ],
535 ],
537 'queryCallable' => function ( $specialPageClassName, $ctx, $dbr,
538 &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selected
539 ) {
540 if ( $selected === [] ) {
541 return;
542 }
543 $rcPatrolledValues = [
544 'unpatrolled' => RecentChange::PRC_UNPATROLLED,
545 'manual' => RecentChange::PRC_PATROLLED,
546 'auto' => RecentChange::PRC_AUTOPATROLLED,
547 ];
548 // e.g. rc_patrolled IN (0, 2)
549 $conds['rc_patrolled'] = array_map( function ( $s ) use ( $rcPatrolledValues ) {
550 return $rcPatrolledValues[ $s ];
551 }, $selected );
552 }
553 ]
554 ];
555
556 $this->hideCategorizationFilterDefinition = [
557 'name' => 'hidecategorization',
558 'label' => 'rcfilters-filter-categorization-label',
559 'description' => 'rcfilters-filter-categorization-description',
560 // rcshowhidecategorization-show, rcshowhidecategorization-hide.
561 // wlshowhidecategorization
562 'showHideSuffix' => 'showhidecategorization',
563 'default' => false,
564 'priority' => -4,
565 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
566 &$query_options, &$join_conds
567 ) {
568 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_CATEGORIZE );
569 },
570 'cssClassSuffix' => 'src-mw-categorize',
571 'isRowApplicableCallable' => function ( $ctx, $rc ) {
572 return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_CATEGORIZE;
573 },
574 ];
575 }
576
582 protected function areFiltersInConflict() {
583 $opts = $this->getOptions();
585 foreach ( $this->getFilterGroups() as $group ) {
586 if ( $group->getConflictingGroups() ) {
588 $group->getName() .
589 " specifies conflicts with other groups but these are not supported yet."
590 );
591 }
592
594 foreach ( $group->getConflictingFilters() as $conflictingFilter ) {
595 if ( $conflictingFilter->activelyInConflictWithGroup( $group, $opts ) ) {
596 return true;
597 }
598 }
599
601 foreach ( $group->getFilters() as $filter ) {
603 foreach ( $filter->getConflictingFilters() as $conflictingFilter ) {
604 if (
605 $conflictingFilter->activelyInConflictWithFilter( $filter, $opts ) &&
606 $filter->activelyInConflictWithFilter( $conflictingFilter, $opts )
607 ) {
608 return true;
609 }
610 }
611
612 }
613
614 }
615
616 return false;
617 }
618
622 public function execute( $subpage ) {
623 $this->rcSubpage = $subpage;
624
625 $this->considerActionsForDefaultSavedQuery( $subpage );
626
627 $opts = $this->getOptions();
628 try {
629 $rows = $this->getRows();
630 if ( $rows === false ) {
631 $rows = new FakeResultWrapper( [] );
632 }
633
634 // Used by Structured UI app to get results without MW chrome
635 if ( $this->getRequest()->getVal( 'action' ) === 'render' ) {
636 $this->getOutput()->setArticleBodyOnly( true );
637 }
638
639 // Used by "live update" and "view newest" to check
640 // if there's new changes with minimal data transfer
641 if ( $this->getRequest()->getBool( 'peek' ) ) {
642 $code = $rows->numRows() > 0 ? 200 : 204;
643 $this->getOutput()->setStatusCode( $code );
644
645 if ( $this->getUser()->isAnon() !==
646 $this->getRequest()->getFuzzyBool( 'isAnon' )
647 ) {
648 $this->getOutput()->setStatusCode( 205 );
649 }
650
651 return;
652 }
653
654 $batch = new LinkBatch;
655 foreach ( $rows as $row ) {
656 $batch->add( NS_USER, $row->rc_user_text );
657 $batch->add( NS_USER_TALK, $row->rc_user_text );
658 $batch->add( $row->rc_namespace, $row->rc_title );
659 if ( $row->rc_source === RecentChange::SRC_LOG ) {
660 $formatter = LogFormatter::newFromRow( $row );
661 foreach ( $formatter->getPreloadTitles() as $title ) {
662 $batch->addObj( $title );
663 }
664 }
665 }
666 $batch->execute();
667
668 $this->setHeaders();
669 $this->outputHeader();
670 $this->addModules();
671 $this->webOutput( $rows, $opts );
672
673 $rows->free();
674 } catch ( DBQueryTimeoutError $timeoutException ) {
675 MWExceptionHandler::logException( $timeoutException );
676
677 $this->setHeaders();
678 $this->outputHeader();
679 $this->addModules();
680
681 $this->getOutput()->setStatusCode( 500 );
682 $this->webOutputHeader( 0, $opts );
683 $this->outputTimeout();
684 }
685
686 if ( $this->getConfig()->get( 'EnableWANCacheReaper' ) ) {
687 // Clean up any bad page entries for titles showing up in RC
688 DeferredUpdates::addUpdate( new WANCacheReapUpdate(
689 $this->getDB(),
690 LoggerFactory::getInstance( 'objectcache' )
691 ) );
692 }
693
694 $this->includeRcFiltersApp();
695 }
696
704 protected function considerActionsForDefaultSavedQuery( $subpage ) {
705 if ( !$this->isStructuredFilterUiEnabled() || $this->including() ) {
706 return;
707 }
708
709 $knownParams = $this->getRequest()->getValues(
710 ...array_keys( $this->getOptions()->getAllValues() )
711 );
712
713 // HACK: Temporarily until we can properly define "sticky" filters and parameters,
714 // we need to exclude several parameters we know should not be counted towards preventing
715 // the loading of defaults.
716 $excludedParams = [ 'limit' => '', 'days' => '', 'enhanced' => '', 'from' => '' ];
717 $knownParams = array_diff_key( $knownParams, $excludedParams );
718
719 if (
720 // If there are NO known parameters in the URL request
721 // (that are not excluded) then we need to check into loading
722 // the default saved query
723 count( $knownParams ) === 0
724 ) {
725 // Get the saved queries data and parse it
726 $savedQueries = FormatJson::decode(
727 $this->getUser()->getOption( static::$savedQueriesPreferenceName ),
728 true
729 );
730
731 if ( $savedQueries && isset( $savedQueries[ 'default' ] ) ) {
732 // Only load queries that are 'version' 2, since those
733 // have parameter representation
734 if ( isset( $savedQueries[ 'version' ] ) && $savedQueries[ 'version' ] === '2' ) {
735 $savedQueryDefaultID = $savedQueries[ 'default' ];
736 $defaultQuery = $savedQueries[ 'queries' ][ $savedQueryDefaultID ][ 'data' ];
737
738 // Build the entire parameter list
739 $query = array_merge(
740 $defaultQuery[ 'params' ],
741 $defaultQuery[ 'highlights' ],
742 [
743 'urlversion' => '2',
744 ]
745 );
746 // Add to the query any parameters that we may have ignored before
747 // but are still valid and requested in the URL
748 $query = array_merge( $this->getRequest()->getValues(), $query );
749 unset( $query[ 'title' ] );
750 $this->getOutput()->redirect( $this->getPageTitle( $subpage )->getCanonicalURL( $query ) );
751 } else {
752 // There's a default, but the version is not 2, and the server can't
753 // actually recognize the query itself. This happens if it is before
754 // the conversion, so we need to tell the UI to reload saved query as
755 // it does the conversion to version 2
756 $this->getOutput()->addJsConfigVars(
757 'wgStructuredChangeFiltersDefaultSavedQueryExists',
758 true
759 );
760
761 // Add the class that tells the frontend it is still loading
762 // another query
763 $this->getOutput()->addBodyClasses( 'mw-rcfilters-ui-loading' );
764 }
765 }
766 }
767 }
768
774 protected function getLinkDays() {
775 $linkDays = $this->getConfig()->get( 'RCLinkDays' );
776 $filterByAge = $this->getConfig()->get( 'RCFilterByAge' );
777 $maxAge = $this->getConfig()->get( 'RCMaxAge' );
778 if ( $filterByAge ) {
779 // Trim it to only links which are within $wgRCMaxAge.
780 // Note that we allow one link higher than the max for things like
781 // "age 56 days" being accessible through the "60 days" link.
782 sort( $linkDays );
783
784 $maxAgeDays = $maxAge / ( 3600 * 24 );
785 foreach ( $linkDays as $i => $days ) {
786 if ( $days >= $maxAgeDays ) {
787 array_splice( $linkDays, $i + 1 );
788 break;
789 }
790 }
791 }
792
793 return $linkDays;
794 }
795
802 protected function includeRcFiltersApp() {
803 $out = $this->getOutput();
804 if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
805 $jsData = $this->getStructuredFilterJsData();
806 $messages = [];
807 foreach ( $jsData['messageKeys'] as $key ) {
808 $messages[$key] = $this->msg( $key )->plain();
809 }
810
811 $out->addBodyClasses( 'mw-rcfilters-enabled' );
812 $collapsed = $this->getUser()->getBoolOption( static::$collapsedPreferenceName );
813 if ( $collapsed ) {
814 $out->addBodyClasses( 'mw-rcfilters-collapsed' );
815 }
816
817 // These config and message exports should be moved into a ResourceLoader data module (T201574)
818 $out->addJsConfigVars( 'wgStructuredChangeFilters', $jsData['groups'] );
819 $out->addJsConfigVars( 'wgStructuredChangeFiltersMessages', $messages );
820 $out->addJsConfigVars( 'wgStructuredChangeFiltersCollapsedState', $collapsed );
821
822 $out->addJsConfigVars(
823 'StructuredChangeFiltersDisplayConfig',
824 [
825 'maxDays' => (int)$this->getConfig()->get( 'RCMaxAge' ) / ( 24 * 3600 ), // Translate to days
826 'limitArray' => $this->getConfig()->get( 'RCLinkLimits' ),
827 'limitDefault' => $this->getDefaultLimit(),
828 'daysArray' => $this->getLinkDays(),
829 'daysDefault' => $this->getDefaultDays(),
830 ]
831 );
832
833 $out->addJsConfigVars(
834 'wgStructuredChangeFiltersSavedQueriesPreferenceName',
835 static::$savedQueriesPreferenceName
836 );
837 $out->addJsConfigVars(
838 'wgStructuredChangeFiltersLimitPreferenceName',
839 static::$limitPreferenceName
840 );
841 $out->addJsConfigVars(
842 'wgStructuredChangeFiltersDaysPreferenceName',
843 static::$daysPreferenceName
844 );
845 $out->addJsConfigVars(
846 'wgStructuredChangeFiltersCollapsedPreferenceName',
847 static::$collapsedPreferenceName
848 );
849 } else {
850 $out->addBodyClasses( 'mw-rcfilters-disabled' );
851 }
852 }
853
863 return [
864 // Reduce version computation by avoiding Message parsing
865 'RCFiltersChangeTags' => self::getChangeTagListSummary( $context ),
866 'StructuredChangeFiltersEditWatchlistUrl' =>
867 SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
868 ];
869 }
870
879 return [
880 'RCFiltersChangeTags' => self::getChangeTagList( $context ),
881 'StructuredChangeFiltersEditWatchlistUrl' =>
882 SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
883 ];
884 }
885
907 $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
908 return $cache->getWithSetCallback(
909 $cache->makeKey( 'ChangesListSpecialPage-changeTagListSummary', $context->getLanguage() ),
910 WANObjectCache::TTL_DAY,
911 function ( $oldValue, &$ttl, array &$setOpts ) use ( $context ) {
912 $explicitlyDefinedTags = array_fill_keys( ChangeTags::listExplicitlyDefinedTags(), 0 );
913 $softwareActivatedTags = array_fill_keys( ChangeTags::listSoftwareActivatedTags(), 0 );
914
915 $tagStats = ChangeTags::tagUsageStatistics();
916 $tagHitCounts = array_merge( $explicitlyDefinedTags, $softwareActivatedTags, $tagStats );
917
918 $result = [];
919 foreach ( $tagHitCounts as $tagName => $hits ) {
920 if (
921 (
922 // Only get active tags
923 isset( $explicitlyDefinedTags[ $tagName ] ) ||
924 isset( $softwareActivatedTags[ $tagName ] )
925 ) &&
926 // Only get tags with more than 0 hits
927 $hits > 0
928 ) {
929 $labelMsg = ChangeTags::tagShortDescriptionMessage( $tagName, $context );
930 if ( $labelMsg === false ) {
931 // Tag is hidden, skip it
932 continue;
933 }
934 $descriptionMsg = ChangeTags::tagLongDescriptionMessage( $tagName, $context );
935 $result[] = [
936 'name' => $tagName,
937 'labelMsg' => $labelMsg,
938 'label' => $labelMsg->plain(),
939 'descriptionMsg' => $descriptionMsg,
940 'description' => $descriptionMsg ? $descriptionMsg->plain() : '',
941 'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ),
942 'hits' => $hits,
943 ];
944 }
945 }
946 return $result;
947 }
948 );
949 }
950
964 protected static function getChangeTagList( ResourceLoaderContext $context ) {
966 $language = Language::factory( $context->getLanguage() );
967 foreach ( $tags as &$tagInfo ) {
968 $tagInfo['label'] = Sanitizer::stripAllTags( $tagInfo['labelMsg']->parse() );
969 $tagInfo['description'] = $tagInfo['descriptionMsg'] ?
970 $language->truncateForVisual(
971 Sanitizer::stripAllTags( $tagInfo['descriptionMsg']->parse() ),
972 self::TAG_DESC_CHARACTER_LIMIT
973 ) :
974 '';
975 unset( $tagInfo['labelMsg'] );
976 unset( $tagInfo['descriptionMsg'] );
977 }
978
979 // Instead of sorting by hit count (disabled for now), sort by display name
980 usort( $tags, function ( $a, $b ) {
981 return strcasecmp( $a['label'], $b['label'] );
982 } );
983 return $tags;
984 }
985
989 protected function outputNoResults() {
990 $this->getOutput()->addHTML(
991 '<div class="mw-changeslist-empty">' .
992 $this->msg( 'recentchanges-noresult' )->parse() .
993 '</div>'
994 );
995 }
996
1000 protected function outputTimeout() {
1001 $this->getOutput()->addHTML(
1002 '<div class="mw-changeslist-empty mw-changeslist-timeout">' .
1003 $this->msg( 'recentchanges-timeout' )->parse() .
1004 '</div>'
1005 );
1006 }
1007
1013 public function getRows() {
1014 $opts = $this->getOptions();
1015
1016 $tables = [];
1017 $fields = [];
1018 $conds = [];
1019 $query_options = [];
1020 $join_conds = [];
1021 $this->buildQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
1022
1023 return $this->doMainQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
1024 }
1025
1031 public function getOptions() {
1032 if ( $this->rcOptions === null ) {
1033 $this->rcOptions = $this->setup( $this->rcSubpage );
1034 }
1035
1036 return $this->rcOptions;
1037 }
1038
1048 protected function registerFilters() {
1049 $this->registerFiltersFromDefinitions( $this->filterGroupDefinitions );
1050
1051 // Make sure this is not being transcluded (we don't want to show this
1052 // information to all users just because the user that saves the edit can
1053 // patrol or is logged in)
1054 if ( !$this->including() && $this->getUser()->useRCPatrol() ) {
1055 $this->registerFiltersFromDefinitions( $this->legacyReviewStatusFilterGroupDefinition );
1056 $this->registerFiltersFromDefinitions( $this->reviewStatusFilterGroupDefinition );
1057 }
1058
1059 $changeTypeGroup = $this->getFilterGroup( 'changeType' );
1060
1061 if ( $this->getConfig()->get( 'RCWatchCategoryMembership' ) ) {
1062 $transformedHideCategorizationDef = $this->transformFilterDefinition(
1063 $this->hideCategorizationFilterDefinition
1064 );
1065
1066 $transformedHideCategorizationDef['group'] = $changeTypeGroup;
1067
1068 $hideCategorization = new ChangesListBooleanFilter(
1069 $transformedHideCategorizationDef
1070 );
1071 }
1072
1073 Hooks::run( 'ChangesListSpecialPageStructuredFilters', [ $this ] );
1074
1075 $this->registerFiltersFromDefinitions( [] );
1076
1077 $userExperienceLevel = $this->getFilterGroup( 'userExpLevel' );
1078 $registered = $userExperienceLevel->getFilter( 'registered' );
1079 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'newcomer' ) );
1080 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'learner' ) );
1081 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'experienced' ) );
1082
1083 $categoryFilter = $changeTypeGroup->getFilter( 'hidecategorization' );
1084 $logactionsFilter = $changeTypeGroup->getFilter( 'hidelog' );
1085 $pagecreationFilter = $changeTypeGroup->getFilter( 'hidenewpages' );
1086
1087 $significanceTypeGroup = $this->getFilterGroup( 'significance' );
1088 $hideMinorFilter = $significanceTypeGroup->getFilter( 'hideminor' );
1089
1090 // categoryFilter is conditional; see registerFilters
1091 if ( $categoryFilter !== null ) {
1092 $hideMinorFilter->conflictsWith(
1093 $categoryFilter,
1094 'rcfilters-hideminor-conflicts-typeofchange-global',
1095 'rcfilters-hideminor-conflicts-typeofchange',
1096 'rcfilters-typeofchange-conflicts-hideminor'
1097 );
1098 }
1099 $hideMinorFilter->conflictsWith(
1100 $logactionsFilter,
1101 'rcfilters-hideminor-conflicts-typeofchange-global',
1102 'rcfilters-hideminor-conflicts-typeofchange',
1103 'rcfilters-typeofchange-conflicts-hideminor'
1104 );
1105 $hideMinorFilter->conflictsWith(
1106 $pagecreationFilter,
1107 'rcfilters-hideminor-conflicts-typeofchange-global',
1108 'rcfilters-hideminor-conflicts-typeofchange',
1109 'rcfilters-typeofchange-conflicts-hideminor'
1110 );
1111 }
1112
1122 protected function transformFilterDefinition( array $filterDefinition ) {
1123 return $filterDefinition;
1124 }
1125
1136 protected function registerFiltersFromDefinitions( array $definition ) {
1137 $autoFillPriority = -1;
1138 foreach ( $definition as $groupDefinition ) {
1139 if ( !isset( $groupDefinition['priority'] ) ) {
1140 $groupDefinition['priority'] = $autoFillPriority;
1141 } else {
1142 // If it's explicitly specified, start over the auto-fill
1143 $autoFillPriority = $groupDefinition['priority'];
1144 }
1145
1146 $autoFillPriority--;
1147
1148 $className = $groupDefinition['class'];
1149 unset( $groupDefinition['class'] );
1150
1151 foreach ( $groupDefinition['filters'] as &$filterDefinition ) {
1152 $filterDefinition = $this->transformFilterDefinition( $filterDefinition );
1153 }
1154
1155 $this->registerFilterGroup( new $className( $groupDefinition ) );
1156 }
1157 }
1158
1162 protected function getLegacyShowHideFilters() {
1163 $filters = [];
1164 foreach ( $this->filterGroups as $group ) {
1165 if ( $group instanceof ChangesListBooleanFilterGroup ) {
1166 foreach ( $group->getFilters() as $key => $filter ) {
1167 if ( $filter->displaysOnUnstructuredUi( $this ) ) {
1168 $filters[ $key ] = $filter;
1169 }
1170 }
1171 }
1172 }
1173 return $filters;
1174 }
1175
1184 public function setup( $parameters ) {
1185 $this->registerFilters();
1186
1187 $opts = $this->getDefaultOptions();
1188
1189 $opts = $this->fetchOptionsFromRequest( $opts );
1190
1191 // Give precedence to subpage syntax
1192 if ( $parameters !== null ) {
1193 $this->parseParameters( $parameters, $opts );
1194 }
1195
1196 $this->validateOptions( $opts );
1197
1198 return $opts;
1199 }
1200
1210 public function getDefaultOptions() {
1211 $opts = new FormOptions();
1212 $structuredUI = $this->isStructuredFilterUiEnabled();
1213 // If urlversion=2 is set, ignore the filter defaults and set them all to false/empty
1214 $useDefaults = $this->getRequest()->getInt( 'urlversion' ) !== 2;
1215
1217 foreach ( $this->filterGroups as $filterGroup ) {
1218 $filterGroup->addOptions( $opts, $useDefaults, $structuredUI );
1219 }
1220
1221 $opts->add( 'namespace', '', FormOptions::STRING );
1222 $opts->add( 'invert', false );
1223 $opts->add( 'associated', false );
1224 $opts->add( 'urlversion', 1 );
1225 $opts->add( 'tagfilter', '' );
1226
1227 $opts->add( 'days', $this->getDefaultDays(), FormOptions::FLOAT );
1228 $opts->add( 'limit', $this->getDefaultLimit(), FormOptions::INT );
1229
1230 $opts->add( 'from', '' );
1231
1232 return $opts;
1233 }
1234
1241 $groupName = $group->getName();
1242
1243 $this->filterGroups[$groupName] = $group;
1244 }
1245
1251 protected function getFilterGroups() {
1252 return $this->filterGroups;
1253 }
1254
1262 public function getFilterGroup( $groupName ) {
1263 return $this->filterGroups[$groupName] ?? null;
1264 }
1265
1266 // Currently, this intentionally only includes filters that display
1267 // in the structured UI. This can be changed easily, though, if we want
1268 // to include data on filters that use the unstructured UI. messageKeys is a
1269 // special top-level value, with the value being an array of the message keys to
1270 // send to the client.
1271
1279 public function getStructuredFilterJsData() {
1280 $output = [
1281 'groups' => [],
1282 'messageKeys' => [],
1283 ];
1284
1285 usort( $this->filterGroups, function ( $a, $b ) {
1286 return $b->getPriority() <=> $a->getPriority();
1287 } );
1288
1289 foreach ( $this->filterGroups as $groupName => $group ) {
1290 $groupOutput = $group->getJsData( $this );
1291 if ( $groupOutput !== null ) {
1292 $output['messageKeys'] = array_merge(
1293 $output['messageKeys'],
1294 $groupOutput['messageKeys']
1295 );
1296
1297 unset( $groupOutput['messageKeys'] );
1298 $output['groups'][] = $groupOutput;
1299 }
1300 }
1301
1302 return $output;
1303 }
1304
1313 protected function fetchOptionsFromRequest( $opts ) {
1314 $opts->fetchValuesFromRequest( $this->getRequest() );
1315
1316 return $opts;
1317 }
1318
1325 public function parseParameters( $par, FormOptions $opts ) {
1326 $stringParameterNameSet = [];
1327 $hideParameterNameSet = [];
1328
1329 // URL parameters can be per-group, like 'userExpLevel',
1330 // or per-filter, like 'hideminor'.
1331
1332 foreach ( $this->filterGroups as $filterGroup ) {
1333 if ( $filterGroup instanceof ChangesListStringOptionsFilterGroup ) {
1334 $stringParameterNameSet[$filterGroup->getName()] = true;
1335 } elseif ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
1336 foreach ( $filterGroup->getFilters() as $filter ) {
1337 $hideParameterNameSet[$filter->getName()] = true;
1338 }
1339 }
1340 }
1341
1342 $bits = preg_split( '/\s*,\s*/', trim( $par ) );
1343 foreach ( $bits as $bit ) {
1344 $m = [];
1345 if ( isset( $hideParameterNameSet[$bit] ) ) {
1346 // hidefoo => hidefoo=true
1347 $opts[$bit] = true;
1348 } elseif ( isset( $hideParameterNameSet["hide$bit"] ) ) {
1349 // foo => hidefoo=false
1350 $opts["hide$bit"] = false;
1351 } elseif ( preg_match( '/^(.*)=(.*)$/', $bit, $m ) ) {
1352 if ( isset( $stringParameterNameSet[$m[1]] ) ) {
1353 $opts[$m[1]] = $m[2];
1354 }
1355 }
1356 }
1357 }
1358
1364 public function validateOptions( FormOptions $opts ) {
1365 $isContradictory = $this->fixContradictoryOptions( $opts );
1366 $isReplaced = $this->replaceOldOptions( $opts );
1367
1368 if ( $isContradictory || $isReplaced ) {
1369 $query = wfArrayToCgi( $this->convertParamsForLink( $opts->getChangedValues() ) );
1370 $this->getOutput()->redirect( $this->getPageTitle()->getCanonicalURL( $query ) );
1371 }
1372
1373 $opts->validateIntBounds( 'limit', 0, 5000 );
1374 $opts->validateBounds( 'days', 0, $this->getConfig()->get( 'RCMaxAge' ) / ( 3600 * 24 ) );
1375 }
1376
1383 private function fixContradictoryOptions( FormOptions $opts ) {
1384 $fixed = $this->fixBackwardsCompatibilityOptions( $opts );
1385
1386 foreach ( $this->filterGroups as $filterGroup ) {
1387 if ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
1388 $filters = $filterGroup->getFilters();
1389
1390 if ( count( $filters ) === 1 ) {
1391 // legacy boolean filters should not be considered
1392 continue;
1393 }
1394
1395 $allInGroupEnabled = array_reduce(
1396 $filters,
1397 function ( $carry, $filter ) use ( $opts ) {
1398 return $carry && $opts[ $filter->getName() ];
1399 },
1400 /* initialValue */ count( $filters ) > 0
1401 );
1402
1403 if ( $allInGroupEnabled ) {
1404 foreach ( $filters as $filter ) {
1405 $opts[ $filter->getName() ] = false;
1406 }
1407
1408 $fixed = true;
1409 }
1410 }
1411 }
1412
1413 return $fixed;
1414 }
1415
1426 if ( $opts['hideanons'] && $opts['hideliu'] ) {
1427 $opts->reset( 'hideanons' );
1428 if ( !$opts['hidebots'] ) {
1429 $opts->reset( 'hideliu' );
1430 $opts['hidehumans'] = 1;
1431 }
1432
1433 return true;
1434 }
1435
1436 return false;
1437 }
1438
1445 public function replaceOldOptions( FormOptions $opts ) {
1446 if ( !$this->isStructuredFilterUiEnabled() ) {
1447 return false;
1448 }
1449
1450 $changed = false;
1451
1452 // At this point 'hideanons' and 'hideliu' cannot be both true,
1453 // because fixBackwardsCompatibilityOptions resets (at least) 'hideanons' in such case
1454 if ( $opts[ 'hideanons' ] ) {
1455 $opts->reset( 'hideanons' );
1456 $opts[ 'userExpLevel' ] = 'registered';
1457 $changed = true;
1458 }
1459
1460 if ( $opts[ 'hideliu' ] ) {
1461 $opts->reset( 'hideliu' );
1462 $opts[ 'userExpLevel' ] = 'unregistered';
1463 $changed = true;
1464 }
1465
1466 if ( $this->getFilterGroup( 'legacyReviewStatus' ) ) {
1467 if ( $opts[ 'hidepatrolled' ] ) {
1468 $opts->reset( 'hidepatrolled' );
1469 $opts[ 'reviewStatus' ] = 'unpatrolled';
1470 $changed = true;
1471 }
1472
1473 if ( $opts[ 'hideunpatrolled' ] ) {
1474 $opts->reset( 'hideunpatrolled' );
1475 $opts[ 'reviewStatus' ] = implode(
1477 [ 'manual', 'auto' ]
1478 );
1479 $changed = true;
1480 }
1481 }
1482
1483 return $changed;
1484 }
1485
1494 protected function convertParamsForLink( $params ) {
1495 foreach ( $params as &$value ) {
1496 if ( $value === false ) {
1497 $value = '0';
1498 }
1499 }
1500 unset( $value );
1501 return $params;
1502 }
1503
1515 protected function buildQuery( &$tables, &$fields, &$conds, &$query_options,
1516 &$join_conds, FormOptions $opts
1517 ) {
1518 $dbr = $this->getDB();
1519 $isStructuredUI = $this->isStructuredFilterUiEnabled();
1520
1522 foreach ( $this->filterGroups as $filterGroup ) {
1523 $filterGroup->modifyQuery( $dbr, $this, $tables, $fields, $conds,
1524 $query_options, $join_conds, $opts, $isStructuredUI );
1525 }
1526
1527 // Namespace filtering
1528 if ( $opts[ 'namespace' ] !== '' ) {
1529 $namespaces = explode( ';', $opts[ 'namespace' ] );
1530
1531 $namespaces = $this->expandSymbolicNamespaceFilters( $namespaces );
1532
1533 if ( $opts[ 'associated' ] ) {
1534 $namespaceInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
1535 $associatedNamespaces = array_map(
1536 function ( $ns ) use ( $namespaceInfo ){
1537 return $namespaceInfo->getAssociated( $ns );
1538 },
1539 array_filter(
1540 $namespaces,
1541 function ( $ns ) use ( $namespaceInfo ) {
1542 return $namespaceInfo->hasTalkNamespace( $ns );
1543 }
1544 )
1545 );
1546 $namespaces = array_unique( array_merge( $namespaces, $associatedNamespaces ) );
1547 }
1548
1549 if ( count( $namespaces ) === 1 ) {
1550 $operator = $opts[ 'invert' ] ? '!=' : '=';
1551 $value = $dbr->addQuotes( reset( $namespaces ) );
1552 } else {
1553 $operator = $opts[ 'invert' ] ? 'NOT IN' : 'IN';
1554 sort( $namespaces );
1555 $value = '(' . $dbr->makeList( $namespaces ) . ')';
1556 }
1557 $conds[] = "rc_namespace $operator $value";
1558 }
1559
1560 // Calculate cutoff
1561 $cutoff_unixtime = time() - $opts['days'] * 3600 * 24;
1562 $cutoff = $dbr->timestamp( $cutoff_unixtime );
1563
1564 $fromValid = preg_match( '/^[0-9]{14}$/', $opts['from'] );
1565 if ( $fromValid && $opts['from'] > wfTimestamp( TS_MW, $cutoff ) ) {
1566 $cutoff = $dbr->timestamp( $opts['from'] );
1567 } else {
1568 $opts->reset( 'from' );
1569 }
1570
1571 $conds[] = 'rc_timestamp >= ' . $dbr->addQuotes( $cutoff );
1572 }
1573
1585 protected function doMainQuery( $tables, $fields, $conds,
1586 $query_options, $join_conds, FormOptions $opts
1587 ) {
1588 $rcQuery = RecentChange::getQueryInfo();
1589 $tables = array_merge( $tables, $rcQuery['tables'] );
1590 $fields = array_merge( $rcQuery['fields'], $fields );
1591 $join_conds = array_merge( $join_conds, $rcQuery['joins'] );
1592
1594 $tables,
1595 $fields,
1596 $conds,
1597 $join_conds,
1598 $query_options,
1599 ''
1600 );
1601
1602 if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
1603 $opts )
1604 ) {
1605 return false;
1606 }
1607
1608 $dbr = $this->getDB();
1609
1610 return $dbr->select(
1611 $tables,
1612 $fields,
1613 $conds,
1614 __METHOD__,
1615 $query_options,
1616 $join_conds
1617 );
1618 }
1619
1620 protected function runMainQueryHook( &$tables, &$fields, &$conds,
1621 &$query_options, &$join_conds, $opts
1622 ) {
1623 return Hooks::run(
1624 'ChangesListSpecialPageQuery',
1625 [ $this->getName(), &$tables, &$fields, &$conds, &$query_options, &$join_conds, $opts ]
1626 );
1627 }
1628
1634 protected function getDB() {
1635 return wfGetDB( DB_REPLICA );
1636 }
1637
1644 private function webOutputHeader( $rowCount, $opts ) {
1645 if ( !$this->including() ) {
1646 $this->outputFeedLinks();
1647 $this->doHeader( $opts, $rowCount );
1648 }
1649 }
1650
1657 public function webOutput( $rows, $opts ) {
1658 $this->webOutputHeader( $rows->numRows(), $opts );
1659
1660 $this->outputChangesList( $rows, $opts );
1661 }
1662
1666 public function outputFeedLinks() {
1667 // nothing by default
1668 }
1669
1676 abstract public function outputChangesList( $rows, $opts );
1677
1684 public function doHeader( $opts, $numRows ) {
1685 $this->setTopText( $opts );
1686
1687 // @todo Lots of stuff should be done here.
1688
1689 $this->setBottomText( $opts );
1690 }
1691
1699 public function setTopText( FormOptions $opts ) {
1700 // nothing by default
1701 }
1702
1710 public function setBottomText( FormOptions $opts ) {
1711 // nothing by default
1712 }
1713
1723 public function getExtraOptions( $opts ) {
1724 return [];
1725 }
1726
1732 public function makeLegend() {
1733 $context = $this->getContext();
1734 $user = $context->getUser();
1735 # The legend showing what the letters and stuff mean
1736 $legend = Html::openElement( 'dl' ) . "\n";
1737 # Iterates through them and gets the messages for both letter and tooltip
1738 $legendItems = $context->getConfig()->get( 'RecentChangesFlags' );
1739 if ( !( $user->useRCPatrol() || $user->useNPPatrol() ) ) {
1740 unset( $legendItems['unpatrolled'] );
1741 }
1742 foreach ( $legendItems as $key => $item ) { # generate items of the legend
1743 $label = $item['legend'] ?? $item['title'];
1744 $letter = $item['letter'];
1745 $cssClass = $item['class'] ?? $key;
1746
1747 $legend .= Html::element( 'dt',
1748 [ 'class' => $cssClass ], $context->msg( $letter )->text()
1749 ) . "\n" .
1750 Html::rawElement( 'dd',
1751 [ 'class' => Sanitizer::escapeClass( 'mw-changeslist-legend-' . $key ) ],
1752 $context->msg( $label )->parse()
1753 ) . "\n";
1754 }
1755 # (+-123)
1756 $legend .= Html::rawElement( 'dt',
1757 [ 'class' => 'mw-plusminus-pos' ],
1758 $context->msg( 'recentchanges-legend-plusminus' )->parse()
1759 ) . "\n";
1760 $legend .= Html::element(
1761 'dd',
1762 [ 'class' => 'mw-changeslist-legend-plusminus' ],
1763 $context->msg( 'recentchanges-label-plusminus' )->text()
1764 ) . "\n";
1765 $legend .= Html::closeElement( 'dl' ) . "\n";
1766
1767 $legendHeading = $this->isStructuredFilterUiEnabled() ?
1768 $context->msg( 'rcfilters-legend-heading' )->parse() :
1769 $context->msg( 'recentchanges-legend-heading' )->parse();
1770
1771 # Collapsible
1772 $collapsedState = $this->getRequest()->getCookie( 'changeslist-state' );
1773 $collapsedClass = $collapsedState === 'collapsed' ? ' mw-collapsed' : '';
1774
1775 $legend =
1776 '<div class="mw-changeslist-legend mw-collapsible' . $collapsedClass . '">' .
1777 $legendHeading .
1778 '<div class="mw-collapsible-content">' . $legend . '</div>' .
1779 '</div>';
1780
1781 return $legend;
1782 }
1783
1787 protected function addModules() {
1788 $out = $this->getOutput();
1789 // Styles and behavior for the legend box (see makeLegend())
1790 $out->addModuleStyles( [
1791 'mediawiki.interface.helpers.styles',
1792 'mediawiki.special.changeslist.legend',
1793 'mediawiki.special.changeslist',
1794 ] );
1795 $out->addModules( 'mediawiki.special.changeslist.legend.js' );
1796
1797 if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
1798 $out->addModules( 'mediawiki.rcfilters.filters.ui' );
1799 $out->addModuleStyles( 'mediawiki.rcfilters.filters.base.styles' );
1800 }
1801 }
1802
1803 protected function getGroupName() {
1804 return 'changes';
1805 }
1806
1823 public function filterOnUserExperienceLevel( $specialPageClassName, $context, $dbr,
1824 &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedExpLevels, $now = 0
1825 ) {
1826 global $wgLearnerEdits,
1830
1831 $LEVEL_COUNT = 5;
1832
1833 // If all levels are selected, don't filter
1834 if ( count( $selectedExpLevels ) === $LEVEL_COUNT ) {
1835 return;
1836 }
1837
1838 // both 'registered' and 'unregistered', experience levels, if any, are included in 'registered'
1839 if (
1840 in_array( 'registered', $selectedExpLevels ) &&
1841 in_array( 'unregistered', $selectedExpLevels )
1842 ) {
1843 return;
1844 }
1845
1846 $actorMigration = ActorMigration::newMigration();
1847 $actorQuery = $actorMigration->getJoin( 'rc_user' );
1848 $tables += $actorQuery['tables'];
1849 $join_conds += $actorQuery['joins'];
1850
1851 // 'registered' but not 'unregistered', experience levels, if any, are included in 'registered'
1852 if (
1853 in_array( 'registered', $selectedExpLevels ) &&
1854 !in_array( 'unregistered', $selectedExpLevels )
1855 ) {
1856 $conds[] = $actorMigration->isNotAnon( $actorQuery['fields']['rc_user'] );
1857 return;
1858 }
1859
1860 if ( $selectedExpLevels === [ 'unregistered' ] ) {
1861 $conds[] = $actorMigration->isAnon( $actorQuery['fields']['rc_user'] );
1862 return;
1863 }
1864
1865 $tables[] = 'user';
1866 $join_conds['user'] = [ 'LEFT JOIN', $actorQuery['fields']['rc_user'] . ' = user_id' ];
1867
1868 if ( $now === 0 ) {
1869 $now = time();
1870 }
1871 $secondsPerDay = 86400;
1872 $learnerCutoff = $now - $wgLearnerMemberSince * $secondsPerDay;
1873 $experiencedUserCutoff = $now - $wgExperiencedUserMemberSince * $secondsPerDay;
1874
1875 $aboveNewcomer = $dbr->makeList(
1876 [
1877 'user_editcount >= ' . intval( $wgLearnerEdits ),
1878 'user_registration <= ' . $dbr->addQuotes( $dbr->timestamp( $learnerCutoff ) ),
1879 ],
1880 IDatabase::LIST_AND
1881 );
1882
1883 $aboveLearner = $dbr->makeList(
1884 [
1885 'user_editcount >= ' . intval( $wgExperiencedUserEdits ),
1886 'user_registration <= ' .
1887 $dbr->addQuotes( $dbr->timestamp( $experiencedUserCutoff ) ),
1888 ],
1889 IDatabase::LIST_AND
1890 );
1891
1892 $conditions = [];
1893
1894 if ( in_array( 'unregistered', $selectedExpLevels ) ) {
1895 $selectedExpLevels = array_diff( $selectedExpLevels, [ 'unregistered' ] );
1896 $conditions[] = $actorMigration->isAnon( $actorQuery['fields']['rc_user'] );
1897 }
1898
1899 if ( $selectedExpLevels === [ 'newcomer' ] ) {
1900 $conditions[] = "NOT ( $aboveNewcomer )";
1901 } elseif ( $selectedExpLevels === [ 'learner' ] ) {
1902 $conditions[] = $dbr->makeList(
1903 [ $aboveNewcomer, "NOT ( $aboveLearner )" ],
1904 IDatabase::LIST_AND
1905 );
1906 } elseif ( $selectedExpLevels === [ 'experienced' ] ) {
1907 $conditions[] = $aboveLearner;
1908 } elseif ( $selectedExpLevels === [ 'learner', 'newcomer' ] ) {
1909 $conditions[] = "NOT ( $aboveLearner )";
1910 } elseif ( $selectedExpLevels === [ 'experienced', 'newcomer' ] ) {
1911 $conditions[] = $dbr->makeList(
1912 [ "NOT ( $aboveNewcomer )", $aboveLearner ],
1913 IDatabase::LIST_OR
1914 );
1915 } elseif ( $selectedExpLevels === [ 'experienced', 'learner' ] ) {
1916 $conditions[] = $aboveNewcomer;
1917 } elseif ( $selectedExpLevels === [ 'experienced', 'learner', 'newcomer' ] ) {
1918 $conditions[] = $actorMigration->isNotAnon( $actorQuery['fields']['rc_user'] );
1919 }
1920
1921 if ( count( $conditions ) > 1 ) {
1922 $conds[] = $dbr->makeList( $conditions, IDatabase::LIST_OR );
1923 } elseif ( count( $conditions ) === 1 ) {
1924 $conds[] = reset( $conditions );
1925 }
1926 }
1927
1934 if ( $this->getRequest()->getBool( 'rcfilters' ) ) {
1935 return true;
1936 }
1937
1938 return static::checkStructuredFilterUiEnabled( $this->getUser() );
1939 }
1940
1948 public static function checkStructuredFilterUiEnabled( $user ) {
1949 if ( $user instanceof Config ) {
1950 wfDeprecated( __METHOD__ . ' with Config argument', '1.34' );
1951 $user = func_get_arg( 1 );
1952 }
1953 return !$user->getOption( 'rcenhancedfilters-disable' );
1954 }
1955
1963 public function getDefaultLimit() {
1964 return $this->getUser()->getIntOption( static::$limitPreferenceName );
1965 }
1966
1975 public function getDefaultDays() {
1976 return floatval( $this->getUser()->getOption( static::$daysPreferenceName ) );
1977 }
1978
1979 private function expandSymbolicNamespaceFilters( array $namespaces ) {
1980 $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
1981 $symbolicFilters = [
1982 'all-contents' => $nsInfo->getSubjectNamespaces(),
1983 'all-discussions' => $nsInfo->getTalkNamespaces(),
1984 ];
1985 $additionalNamespaces = [];
1986 foreach ( $symbolicFilters as $name => $values ) {
1987 if ( in_array( $name, $namespaces ) ) {
1988 $additionalNamespaces = array_merge( $additionalNamespaces, $values );
1989 }
1990 }
1991 $namespaces = array_diff( $namespaces, array_keys( $symbolicFilters ) );
1992 $namespaces = array_merge( $namespaces, $additionalNamespaces );
1993 return array_unique( $namespaces );
1994 }
1995}
$wgLearnerMemberSince
Specify the difference engine to use.
$wgExperiencedUserMemberSince
Specify the difference engine to use.
$wgLearnerEdits
The following variables define 3 user experience levels:
$wgExperiencedUserEdits
Specify the difference engine to use.
wfGetDB( $db, $groups=[], $wiki=false)
Get a Database object.
wfLogWarning( $msg, $callerOffset=1, $level=E_USER_WARNING)
Send a warning as a PHP error and the debug log.
wfArrayToCgi( $array1, $array2=null, $prefix='')
This function takes one or two arrays as input, and returns a CGI-style string, e....
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Throws a warning that $function is deprecated.
static tagLongDescriptionMessage( $tag, MessageLocalizer $context)
Get the message object for the tag's long description.
static listSoftwareActivatedTags()
Lists those tags which core or extensions report as being "active".
static tagUsageStatistics()
Returns a map of any tags used on the wiki to number of edits tagged with them, ordered descending by...
static tagShortDescriptionMessage( $tag, MessageLocalizer $context)
Get the message object for the tag's short description.
static modifyDisplayQuery(&$tables, &$fields, &$conds, &$join_conds, &$options, $filter_tag='')
Applies all tags-related changes to a query.
static listExplicitlyDefinedTags()
Lists tags explicitly defined in the change_tag_def table of the database.
If the group is active, any unchecked filters will translate to hide parameters in the URL.
Represents a hide-based boolean filter (used on ChangesListSpecialPage and descendants)
Represents a filter group (used on ChangesListSpecialPage and descendants)
Represents a filter (used on ChangesListSpecialPage and descendants)
getConflictingFilters()
Get filters conflicting with this filter.
Special page which uses a ChangesList to show query results.
static getRcFiltersConfigVars(ResourceLoaderContext $context)
Get config vars to export with the mediawiki.rcfilters.filters.ui module.
const TAG_DESC_CHARACTER_LIMIT
Maximum length of a tag description in UTF-8 characters.
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.
filterOnUserExperienceLevel( $specialPageClassName, $context, $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedExpLevels, $now=0)
Filter on users' experience levels; this will not be called if nothing is selected.
runMainQueryHook(&$tables, &$fields, &$conds, &$query_options, &$join_conds, $opts)
getDefaultLimit()
Get the default value of the number of changes to display when loading the result set.
registerFiltersFromDefinitions(array $definition)
Register filters from a definition object.
convertParamsForLink( $params)
Convert parameters values from true/false to 1/0 so they are not omitted by wfArrayToCgi() T38524.
parseParameters( $par, FormOptions $opts)
Process $par and put options found in $opts.
static getChangeTagListSummary(ResourceLoaderContext $context)
Get information about change tags, without parsing messages, for getRcFiltersConfigSummary().
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.
static getChangeTagList(ResourceLoaderContext $context)
Get information about change tags to export to JS via getRcFiltersConfigVars().
static string $savedQueriesPreferenceName
Preference name for saved queries.
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.
fixBackwardsCompatibilityOptions(FormOptions $opts)
Fix a special case (hideanons=1 and hideliu=1) in a special way, for backwards compatibility.
expandSymbolicNamespaceFilters(array $namespaces)
outputNoResults()
Add the "no results" message to the output.
static string $limitPreferenceName
Preference name for 'limit'.
static string $daysPreferenceName
Preference name for 'days'.
getFilterGroups()
Gets the currently registered filters groups.
registerFilterGroup(ChangesListFilterGroup $group)
Register a structured changes list filter group.
addModules()
Add page-specific modules.
fixContradictoryOptions(FormOptions $opts)
Fix invalid options by resetting pairs that should never appear together.
static getRcFiltersConfigSummary(ResourceLoaderContext $context)
Get essential data about getRcFiltersConfigVars() for change detection.
__construct( $name, $restriction)
outputFeedLinks()
Output feed links.
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.
setBottomText(FormOptions $opts)
Send the text to be displayed after the options.
getStructuredFilterJsData()
Gets structured filter information needed by JS.
buildQuery(&$tables, &$fields, &$conds, &$query_options, &$join_conds, FormOptions $opts)
Sets appropriate tables, fields, conditions, etc.
webOutputHeader( $rowCount, $opts)
Send header output to the OutputPage object, only called if not using feeds.
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.
$filterGroups
Filter groups, and their contained filters This is an associative array (with group name as key) of C...
static checkStructuredFilterUiEnabled( $user)
Static method to check whether StructuredFilter UI is enabled for the given user.
outputChangesList( $rows, $opts)
Build and output the actual changes list.
getRows()
Get the database result for this special page instance.
$filterGroupDefinitions
Definition information for the filters and their groups.
includeRcFiltersApp()
Include the modules and configuration for the RCFilters app.
static string $collapsedPreferenceName
Preference name for collapsing the active filter display.
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.
reset( $name)
Delete the option value.
const FLOAT
Float type, maps guessType() to WebRequest::getFloat()
validateBounds( $name, $min, $max)
Constrain a numeric value for a given option to a given range.
const STRING
String type, maps guessType() to WebRequest::getText()
validateIntBounds( $name, $min, $max)
const INT
Integer type, maps guessType() to WebRequest::getInt()
getChangedValues()
Return options modified as an array ( name => value )
Class representing a list of titles The execute() method checks them all for existence and adds them ...
Definition LinkBatch.php:34
add( $ns, $dbkey)
Definition LinkBatch.php:83
static newFromRow( $row)
Handy shortcut for constructing a formatter directly from database row.
PSR-3 logger instance factory.
MediaWikiServices is the service locator for the application scope of MediaWiki.
Context object that contains information about the state of a specific ResourceLoader web request.
Parent class for all special pages.
outputHeader( $summaryMessageKey='')
Outputs a summary message on top of special pages Per default the message key is the canonical name o...
getName()
Get the name of this Special Page.
setHeaders()
Sets headers - this should be called from the execute() method of all derived classes!
getOutput()
Get the OutputPage being used for this instance.
getUser()
Shortcut to get the User executing this instance.
static getTitleFor( $name, $subpage=false, $fragment='')
Get a localised Title object for a specified special page name If you don't need a full Title object,...
getContext()
Gets the context this SpecialPage is executed in.
msg( $key,... $params)
Wrapper around wfMessage that sets the current context.
getConfig()
Shortcut to get main config object.
getRequest()
Get the WebRequest being used for this instance.
getPageTitle( $subpage=false)
Get a self-referential title object.
including( $x=null)
Whether the special page is being evaluated via transclusion.
Class for fixing stale WANObjectCache keys using a purge event source.
Error thrown when a query times out.
Overloads the relevant methods of the real ResultsWrapper so it doesn't go anywhere near an actual da...
const NS_USER
Definition Defines.php:71
const RC_NEW
Definition Defines.php:132
const LIST_OR
Definition Defines.php:51
const RC_LOG
Definition Defines.php:133
const NS_USER_TALK
Definition Defines.php:72
const RC_EDIT
Definition Defines.php:131
const RC_CATEGORIZE
Definition Defines.php:135
Interface for configuration instances.
Definition Config.php:28
Basic database interface for live and lazy-loaded relation database handles.
Definition IDatabase.php:38
Result wrapper for grabbing data queried from an IDatabase object.
$context
Definition load.php:45
$cache
Definition mcc.php:33
$filter
const DB_REPLICA
Definition defines.php:25