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