1<?php 2/** 3 * Special page which uses a ChangesList to show query results. 4 * 5 * This program is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation; either version 2 of the License, or 8 * (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License along 16 * with this program; if not, write to the Free Software Foundation, Inc., 17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 18 * http://www.gnu.org/copyleft/gpl.html 19 * 20 * @file 21 * @ingroup SpecialPage 22 */ 23 24use MediaWiki\Logger\LoggerFactory; 25use MediaWiki\MediaWikiServices; 26use OOUI\IconWidget; 27use Wikimedia\Rdbms\DBQueryTimeoutError; 28use Wikimedia\Rdbms\FakeResultWrapper; 29use Wikimedia\Rdbms\IDatabase; 30use Wikimedia\Rdbms\IResultWrapper; 31 32/** 33 * Special page which uses a ChangesList to show query results. 34 * @todo Way too many public functions, most of them should be protected 35 * 36 * @ingroup SpecialPage 37 */ 38abstract class ChangesListSpecialPage extends SpecialPage { 39 /** 40 * Maximum length of a tag description in UTF-8 characters. 41 * Longer descriptions will be truncated. 42 */ 43 private const TAG_DESC_CHARACTER_LIMIT = 120; 44 45 /** 46 * Preference name for saved queries. Subclasses that use saved queries should override this. 47 * @var string 48 */ 49 protected static $savedQueriesPreferenceName; 50 51 /** 52 * Preference name for 'days'. Subclasses should override this. 53 * @var string 54 */ 55 protected static $daysPreferenceName; 56 57 /** 58 * Preference name for collapsing the active filter display. Subclasses should override this. 59 * @var string 60 */ 61 protected static $collapsedPreferenceName; 62 63 /** @var string */ 64 protected $rcSubpage; 65 66 /** @var FormOptions */ 67 protected $rcOptions; 68 69 // Order of both groups and filters is significant; first is top-most priority, 70 // descending from there. 71 // 'showHideSuffix' is a shortcut to and avoid spelling out 72 // details specific to subclasses here. 73 /** 74 * Definition information for the filters and their groups 75 * 76 * The value is $groupDefinition, a parameter to the ChangesListFilterGroup constructor. 77 * However, priority is dynamically added for the core groups, to ease maintenance. 78 * 79 * Groups are displayed to the user in the structured UI. However, if necessary, 80 * all of the filters in a group can be configured to only display on the 81 * unstuctured UI, in which case you don't need a group title. 82 * 83 * @var array 84 */ 85 private $filterGroupDefinitions; 86 87 /** 88 * @var array Same format as filterGroupDefinitions, but for a single group (reviewStatus) 89 * that is registered conditionally. 90 */ 91 private $legacyReviewStatusFilterGroupDefinition; 92 93 /** @var array Single filter group registered conditionally */ 94 private $reviewStatusFilterGroupDefinition; 95 96 /** @var array Single filter group registered conditionally */ 97 private $hideCategorizationFilterDefinition; 98 99 /** 100 * Filter groups, and their contained filters 101 * This is an associative array (with group name as key) of ChangesListFilterGroup objects. 102 * 103 * @var ChangesListFilterGroup[] 104 */ 105 protected $filterGroups = []; 106 107 public function __construct( $name, $restriction ) { 108 parent::__construct( $name, $restriction ); 109 110 $nonRevisionTypes = [ RC_LOG ]; 111 $this->getHookRunner()->onSpecialWatchlistGetNonRevisionTypes( $nonRevisionTypes ); 112 113 $this->filterGroupDefinitions = [ 114 [ 115 'name' => 'registration', 116 'title' => 'rcfilters-filtergroup-registration', 117 'class' => ChangesListBooleanFilterGroup::class, 118 'filters' => [ 119 [ 120 'name' => 'hideliu', 121 // rcshowhideliu-show, rcshowhideliu-hide, 122 // wlshowhideliu 123 'showHideSuffix' => 'showhideliu', 124 'default' => false, 125 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx, 126 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds 127 ) { 128 $conds['actor_user'] = null; 129 }, 130 'isReplacedInStructuredUi' => true, 131 132 ], 133 [ 134 'name' => 'hideanons', 135 // rcshowhideanons-show, rcshowhideanons-hide, 136 // wlshowhideanons 137 'showHideSuffix' => 'showhideanons', 138 'default' => false, 139 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx, 140 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds 141 ) { 142 $conds[] = 'actor_user IS NOT NULL'; 143 }, 144 'isReplacedInStructuredUi' => true, 145 ] 146 ], 147 ], 148 149 [ 150 'name' => 'userExpLevel', 151 'title' => 'rcfilters-filtergroup-user-experience-level', 152 'class' => ChangesListStringOptionsFilterGroup::class, 153 'isFullCoverage' => true, 154 'filters' => [ 155 [ 156 'name' => 'unregistered', 157 'label' => 'rcfilters-filter-user-experience-level-unregistered-label', 158 'description' => 'rcfilters-filter-user-experience-level-unregistered-description', 159 'cssClassSuffix' => 'user-unregistered', 160 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 161 return !$rc->getAttribute( 'rc_user' ); 162 } 163 ], 164 [ 165 'name' => 'registered', 166 'label' => 'rcfilters-filter-user-experience-level-registered-label', 167 'description' => 'rcfilters-filter-user-experience-level-registered-description', 168 'cssClassSuffix' => 'user-registered', 169 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 170 return $rc->getAttribute( 'rc_user' ); 171 } 172 ], 173 [ 174 'name' => 'newcomer', 175 'label' => 'rcfilters-filter-user-experience-level-newcomer-label', 176 'description' => 'rcfilters-filter-user-experience-level-newcomer-description', 177 'cssClassSuffix' => 'user-newcomer', 178 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 179 $performer = User::newFromIdentity( $rc->getPerformerIdentity() ); 180 return $performer && $performer->isRegistered() && 181 $performer->getExperienceLevel() === 'newcomer'; 182 } 183 ], 184 [ 185 'name' => 'learner', 186 'label' => 'rcfilters-filter-user-experience-level-learner-label', 187 'description' => 'rcfilters-filter-user-experience-level-learner-description', 188 'cssClassSuffix' => 'user-learner', 189 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 190 $performer = User::newFromIdentity( $rc->getPerformerIdentity() ); 191 return $performer && $performer->isRegistered() && 192 $performer->getExperienceLevel() === 'learner'; 193 }, 194 ], 195 [ 196 'name' => 'experienced', 197 'label' => 'rcfilters-filter-user-experience-level-experienced-label', 198 'description' => 'rcfilters-filter-user-experience-level-experienced-description', 199 'cssClassSuffix' => 'user-experienced', 200 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 201 $performer = User::newFromIdentity( $rc->getPerformerIdentity() ); 202 return $performer && $performer->isRegistered() && 203 $performer->getExperienceLevel() === 'experienced'; 204 }, 205 ] 206 ], 207 'default' => ChangesListStringOptionsFilterGroup::NONE, 208 'queryCallable' => [ $this, 'filterOnUserExperienceLevel' ], 209 ], 210 211 [ 212 'name' => 'authorship', 213 'title' => 'rcfilters-filtergroup-authorship', 214 'class' => ChangesListBooleanFilterGroup::class, 215 'filters' => [ 216 [ 217 'name' => 'hidemyself', 218 'label' => 'rcfilters-filter-editsbyself-label', 219 'description' => 'rcfilters-filter-editsbyself-description', 220 // rcshowhidemine-show, rcshowhidemine-hide, 221 // wlshowhidemine 222 'showHideSuffix' => 'showhidemine', 223 'default' => false, 224 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx, 225 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds 226 ) { 227 $user = $ctx->getUser(); 228 $conds[] = 'actor_name<>' . $dbr->addQuotes( $user->getName() ); 229 }, 230 'cssClassSuffix' => 'self', 231 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 232 return $ctx->getUser()->equals( $rc->getPerformerIdentity() ); 233 }, 234 ], 235 [ 236 'name' => 'hidebyothers', 237 'label' => 'rcfilters-filter-editsbyother-label', 238 'description' => 'rcfilters-filter-editsbyother-description', 239 'default' => false, 240 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx, 241 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds 242 ) { 243 $user = $ctx->getUser(); 244 if ( $user->isAnon() ) { 245 $conds['actor_name'] = $user->getName(); 246 } else { 247 $conds['actor_user'] = $user->getId(); 248 } 249 }, 250 'cssClassSuffix' => 'others', 251 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 252 return !$ctx->getUser()->equals( $rc->getPerformerIdentity() ); 253 }, 254 ] 255 ] 256 ], 257 258 [ 259 'name' => 'automated', 260 'title' => 'rcfilters-filtergroup-automated', 261 'class' => ChangesListBooleanFilterGroup::class, 262 'filters' => [ 263 [ 264 'name' => 'hidebots', 265 'label' => 'rcfilters-filter-bots-label', 266 'description' => 'rcfilters-filter-bots-description', 267 // rcshowhidebots-show, rcshowhidebots-hide, 268 // wlshowhidebots 269 'showHideSuffix' => 'showhidebots', 270 'default' => false, 271 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx, 272 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds 273 ) { 274 $conds['rc_bot'] = 0; 275 }, 276 'cssClassSuffix' => 'bot', 277 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 278 return $rc->getAttribute( 'rc_bot' ); 279 }, 280 ], 281 [ 282 'name' => 'hidehumans', 283 'label' => 'rcfilters-filter-humans-label', 284 'description' => 'rcfilters-filter-humans-description', 285 'default' => false, 286 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx, 287 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds 288 ) { 289 $conds['rc_bot'] = 1; 290 }, 291 'cssClassSuffix' => 'human', 292 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 293 return !$rc->getAttribute( 'rc_bot' ); 294 }, 295 ] 296 ] 297 ], 298 299 // significance (conditional) 300 301 [ 302 'name' => 'significance', 303 'title' => 'rcfilters-filtergroup-significance', 304 'class' => ChangesListBooleanFilterGroup::class, 305 'priority' => -6, 306 'filters' => [ 307 [ 308 'name' => 'hideminor', 309 'label' => 'rcfilters-filter-minor-label', 310 'description' => 'rcfilters-filter-minor-description', 311 // rcshowhideminor-show, rcshowhideminor-hide, 312 // wlshowhideminor 313 'showHideSuffix' => 'showhideminor', 314 'default' => false, 315 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx, 316 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds 317 ) { 318 $conds[] = 'rc_minor = 0'; 319 }, 320 'cssClassSuffix' => 'minor', 321 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 322 return $rc->getAttribute( 'rc_minor' ); 323 } 324 ], 325 [ 326 'name' => 'hidemajor', 327 'label' => 'rcfilters-filter-major-label', 328 'description' => 'rcfilters-filter-major-description', 329 'default' => false, 330 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx, 331 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds 332 ) { 333 $conds[] = 'rc_minor = 1'; 334 }, 335 'cssClassSuffix' => 'major', 336 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 337 return !$rc->getAttribute( 'rc_minor' ); 338 } 339 ] 340 ] 341 ], 342 343 [ 344 'name' => 'lastRevision', 345 'title' => 'rcfilters-filtergroup-lastrevision', 346 'class' => ChangesListBooleanFilterGroup::class, 347 'priority' => -7, 348 'filters' => [ 349 [ 350 'name' => 'hidelastrevision', 351 'label' => 'rcfilters-filter-lastrevision-label', 352 'description' => 'rcfilters-filter-lastrevision-description', 353 'default' => false, 354 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx, 355 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds 356 ) use ( $nonRevisionTypes ) { 357 $conds[] = $dbr->makeList( 358 [ 359 'rc_this_oldid <> page_latest', 360 'rc_type' => $nonRevisionTypes, 361 ], 362 LIST_OR 363 ); 364 }, 365 'cssClassSuffix' => 'last', 366 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 367 return $rc->getAttribute( 'rc_this_oldid' ) === $rc->getAttribute( 'page_latest' ); 368 } 369 ], 370 [ 371 'name' => 'hidepreviousrevisions', 372 'label' => 'rcfilters-filter-previousrevision-label', 373 'description' => 'rcfilters-filter-previousrevision-description', 374 'default' => false, 375 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx, 376 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds 377 ) use ( $nonRevisionTypes ) { 378 $conds[] = $dbr->makeList( 379 [ 380 'rc_this_oldid = page_latest', 381 'rc_type' => $nonRevisionTypes, 382 ], 383 LIST_OR 384 ); 385 }, 386 'cssClassSuffix' => 'previous', 387 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 388 return $rc->getAttribute( 'rc_this_oldid' ) !== $rc->getAttribute( 'page_latest' ); 389 } 390 ] 391 ] 392 ], 393 394 // With extensions, there can be change types that will not be hidden by any of these. 395 [ 396 'name' => 'changeType', 397 'title' => 'rcfilters-filtergroup-changetype', 398 'class' => ChangesListBooleanFilterGroup::class, 399 'priority' => -8, 400 'filters' => [ 401 [ 402 'name' => 'hidepageedits', 403 'label' => 'rcfilters-filter-pageedits-label', 404 'description' => 'rcfilters-filter-pageedits-description', 405 'default' => false, 406 'priority' => -2, 407 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx, 408 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds 409 ) { 410 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_EDIT ); 411 }, 412 'cssClassSuffix' => 'src-mw-edit', 413 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 414 return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_EDIT; 415 }, 416 ], 417 [ 418 'name' => 'hidenewpages', 419 'label' => 'rcfilters-filter-newpages-label', 420 'description' => 'rcfilters-filter-newpages-description', 421 'default' => false, 422 'priority' => -3, 423 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx, 424 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds 425 ) { 426 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_NEW ); 427 }, 428 'cssClassSuffix' => 'src-mw-new', 429 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 430 return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_NEW; 431 }, 432 ], 433 434 // hidecategorization 435 436 [ 437 'name' => 'hidelog', 438 'label' => 'rcfilters-filter-logactions-label', 439 'description' => 'rcfilters-filter-logactions-description', 440 'default' => false, 441 'priority' => -5, 442 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx, 443 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds 444 ) { 445 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_LOG ); 446 }, 447 'cssClassSuffix' => 'src-mw-log', 448 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 449 return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_LOG; 450 } 451 ], 452 ], 453 ], 454 455 ]; 456 457 $this->legacyReviewStatusFilterGroupDefinition = [ 458 [ 459 'name' => 'legacyReviewStatus', 460 'title' => 'rcfilters-filtergroup-reviewstatus', 461 'class' => ChangesListBooleanFilterGroup::class, 462 'filters' => [ 463 [ 464 'name' => 'hidepatrolled', 465 // rcshowhidepatr-show, rcshowhidepatr-hide 466 // wlshowhidepatr 467 'showHideSuffix' => 'showhidepatr', 468 'default' => false, 469 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx, 470 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds 471 ) { 472 $conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED; 473 }, 474 'isReplacedInStructuredUi' => true, 475 ], 476 [ 477 'name' => 'hideunpatrolled', 478 'default' => false, 479 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx, 480 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds 481 ) { 482 $conds[] = 'rc_patrolled != ' . RecentChange::PRC_UNPATROLLED; 483 }, 484 'isReplacedInStructuredUi' => true, 485 ], 486 ], 487 ] 488 ]; 489 490 $this->reviewStatusFilterGroupDefinition = [ 491 [ 492 'name' => 'reviewStatus', 493 'title' => 'rcfilters-filtergroup-reviewstatus', 494 'class' => ChangesListStringOptionsFilterGroup::class, 495 'isFullCoverage' => true, 496 'priority' => -5, 497 'filters' => [ 498 [ 499 'name' => 'unpatrolled', 500 'label' => 'rcfilters-filter-reviewstatus-unpatrolled-label', 501 'description' => 'rcfilters-filter-reviewstatus-unpatrolled-description', 502 'cssClassSuffix' => 'reviewstatus-unpatrolled', 503 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 504 return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED; 505 }, 506 ], 507 [ 508 'name' => 'manual', 509 'label' => 'rcfilters-filter-reviewstatus-manual-label', 510 'description' => 'rcfilters-filter-reviewstatus-manual-description', 511 'cssClassSuffix' => 'reviewstatus-manual', 512 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 513 return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_PATROLLED; 514 }, 515 ], 516 [ 517 'name' => 'auto', 518 'label' => 'rcfilters-filter-reviewstatus-auto-label', 519 'description' => 'rcfilters-filter-reviewstatus-auto-description', 520 'cssClassSuffix' => 'reviewstatus-auto', 521 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 522 return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_AUTOPATROLLED; 523 }, 524 ], 525 ], 526 'default' => ChangesListStringOptionsFilterGroup::NONE, 527 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx, 528 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selected 529 ) { 530 if ( $selected === [] ) { 531 return; 532 } 533 $rcPatrolledValues = [ 534 'unpatrolled' => RecentChange::PRC_UNPATROLLED, 535 'manual' => RecentChange::PRC_PATROLLED, 536 'auto' => RecentChange::PRC_AUTOPATROLLED, 537 ]; 538 // e.g. rc_patrolled IN (0, 2) 539 $conds['rc_patrolled'] = array_map( static function ( $s ) use ( $rcPatrolledValues ) { 540 return $rcPatrolledValues[ $s ]; 541 }, $selected ); 542 } 543 ] 544 ]; 545 546 $this->hideCategorizationFilterDefinition = [ 547 'name' => 'hidecategorization', 548 'label' => 'rcfilters-filter-categorization-label', 549 'description' => 'rcfilters-filter-categorization-description', 550 // rcshowhidecategorization-show, rcshowhidecategorization-hide. 551 // wlshowhidecategorization 552 'showHideSuffix' => 'showhidecategorization', 553 'default' => false, 554 'priority' => -4, 555 'queryCallable' => static function ( string $specialClassName, IContextSource $ctx, 556 IDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds 557 ) { 558 $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_CATEGORIZE ); 559 }, 560 'cssClassSuffix' => 'src-mw-categorize', 561 'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) { 562 return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_CATEGORIZE; 563 }, 564 ]; 565 } 566 567 /** 568 * Check if filters are in conflict and guaranteed to return no results. 569 * 570 * @return bool 571 */ 572 protected function areFiltersInConflict() { 573 $opts = $this->getOptions(); 574 /** @var ChangesListFilterGroup $group */ 575 foreach ( $this->getFilterGroups() as $group ) { 576 if ( $group->getConflictingGroups() ) { 577 wfLogWarning( 578 $group->getName() . 579 " specifies conflicts with other groups but these are not supported yet." 580 ); 581 } 582 583 /** @var ChangesListFilter $conflictingFilter */ 584 foreach ( $group->getConflictingFilters() as $conflictingFilter ) { 585 if ( $conflictingFilter->activelyInConflictWithGroup( $group, $opts ) ) { 586 return true; 587 } 588 } 589 590 /** @var ChangesListFilter $filter */ 591 foreach ( $group->getFilters() as $filter ) { 592 /** @var ChangesListFilter $conflictingFilter */ 593 foreach ( $filter->getConflictingFilters() as $conflictingFilter ) { 594 if ( 595 $conflictingFilter->activelyInConflictWithFilter( $filter, $opts ) && 596 $filter->activelyInConflictWithFilter( $conflictingFilter, $opts ) 597 ) { 598 return true; 599 } 600 } 601 602 } 603 604 } 605 606 return false; 607 } 608 609 /** 610 * @param string|null $subpage 611 */ 612 public function execute( $subpage ) { 613 $this->rcSubpage = $subpage; 614 615 $this->considerActionsForDefaultSavedQuery( $subpage ); 616 617 // Enable OOUI and module for the clock icon. 618 if ( $this->getConfig()->get( 'WatchlistExpiry' ) ) { 619 $this->getOutput()->enableOOUI(); 620 $this->getOutput()->addModules( 'mediawiki.special.changeslist.watchlistexpiry' ); 621 } 622 623 $opts = $this->getOptions(); 624 try { 625 $rows = $this->getRows(); 626 if ( $rows === false ) { 627 $rows = new FakeResultWrapper( [] ); 628 } 629 630 // Used by Structured UI app to get results without MW chrome 631 if ( $this->getRequest()->getRawVal( 'action' ) === 'render' ) { 632 $this->getOutput()->setArticleBodyOnly( true ); 633 } 634 635 // Used by "live update" and "view newest" to check 636 // if there's new changes with minimal data transfer 637 if ( $this->getRequest()->getBool( 'peek' ) ) { 638 $code = $rows->numRows() > 0 ? 200 : 204; 639 $this->getOutput()->setStatusCode( $code ); 640 641 if ( $this->getUser()->isAnon() !== 642 $this->getRequest()->getFuzzyBool( 'isAnon' ) 643 ) { 644 $this->getOutput()->setStatusCode( 205 ); 645 } 646 647 return; 648 } 649 650 $linkBatchFactory = MediaWikiServices::getInstance()->getLinkBatchFactory(); 651 $batch = $linkBatchFactory->newLinkBatch(); 652 foreach ( $rows as $row ) { 653 $batch->add( NS_USER, $row->rc_user_text ); 654 $batch->add( NS_USER_TALK, $row->rc_user_text ); 655 $batch->add( $row->rc_namespace, $row->rc_title ); 656 if ( $row->rc_source === RecentChange::SRC_LOG ) { 657 $formatter = LogFormatter::newFromRow( $row ); 658 foreach ( $formatter->getPreloadTitles() as $title ) { 659 $batch->addObj( $title ); 660 } 661 } 662 } 663 $batch->execute(); 664 665 $this->setHeaders(); 666 $this->outputHeader(); 667 $this->addModules(); 668 $this->webOutput( $rows, $opts ); 669 670 $rows->free(); 671 } catch ( DBQueryTimeoutError $timeoutException ) { 672 MWExceptionHandler::logException( $timeoutException ); 673 674 $this->setHeaders(); 675 $this->outputHeader(); 676 $this->addModules(); 677 678 $this->getOutput()->setStatusCode( 500 ); 679 $this->webOutputHeader( 0, $opts ); 680 $this->outputTimeout(); 681 } 682 683 if ( $this->getConfig()->get( 'EnableWANCacheReaper' ) ) { 684 // Clean up any bad page entries for titles showing up in RC 685 DeferredUpdates::addUpdate( new WANCacheReapUpdate( 686 $this->getDB(), 687 LoggerFactory::getInstance( 'objectcache' ) 688 ) ); 689 } 690 691 $this->includeRcFiltersApp(); 692 } 693 694 /** 695 * Check whether or not the page should load defaults, and if so, whether 696 * a default saved query is relevant to be redirected to. If it is relevant, 697 * redirect properly with all necessary query parameters. 698 * 699 * @param string $subpage 700 */ 701 protected function considerActionsForDefaultSavedQuery( $subpage ) { 702 if ( !$this->isStructuredFilterUiEnabled() || $this->including() ) { 703 return; 704 } 705 706 $knownParams = $this->getRequest()->getValues( 707 ...array_keys( $this->getOptions()->getAllValues() ) 708 ); 709 710 // HACK: Temporarily until we can properly define "sticky" filters and parameters, 711 // we need to exclude several parameters we know should not be counted towards preventing 712 // the loading of defaults. 713 $excludedParams = [ 'limit' => '', 'days' => '', 'enhanced' => '', 'from' => '' ]; 714 $knownParams = array_diff_key( $knownParams, $excludedParams ); 715 716 if ( 717 // If there are NO known parameters in the URL request 718 // (that are not excluded) then we need to check into loading 719 // the default saved query 720 count( $knownParams ) === 0 721 ) { 722 // Get the saved queries data and parse it 723 $savedQueries = FormatJson::decode( 724 $this->getUser()->getOption( static::$savedQueriesPreferenceName ), 725 true 726 ); 727 728 if ( $savedQueries && isset( $savedQueries[ 'default' ] ) ) { 729 // Only load queries that are 'version' 2, since those 730 // have parameter representation 731 if ( isset( $savedQueries[ 'version' ] ) && $savedQueries[ 'version' ] === '2' ) { 732 $savedQueryDefaultID = $savedQueries[ 'default' ]; 733 $defaultQuery = $savedQueries[ 'queries' ][ $savedQueryDefaultID ][ 'data' ]; 734 735 // Build the entire parameter list 736 $query = array_merge( 737 $defaultQuery[ 'params' ], 738 $defaultQuery[ 'highlights' ], 739 [ 740 'urlversion' => '2', 741 ] 742 ); 743 // Add to the query any parameters that we may have ignored before 744 // but are still valid and requested in the URL 745 $query = array_merge( $this->getRequest()->getValues(), $query ); 746 unset( $query[ 'title' ] ); 747 $this->getOutput()->redirect( $this->getPageTitle( $subpage )->getCanonicalURL( $query ) ); 748 } else { 749 // There's a default, but the version is not 2, and the server can't 750 // actually recognize the query itself. This happens if it is before 751 // the conversion, so we need to tell the UI to reload saved query as 752 // it does the conversion to version 2 753 $this->getOutput()->addJsConfigVars( 754 'wgStructuredChangeFiltersDefaultSavedQueryExists', 755 true 756 ); 757 758 // Add the class that tells the frontend it is still loading 759 // another query 760 $this->getOutput()->addBodyClasses( 'mw-rcfilters-ui-loading' ); 761 } 762 } 763 } 764 } 765 766 /** 767 * @see $wgRCLinkDays in DefaultSettings.php. 768 * @see $wgRCFilterByAge in DefaultSettings.php. 769 * @return int[] 770 */ 771 protected function getLinkDays() { 772 $linkDays = $this->getConfig()->get( 'RCLinkDays' ); 773 $filterByAge = $this->getConfig()->get( 'RCFilterByAge' ); 774 $maxAge = $this->getConfig()->get( 'RCMaxAge' ); 775 if ( $filterByAge ) { 776 // Trim it to only links which are within $wgRCMaxAge. 777 // Note that we allow one link higher than the max for things like 778 // "age 56 days" being accessible through the "60 days" link. 779 sort( $linkDays ); 780 781 $maxAgeDays = $maxAge / ( 3600 * 24 ); 782 foreach ( $linkDays as $i => $days ) { 783 if ( $days >= $maxAgeDays ) { 784 array_splice( $linkDays, $i + 1 ); 785 break; 786 } 787 } 788 } 789 790 return $linkDays; 791 } 792 793 /** 794 * Include the modules and configuration for the RCFilters app. 795 * Conditional on the user having the feature enabled. 796 * 797 * If it is disabled, add a <body> class marking that 798 */ 799 protected function includeRcFiltersApp() { 800 $out = $this->getOutput(); 801 if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) { 802 $jsData = $this->getStructuredFilterJsData(); 803 $messages = []; 804 foreach ( $jsData['messageKeys'] as $key ) { 805 $messages[$key] = $this->msg( $key )->plain(); 806 } 807 808 $out->addBodyClasses( 'mw-rcfilters-enabled' ); 809 $collapsed = MediaWikiServices::getInstance()->getUserOptionsLookup() 810 ->getBoolOption( $this->getUser(), static::$collapsedPreferenceName ); 811 if ( $collapsed ) { 812 $out->addBodyClasses( 'mw-rcfilters-collapsed' ); 813 } 814 815 // These config and message exports should be moved into a ResourceLoader data module (T201574) 816 $out->addJsConfigVars( 'wgStructuredChangeFilters', $jsData['groups'] ); 817 $out->addJsConfigVars( 'wgStructuredChangeFiltersMessages', $messages ); 818 $out->addJsConfigVars( 'wgStructuredChangeFiltersCollapsedState', $collapsed ); 819 820 $out->addJsConfigVars( 821 'StructuredChangeFiltersDisplayConfig', 822 [ 823 'maxDays' => (int)$this->getConfig()->get( 'RCMaxAge' ) / ( 24 * 3600 ), // Translate to days 824 'limitArray' => $this->getConfig()->get( 'RCLinkLimits' ), 825 'limitDefault' => $this->getDefaultLimit(), 826 'daysArray' => $this->getLinkDays(), 827 'daysDefault' => $this->getDefaultDays(), 828 ] 829 ); 830 831 $out->addJsConfigVars( 832 'wgStructuredChangeFiltersSavedQueriesPreferenceName', 833 static::$savedQueriesPreferenceName 834 ); 835 $out->addJsConfigVars( 836 'wgStructuredChangeFiltersLimitPreferenceName', 837 $this->getLimitPreferenceName() 838 ); 839 $out->addJsConfigVars( 840 'wgStructuredChangeFiltersDaysPreferenceName', 841 static::$daysPreferenceName 842 ); 843 $out->addJsConfigVars( 844 'wgStructuredChangeFiltersCollapsedPreferenceName', 845 static::$collapsedPreferenceName 846 ); 847 } else { 848 $out->addBodyClasses( 'mw-rcfilters-disabled' ); 849 } 850 } 851 852 /** 853 * Get essential data about getRcFiltersConfigVars() for change detection. 854 * 855 * @internal For use by Resources.php only. 856 * @see ResourceLoaderModule::getDefinitionSummary() and ResourceLoaderModule::getVersionHash() 857 * @param ResourceLoaderContext $context 858 * @return array 859 */ 860 public static function getRcFiltersConfigSummary( ResourceLoaderContext $context ) { 861 return [ 862 // Reduce version computation by avoiding Message parsing 863 'RCFiltersChangeTags' => self::getChangeTagListSummary( $context ), 864 'StructuredChangeFiltersEditWatchlistUrl' => 865 SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL() 866 ]; 867 } 868 869 /** 870 * Get config vars to export with the mediawiki.rcfilters.filters.ui module. 871 * 872 * @internal For use by Resources.php only. 873 * @param ResourceLoaderContext $context 874 * @return array 875 */ 876 public static function getRcFiltersConfigVars( ResourceLoaderContext $context ) { 877 return [ 878 'RCFiltersChangeTags' => self::getChangeTagList( $context ), 879 'StructuredChangeFiltersEditWatchlistUrl' => 880 SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL() 881 ]; 882 } 883 884 /** 885 * Get information about change tags, without parsing messages, for getRcFiltersConfigSummary(). 886 * 887 * Message contents are the raw values (->plain()), because parsing messages is expensive. 888 * Even though we're not parsing messages, building a data structure with the contents of 889 * hundreds of i18n messages is still not cheap (see T223260#5370610), so the result of this 890 * function is cached in WANCache for 24 hours. 891 * 892 * Returns an array of associative arrays with information about each tag: 893 * - name: Tag name (string) 894 * - labelMsg: Short description message (Message object, or false for hidden tags) 895 * - label: Short description message (raw message contents) 896 * - descriptionMsg: Long description message (Message object) 897 * - description: Long description message (raw message contents) 898 * - cssClass: CSS class to use for RC entries with this tag 899 * - hits: Number of RC entries that have this tag 900 * 901 * @param ResourceLoaderContext $context 902 * @return array[] Information about each tag 903 */ 904 protected static function getChangeTagListSummary( ResourceLoaderContext $context ) { 905 $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); 906 return $cache->getWithSetCallback( 907 $cache->makeKey( 'ChangesListSpecialPage-changeTagListSummary', $context->getLanguage() ), 908 WANObjectCache::TTL_DAY, 909 static function ( $oldValue, &$ttl, array &$setOpts ) use ( $context ) { 910 $explicitlyDefinedTags = array_fill_keys( ChangeTags::listExplicitlyDefinedTags(), 0 ); 911 $softwareActivatedTags = array_fill_keys( ChangeTags::listSoftwareActivatedTags(), 0 ); 912 913 $tagStats = ChangeTags::tagUsageStatistics(); 914 $tagHitCounts = array_merge( $explicitlyDefinedTags, $softwareActivatedTags, $tagStats ); 915 916 $result = []; 917 foreach ( $tagHitCounts as $tagName => $hits ) { 918 if ( 919 ( 920 // Only get active tags 921 isset( $explicitlyDefinedTags[ $tagName ] ) || 922 isset( $softwareActivatedTags[ $tagName ] ) 923 ) && 924 // Only get tags with more than 0 hits 925 $hits > 0 926 ) { 927 $labelMsg = ChangeTags::tagShortDescriptionMessage( $tagName, $context ); 928 $descriptionMsg = ChangeTags::tagLongDescriptionMessage( $tagName, $context ); 929 $result[] = [ 930 'name' => $tagName, 931 'labelMsg' => $labelMsg, 932 'label' => $labelMsg ? $labelMsg->plain() : $tagName, 933 'descriptionMsg' => $descriptionMsg, 934 'description' => $descriptionMsg ? $descriptionMsg->plain() : '', 935 'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ), 936 'hits' => $hits, 937 ]; 938 } 939 } 940 return $result; 941 } 942 ); 943 } 944 945 /** 946 * Get information about change tags to export to JS via getRcFiltersConfigVars(). 947 * 948 * This manipulates the label and description of each tag, which are parsed, stripped 949 * and (in the case of description) truncated versions of these messages. Message 950 * parsing is expensive, so to detect whether the tag list has changed, use 951 * getChangeTagListSummary() instead. 952 * 953 * The result of this function is cached in WANCache for 24 hours. 954 * 955 * @param ResourceLoaderContext $context 956 * @return array[] Same as getChangeTagListSummary(), with messages parsed, stripped and truncated 957 */ 958 protected static function getChangeTagList( ResourceLoaderContext $context ) { 959 $tags = self::getChangeTagListSummary( $context ); 960 $language = MediaWikiServices::getInstance()->getLanguageFactory() 961 ->getLanguage( $context->getLanguage() ); 962 foreach ( $tags as &$tagInfo ) { 963 if ( $tagInfo['labelMsg'] ) { 964 $tagInfo['label'] = Sanitizer::stripAllTags( $tagInfo['labelMsg']->parse() ); 965 } else { 966 $tagInfo['label'] = $context->msg( 'rcfilters-tag-hidden', $tagInfo['name'] )->text(); 967 } 968 $tagInfo['description'] = $tagInfo['descriptionMsg'] ? 969 $language->truncateForVisual( 970 Sanitizer::stripAllTags( $tagInfo['descriptionMsg']->parse() ), 971 self::TAG_DESC_CHARACTER_LIMIT 972 ) : 973 ''; 974 unset( $tagInfo['labelMsg'] ); 975 unset( $tagInfo['descriptionMsg'] ); 976 } 977 978 // Instead of sorting by hit count (disabled for now), sort by display name 979 usort( $tags, static function ( $a, $b ) { 980 return strcasecmp( $a['label'], $b['label'] ); 981 } ); 982 return $tags; 983 } 984 985 /** 986 * Add the "no results" message to the output 987 */ 988 protected function outputNoResults() { 989 $this->getOutput()->addHTML( 990 Html::rawElement( 991 'div', 992 [ 'class' => 'mw-changeslist-empty' ], 993 $this->msg( 'recentchanges-noresult' )->parse() 994 ) 995 ); 996 } 997 998 /** 999 * Add the "timeout" message to the output 1000 */ 1001 protected function outputTimeout() { 1002 $this->getOutput()->addHTML( 1003 '<div class="mw-changeslist-empty mw-changeslist-timeout">' . 1004 $this->msg( 'recentchanges-timeout' )->parse() . 1005 '</div>' 1006 ); 1007 } 1008 1009 /** 1010 * Get the database result for this special page instance. Used by ApiFeedRecentChanges. 1011 * 1012 * @return IResultWrapper|false 1013 */ 1014 public function getRows() { 1015 $opts = $this->getOptions(); 1016 1017 $tables = []; 1018 $fields = []; 1019 $conds = []; 1020 $query_options = []; 1021 $join_conds = []; 1022 $this->buildQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts ); 1023 1024 return $this->doMainQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts ); 1025 } 1026 1027 /** 1028 * Get the current FormOptions for this request 1029 * 1030 * @return FormOptions 1031 */ 1032 public function getOptions() { 1033 if ( $this->rcOptions === null ) { 1034 $this->rcOptions = $this->setup( $this->rcSubpage ); 1035 } 1036 1037 return $this->rcOptions; 1038 } 1039 1040 /** 1041 * Register all filters and their groups (including those from hooks), plus handle 1042 * conflicts and defaults. 1043 * 1044 * You might want to customize these in the same method, in subclasses. You can 1045 * call getFilterGroup to access a group, and (on the group) getFilter to access a 1046 * filter, then make necessary modfications to the filter or group (e.g. with 1047 * setDefault). 1048 */ 1049 protected function registerFilters() { 1050 $this->registerFiltersFromDefinitions( $this->filterGroupDefinitions ); 1051 1052 // Make sure this is not being transcluded (we don't want to show this 1053 // information to all users just because the user that saves the edit can 1054 // patrol or is logged in) 1055 if ( !$this->including() && $this->getUser()->useRCPatrol() ) { 1056 $this->registerFiltersFromDefinitions( $this->legacyReviewStatusFilterGroupDefinition ); 1057 $this->registerFiltersFromDefinitions( $this->reviewStatusFilterGroupDefinition ); 1058 } 1059 1060 $changeTypeGroup = $this->getFilterGroup( 'changeType' ); 1061 1062 if ( $this->getConfig()->get( 'RCWatchCategoryMembership' ) ) { 1063 $transformedHideCategorizationDef = $this->transformFilterDefinition( 1064 $this->hideCategorizationFilterDefinition 1065 ); 1066 1067 $transformedHideCategorizationDef['group'] = $changeTypeGroup; 1068 1069 $hideCategorization = new ChangesListBooleanFilter( 1070 $transformedHideCategorizationDef 1071 ); 1072 } 1073 1074 $this->getHookRunner()->onChangesListSpecialPageStructuredFilters( $this ); 1075 1076 $this->registerFiltersFromDefinitions( [] ); 1077 1078 $userExperienceLevel = $this->getFilterGroup( 'userExpLevel' ); 1079 $registered = $userExperienceLevel->getFilter( 'registered' ); 1080 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'newcomer' ) ); 1081 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'learner' ) ); 1082 $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'experienced' ) ); 1083 1084 $categoryFilter = $changeTypeGroup->getFilter( 'hidecategorization' ); 1085 $logactionsFilter = $changeTypeGroup->getFilter( 'hidelog' ); 1086 $pagecreationFilter = $changeTypeGroup->getFilter( 'hidenewpages' ); 1087 1088 $significanceTypeGroup = $this->getFilterGroup( 'significance' ); 1089 $hideMinorFilter = $significanceTypeGroup->getFilter( 'hideminor' ); 1090 1091 // categoryFilter is conditional; see registerFilters 1092 if ( $categoryFilter !== null ) { 1093 $hideMinorFilter->conflictsWith( 1094 $categoryFilter, 1095 'rcfilters-hideminor-conflicts-typeofchange-global', 1096 'rcfilters-hideminor-conflicts-typeofchange', 1097 'rcfilters-typeofchange-conflicts-hideminor' 1098 ); 1099 } 1100 $hideMinorFilter->conflictsWith( 1101 $logactionsFilter, 1102 'rcfilters-hideminor-conflicts-typeofchange-global', 1103 'rcfilters-hideminor-conflicts-typeofchange', 1104 'rcfilters-typeofchange-conflicts-hideminor' 1105 ); 1106 $hideMinorFilter->conflictsWith( 1107 $pagecreationFilter, 1108 'rcfilters-hideminor-conflicts-typeofchange-global', 1109 'rcfilters-hideminor-conflicts-typeofchange', 1110 'rcfilters-typeofchange-conflicts-hideminor' 1111 ); 1112 } 1113 1114 /** 1115 * Transforms filter definition to prepare it for constructor. 1116 * 1117 * See overrides of this method as well. 1118 * 1119 * @param array $filterDefinition Original filter definition 1120 * 1121 * @return array Transformed definition 1122 */ 1123 protected function transformFilterDefinition( array $filterDefinition ) { 1124 return $filterDefinition; 1125 } 1126 1127 /** 1128 * Register filters from a definition object 1129 * 1130 * Array specifying groups and their filters; see Filter and 1131 * ChangesListFilterGroup constructors. 1132 * 1133 * There is light processing to simplify core maintenance. 1134 * @param array $definition 1135 * @phan-param array<int,array{class:class-string<ChangesListFilterGroup>,filters:array}> $definition 1136 */ 1137 protected function registerFiltersFromDefinitions( array $definition ) { 1138 $autoFillPriority = -1; 1139 foreach ( $definition as $groupDefinition ) { 1140 if ( !isset( $groupDefinition['priority'] ) ) { 1141 $groupDefinition['priority'] = $autoFillPriority; 1142 } else { 1143 // If it's explicitly specified, start over the auto-fill 1144 $autoFillPriority = $groupDefinition['priority']; 1145 } 1146 1147 $autoFillPriority--; 1148 1149 $className = $groupDefinition['class']; 1150 unset( $groupDefinition['class'] ); 1151 1152 foreach ( $groupDefinition['filters'] as &$filterDefinition ) { 1153 $filterDefinition = $this->transformFilterDefinition( $filterDefinition ); 1154 } 1155 1156 $this->registerFilterGroup( new $className( $groupDefinition ) ); 1157 } 1158 } 1159 1160 /** 1161 * @return ChangesListBooleanFilter[] The legacy show/hide toggle filters 1162 */ 1163 protected function getLegacyShowHideFilters() { 1164 $filters = []; 1165 foreach ( $this->filterGroups as $group ) { 1166 if ( $group instanceof ChangesListBooleanFilterGroup ) { 1167 foreach ( $group->getFilters() as $key => $filter ) { 1168 if ( $filter->displaysOnUnstructuredUi() ) { 1169 $filters[ $key ] = $filter; 1170 } 1171 } 1172 } 1173 } 1174 return $filters; 1175 } 1176 1177 /** 1178 * Register all the filters, including legacy hook-driven ones. 1179 * Then create a FormOptions object with options as specified by the user 1180 * 1181 * @param string $parameters 1182 * 1183 * @return FormOptions 1184 */ 1185 public function setup( $parameters ) { 1186 $this->registerFilters(); 1187 1188 $opts = $this->getDefaultOptions(); 1189 1190 $opts = $this->fetchOptionsFromRequest( $opts ); 1191 1192 // Give precedence to subpage syntax 1193 if ( $parameters !== null ) { 1194 $this->parseParameters( $parameters, $opts ); 1195 } 1196 1197 $this->validateOptions( $opts ); 1198 1199 return $opts; 1200 } 1201 1202 /** 1203 * Get a FormOptions object containing the default options. By default, returns 1204 * some basic options. The filters listed explicitly here are overriden in this 1205 * method, in subclasses, but most filters (e.g. hideminor, userExpLevel filters, 1206 * and more) are structured. Structured filters are overriden in registerFilters. 1207 * not here. 1208 * 1209 * @return FormOptions 1210 */ 1211 public function getDefaultOptions() { 1212 $opts = new FormOptions(); 1213 $structuredUI = $this->isStructuredFilterUiEnabled(); 1214 // If urlversion=2 is set, ignore the filter defaults and set them all to false/empty 1215 $useDefaults = $this->getRequest()->getInt( 'urlversion' ) !== 2; 1216 1217 /** @var ChangesListFilterGroup $filterGroup */ 1218 foreach ( $this->filterGroups as $filterGroup ) { 1219 $filterGroup->addOptions( $opts, $useDefaults, $structuredUI ); 1220 } 1221 1222 $opts->add( 'namespace', '', FormOptions::STRING ); 1223 $opts->add( 'invert', false ); 1224 $opts->add( 'associated', false ); 1225 $opts->add( 'urlversion', 1 ); 1226 $opts->add( 'tagfilter', '' ); 1227 1228 $opts->add( 'days', $this->getDefaultDays(), FormOptions::FLOAT ); 1229 $opts->add( 'limit', $this->getDefaultLimit(), FormOptions::INT ); 1230 1231 $opts->add( 'from', '' ); 1232 1233 return $opts; 1234 } 1235 1236 /** 1237 * Register a structured changes list filter group 1238 * 1239 * @param ChangesListFilterGroup $group 1240 */ 1241 public function registerFilterGroup( ChangesListFilterGroup $group ) { 1242 $groupName = $group->getName(); 1243 1244 $this->filterGroups[$groupName] = $group; 1245 } 1246 1247 /** 1248 * Gets the currently registered filters groups 1249 * 1250 * @return ChangesListFilterGroup[] Associative array of ChangesListFilterGroup objects, with group name as key 1251 */ 1252 protected function getFilterGroups() { 1253 return $this->filterGroups; 1254 } 1255 1256 /** 1257 * Gets a specified ChangesListFilterGroup by name 1258 * 1259 * @param string $groupName Name of group 1260 * 1261 * @return ChangesListFilterGroup|null Group, or null if not registered 1262 */ 1263 public function getFilterGroup( $groupName ) { 1264 return $this->filterGroups[$groupName] ?? null; 1265 } 1266 1267 // Currently, this intentionally only includes filters that display 1268 // in the structured UI. This can be changed easily, though, if we want 1269 // to include data on filters that use the unstructured UI. messageKeys is a 1270 // special top-level value, with the value being an array of the message keys to 1271 // send to the client. 1272 1273 /** 1274 * Gets structured filter information needed by JS 1275 * 1276 * @return array Associative array 1277 * * array $return['groups'] Group data 1278 * * array $return['messageKeys'] Array of message keys 1279 */ 1280 public function getStructuredFilterJsData() { 1281 $output = [ 1282 'groups' => [], 1283 'messageKeys' => [], 1284 ]; 1285 1286 usort( $this->filterGroups, static function ( ChangesListFilterGroup $a, ChangesListFilterGroup $b ) { 1287 return $b->getPriority() <=> $a->getPriority(); 1288 } ); 1289 1290 foreach ( $this->filterGroups as $groupName => $group ) { 1291 $groupOutput = $group->getJsData(); 1292 if ( $groupOutput !== null ) { 1293 $output['messageKeys'] = array_merge( 1294 $output['messageKeys'], 1295 $groupOutput['messageKeys'] 1296 ); 1297 1298 unset( $groupOutput['messageKeys'] ); 1299 $output['groups'][] = $groupOutput; 1300 } 1301 } 1302 1303 return $output; 1304 } 1305 1306 /** 1307 * Fetch values for a FormOptions object from the WebRequest associated with this instance. 1308 * 1309 * Intended for subclassing, e.g. to add a backwards-compatibility layer. 1310 * 1311 * @param FormOptions $opts 1312 * @return FormOptions 1313 */ 1314 protected function fetchOptionsFromRequest( $opts ) { 1315 $opts->fetchValuesFromRequest( $this->getRequest() ); 1316 1317 return $opts; 1318 } 1319 1320 /** 1321 * Process $par and put options found in $opts. Used when including the page. 1322 * 1323 * @param string $par 1324 * @param FormOptions $opts 1325 */ 1326 public function parseParameters( $par, FormOptions $opts ) { 1327 $stringParameterNameSet = []; 1328 $hideParameterNameSet = []; 1329 1330 // URL parameters can be per-group, like 'userExpLevel', 1331 // or per-filter, like 'hideminor'. 1332 1333 foreach ( $this->filterGroups as $filterGroup ) { 1334 if ( $filterGroup instanceof ChangesListStringOptionsFilterGroup ) { 1335 $stringParameterNameSet[$filterGroup->getName()] = true; 1336 } elseif ( $filterGroup instanceof ChangesListBooleanFilterGroup ) { 1337 foreach ( $filterGroup->getFilters() as $filter ) { 1338 $hideParameterNameSet[$filter->getName()] = true; 1339 } 1340 } 1341 } 1342 1343 $bits = preg_split( '/\s*,\s*/', trim( $par ) ); 1344 foreach ( $bits as $bit ) { 1345 $m = []; 1346 if ( isset( $hideParameterNameSet[$bit] ) ) { 1347 // hidefoo => hidefoo=true 1348 $opts[$bit] = true; 1349 } elseif ( isset( $hideParameterNameSet["hide$bit"] ) ) { 1350 // foo => hidefoo=false 1351 $opts["hide$bit"] = false; 1352 } elseif ( preg_match( '/^(.*)=(.*)$/', $bit, $m ) ) { 1353 if ( isset( $stringParameterNameSet[$m[1]] ) ) { 1354 $opts[$m[1]] = $m[2]; 1355 } 1356 } 1357 } 1358 } 1359 1360 /** 1361 * Validate a FormOptions object generated by getDefaultOptions() with values already populated. 1362 * 1363 * @param FormOptions $opts 1364 */ 1365 public function validateOptions( FormOptions $opts ) { 1366 $isContradictory = $this->fixContradictoryOptions( $opts ); 1367 $isReplaced = $this->replaceOldOptions( $opts ); 1368 1369 if ( $isContradictory || $isReplaced ) { 1370 $query = wfArrayToCgi( $this->convertParamsForLink( $opts->getChangedValues() ) ); 1371 $this->getOutput()->redirect( $this->getPageTitle()->getCanonicalURL( $query ) ); 1372 } 1373 1374 $opts->validateIntBounds( 'limit', 0, 5000 ); 1375 $opts->validateBounds( 'days', 0, $this->getConfig()->get( 'RCMaxAge' ) / ( 3600 * 24 ) ); 1376 } 1377 1378 /** 1379 * Fix invalid options by resetting pairs that should never appear together. 1380 * 1381 * @param FormOptions $opts 1382 * @return bool True if any option was reset 1383 */ 1384 private function fixContradictoryOptions( FormOptions $opts ) { 1385 $fixed = $this->fixBackwardsCompatibilityOptions( $opts ); 1386 1387 foreach ( $this->filterGroups as $filterGroup ) { 1388 if ( $filterGroup instanceof ChangesListBooleanFilterGroup ) { 1389 $filters = $filterGroup->getFilters(); 1390 1391 if ( count( $filters ) === 1 ) { 1392 // legacy boolean filters should not be considered 1393 continue; 1394 } 1395 1396 $allInGroupEnabled = array_reduce( 1397 $filters, 1398 static function ( bool $carry, ChangesListBooleanFilter $filter ) use ( $opts ) { 1399 return $carry && $opts[ $filter->getName() ]; 1400 }, 1401 /* initialValue */ count( $filters ) > 0 1402 ); 1403 1404 if ( $allInGroupEnabled ) { 1405 foreach ( $filters as $filter ) { 1406 $opts[ $filter->getName() ] = false; 1407 } 1408 1409 $fixed = true; 1410 } 1411 } 1412 } 1413 1414 return $fixed; 1415 } 1416 1417 /** 1418 * Fix a special case (hideanons=1 and hideliu=1) in a special way, for backwards 1419 * compatibility. 1420 * 1421 * This is deprecated and may be removed. 1422 * 1423 * @param FormOptions $opts 1424 * @return bool True if this change was mode 1425 */ 1426 private function fixBackwardsCompatibilityOptions( FormOptions $opts ) { 1427 if ( $opts['hideanons'] && $opts['hideliu'] ) { 1428 $opts->reset( 'hideanons' ); 1429 if ( !$opts['hidebots'] ) { 1430 $opts->reset( 'hideliu' ); 1431 $opts['hidehumans'] = 1; 1432 } 1433 1434 return true; 1435 } 1436 1437 return false; 1438 } 1439 1440 /** 1441 * Replace old options with their structured UI equivalents 1442 * 1443 * @param FormOptions $opts 1444 * @return bool True if the change was made 1445 */ 1446 public function replaceOldOptions( FormOptions $opts ) { 1447 if ( !$this->isStructuredFilterUiEnabled() ) { 1448 return false; 1449 } 1450 1451 $changed = false; 1452 1453 // At this point 'hideanons' and 'hideliu' cannot be both true, 1454 // because fixBackwardsCompatibilityOptions resets (at least) 'hideanons' in such case 1455 if ( $opts[ 'hideanons' ] ) { 1456 $opts->reset( 'hideanons' ); 1457 $opts[ 'userExpLevel' ] = 'registered'; 1458 $changed = true; 1459 } 1460 1461 if ( $opts[ 'hideliu' ] ) { 1462 $opts->reset( 'hideliu' ); 1463 $opts[ 'userExpLevel' ] = 'unregistered'; 1464 $changed = true; 1465 } 1466 1467 if ( $this->getFilterGroup( 'legacyReviewStatus' ) ) { 1468 if ( $opts[ 'hidepatrolled' ] ) { 1469 $opts->reset( 'hidepatrolled' ); 1470 $opts[ 'reviewStatus' ] = 'unpatrolled'; 1471 $changed = true; 1472 } 1473 1474 if ( $opts[ 'hideunpatrolled' ] ) { 1475 $opts->reset( 'hideunpatrolled' ); 1476 $opts[ 'reviewStatus' ] = implode( 1477 ChangesListStringOptionsFilterGroup::SEPARATOR, 1478 [ 'manual', 'auto' ] 1479 ); 1480 $changed = true; 1481 } 1482 } 1483 1484 return $changed; 1485 } 1486 1487 /** 1488 * Convert parameters values from true/false to 1/0 1489 * so they are not omitted by wfArrayToCgi() 1490 * T38524 1491 * 1492 * @param array $params 1493 * @return array 1494 */ 1495 protected function convertParamsForLink( $params ) { 1496 foreach ( $params as &$value ) { 1497 if ( $value === false ) { 1498 $value = '0'; 1499 } 1500 } 1501 unset( $value ); 1502 return $params; 1503 } 1504 1505 /** 1506 * Sets appropriate tables, fields, conditions, etc. depending on which filters 1507 * the user requested. 1508 * 1509 * @param array &$tables Array of tables; see IDatabase::select $table 1510 * @param array &$fields Array of fields; see IDatabase::select $vars 1511 * @param array &$conds Array of conditions; see IDatabase::select $conds 1512 * @param array &$query_options Array of query options; see IDatabase::select $options 1513 * @param array &$join_conds Array of join conditions; see IDatabase::select $join_conds 1514 * @param FormOptions $opts 1515 */ 1516 protected function buildQuery( &$tables, &$fields, &$conds, &$query_options, 1517 &$join_conds, FormOptions $opts 1518 ) { 1519 $dbr = $this->getDB(); 1520 $isStructuredUI = $this->isStructuredFilterUiEnabled(); 1521 1522 /** @var ChangesListFilterGroup $filterGroup */ 1523 foreach ( $this->filterGroups as $filterGroup ) { 1524 $filterGroup->modifyQuery( $dbr, $this, $tables, $fields, $conds, 1525 $query_options, $join_conds, $opts, $isStructuredUI ); 1526 } 1527 1528 // Namespace filtering 1529 if ( $opts[ 'namespace' ] !== '' ) { 1530 $namespaces = explode( ';', $opts[ 'namespace' ] ); 1531 1532 $namespaces = $this->expandSymbolicNamespaceFilters( $namespaces ); 1533 1534 $namespaceInfo = MediaWikiServices::getInstance()->getNamespaceInfo(); 1535 $namespaces = array_filter( 1536 $namespaces, 1537 static function ( $ns ) use ( $namespaceInfo ) { 1538 return $namespaceInfo->exists( $ns ); 1539 } 1540 ); 1541 1542 if ( $namespaces !== [] ) { 1543 // Namespaces are just ints, use them as int when acting with the database 1544 $namespaces = array_map( 'intval', $namespaces ); 1545 1546 if ( $opts[ 'associated' ] ) { 1547 $associatedNamespaces = array_map( 1548 static function ( $ns ) use ( $namespaceInfo ){ 1549 return $namespaceInfo->getAssociated( $ns ); 1550 }, 1551 array_filter( 1552 $namespaces, 1553 static function ( $ns ) use ( $namespaceInfo ) { 1554 return $namespaceInfo->hasTalkNamespace( $ns ); 1555 } 1556 ) 1557 ); 1558 $namespaces = array_unique( array_merge( $namespaces, $associatedNamespaces ) ); 1559 } 1560 1561 if ( count( $namespaces ) === 1 ) { 1562 $operator = $opts[ 'invert' ] ? '!=' : '='; 1563 $value = $dbr->addQuotes( reset( $namespaces ) ); 1564 } else { 1565 $operator = $opts[ 'invert' ] ? 'NOT IN' : 'IN'; 1566 sort( $namespaces ); 1567 $value = '(' . $dbr->makeList( $namespaces ) . ')'; 1568 } 1569 $conds[] = "rc_namespace $operator $value"; 1570 } 1571 } 1572 1573 // Calculate cutoff 1574 $cutoff_unixtime = time() - $opts['days'] * 3600 * 24; 1575 $cutoff = $dbr->timestamp( $cutoff_unixtime ); 1576 1577 $fromValid = preg_match( '/^[0-9]{14}$/', $opts['from'] ); 1578 if ( $fromValid && $opts['from'] > wfTimestamp( TS_MW, $cutoff ) ) { 1579 $cutoff = $dbr->timestamp( $opts['from'] ); 1580 } else { 1581 $opts->reset( 'from' ); 1582 } 1583 1584 $conds[] = 'rc_timestamp >= ' . $dbr->addQuotes( $cutoff ); 1585 } 1586 1587 /** 1588 * Process the query 1589 * 1590 * @param array $tables Array of tables; see IDatabase::select $table 1591 * @param array $fields Array of fields; see IDatabase::select $vars 1592 * @param array $conds Array of conditions; see IDatabase::select $conds 1593 * @param array $query_options Array of query options; see IDatabase::select $options 1594 * @param array $join_conds Array of join conditions; see IDatabase::select $join_conds 1595 * @param FormOptions $opts 1596 * @return bool|IResultWrapper Result or false 1597 */ 1598 protected function doMainQuery( $tables, $fields, $conds, 1599 $query_options, $join_conds, FormOptions $opts 1600 ) { 1601 $rcQuery = RecentChange::getQueryInfo(); 1602 $tables = array_merge( $tables, $rcQuery['tables'] ); 1603 $fields = array_merge( $rcQuery['fields'], $fields ); 1604 $join_conds = array_merge( $join_conds, $rcQuery['joins'] ); 1605 1606 ChangeTags::modifyDisplayQuery( 1607 $tables, 1608 $fields, 1609 $conds, 1610 $join_conds, 1611 $query_options, 1612 '' 1613 ); 1614 1615 if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds, 1616 $opts ) 1617 ) { 1618 return false; 1619 } 1620 1621 $dbr = $this->getDB(); 1622 1623 return $dbr->select( 1624 $tables, 1625 $fields, 1626 $conds, 1627 __METHOD__, 1628 $query_options, 1629 $join_conds 1630 ); 1631 } 1632 1633 protected function runMainQueryHook( &$tables, &$fields, &$conds, 1634 &$query_options, &$join_conds, $opts 1635 ) { 1636 return $this->getHookRunner()->onChangesListSpecialPageQuery( 1637 $this->getName(), $tables, $fields, $conds, $query_options, $join_conds, $opts ); 1638 } 1639 1640 /** 1641 * Return a IDatabase object for reading 1642 * 1643 * @return IDatabase 1644 */ 1645 protected function getDB() { 1646 return wfGetDB( DB_REPLICA ); 1647 } 1648 1649 /** 1650 * Send header output to the OutputPage object, only called if not using feeds 1651 * 1652 * @param int $rowCount Number of database rows 1653 * @param FormOptions $opts 1654 */ 1655 private function webOutputHeader( $rowCount, $opts ) { 1656 if ( !$this->including() ) { 1657 $this->outputFeedLinks(); 1658 $this->doHeader( $opts, $rowCount ); 1659 } 1660 } 1661 1662 /** 1663 * Send output to the OutputPage object, only called if not used feeds 1664 * 1665 * @param IResultWrapper $rows Database rows 1666 * @param FormOptions $opts 1667 */ 1668 public function webOutput( $rows, $opts ) { 1669 $this->webOutputHeader( $rows->numRows(), $opts ); 1670 1671 $this->outputChangesList( $rows, $opts ); 1672 } 1673 1674 public function outputFeedLinks() { 1675 // nothing by default 1676 } 1677 1678 /** 1679 * Build and output the actual changes list. 1680 * 1681 * @param IResultWrapper $rows Database rows 1682 * @param FormOptions $opts 1683 */ 1684 abstract public function outputChangesList( $rows, $opts ); 1685 1686 /** 1687 * Set the text to be displayed above the changes 1688 * 1689 * @param FormOptions $opts 1690 * @param int $numRows Number of rows in the result to show after this header 1691 */ 1692 public function doHeader( $opts, $numRows ) { 1693 $this->setTopText( $opts ); 1694 1695 // @todo Lots of stuff should be done here. 1696 1697 $this->setBottomText( $opts ); 1698 } 1699 1700 /** 1701 * Send the text to be displayed before the options. 1702 * Should use $this->getOutput()->addWikiTextAsInterface() 1703 * or similar methods to print the text. 1704 * 1705 * @param FormOptions $opts 1706 */ 1707 public function setTopText( FormOptions $opts ) { 1708 // nothing by default 1709 } 1710 1711 /** 1712 * Send the text to be displayed after the options. 1713 * Should use $this->getOutput()->addWikiTextAsInterface() 1714 * or similar methods to print the text. 1715 * 1716 * @param FormOptions $opts 1717 */ 1718 public function setBottomText( FormOptions $opts ) { 1719 // nothing by default 1720 } 1721 1722 /** 1723 * Get options to be displayed in a form 1724 * @todo This should handle options returned by getDefaultOptions(). 1725 * @todo Not called by anything in this class (but is in subclasses), should be 1726 * called by something… doHeader() maybe? 1727 * 1728 * @param FormOptions $opts 1729 * @return array 1730 */ 1731 public function getExtraOptions( $opts ) { 1732 return []; 1733 } 1734 1735 /** 1736 * Return the legend displayed within the fieldset 1737 * 1738 * @return string 1739 */ 1740 public function makeLegend() { 1741 $context = $this->getContext(); 1742 $user = $context->getUser(); 1743 # The legend showing what the letters and stuff mean 1744 $legend = Html::openElement( 'dl' ) . "\n"; 1745 # Iterates through them and gets the messages for both letter and tooltip 1746 $legendItems = $context->getConfig()->get( 'RecentChangesFlags' ); 1747 if ( !( $user->useRCPatrol() || $user->useNPPatrol() ) ) { 1748 unset( $legendItems['unpatrolled'] ); 1749 } 1750 foreach ( $legendItems as $key => $item ) { # generate items of the legend 1751 $label = $item['legend'] ?? $item['title']; 1752 $letter = $item['letter']; 1753 $cssClass = $item['class'] ?? $key; 1754 1755 $legend .= Html::element( 'dt', 1756 [ 'class' => $cssClass ], $context->msg( $letter )->text() 1757 ) . "\n" . 1758 Html::rawElement( 'dd', 1759 [ 'class' => Sanitizer::escapeClass( 'mw-changeslist-legend-' . $key ) ], 1760 $context->msg( $label )->parse() 1761 ) . "\n"; 1762 } 1763 # (+-123) 1764 $legend .= Html::rawElement( 'dt', 1765 [ 'class' => 'mw-plusminus-pos' ], 1766 $context->msg( 'recentchanges-legend-plusminus' )->parse() 1767 ) . "\n"; 1768 $legend .= Html::element( 1769 'dd', 1770 [ 'class' => 'mw-changeslist-legend-plusminus' ], 1771 $context->msg( 'recentchanges-label-plusminus' )->text() 1772 ) . "\n"; 1773 // Watchlist expiry clock icon. 1774 if ( $context->getConfig()->get( 'WatchlistExpiry' ) ) { 1775 $widget = new IconWidget( [ 1776 'icon' => 'clock', 1777 'classes' => [ 'mw-changesList-watchlistExpiry' ], 1778 ] ); 1779 // Link the image to its label for assistive technologies. 1780 $watchlistLabelId = 'mw-changeslist-watchlistExpiry-label'; 1781 $widget->getIconElement()->setAttributes( [ 1782 'role' => 'img', 1783 'aria-labelledby' => $watchlistLabelId, 1784 ] ); 1785 $legend .= Html::rawElement( 1786 'dt', 1787 [ 'class' => 'mw-changeslist-legend-watchlistexpiry' ], 1788 $widget 1789 ); 1790 $legend .= Html::element( 1791 'dd', 1792 [ 'class' => 'mw-changeslist-legend-watchlistexpiry', 'id' => $watchlistLabelId ], 1793 $context->msg( 'recentchanges-legend-watchlistexpiry' )->text() 1794 ); 1795 } 1796 $legend .= Html::closeElement( 'dl' ) . "\n"; 1797 1798 $legendHeading = $this->isStructuredFilterUiEnabled() ? 1799 $context->msg( 'rcfilters-legend-heading' )->parse() : 1800 $context->msg( 'recentchanges-legend-heading' )->parse(); 1801 1802 # Collapsible 1803 $collapsedState = $this->getRequest()->getCookie( 'changeslist-state' ); 1804 $collapsedClass = $collapsedState === 'collapsed' ? 'mw-collapsed' : ''; 1805 1806 $legend = Html::rawElement( 1807 'div', 1808 [ 'class' => [ 'mw-changeslist-legend', 'mw-collapsible', $collapsedClass ] ], 1809 $legendHeading . 1810 Html::rawElement( 'div', [ 'class' => 'mw-collapsible-content' ], $legend ) 1811 ); 1812 1813 return $legend; 1814 } 1815 1816 /** 1817 * Add page-specific modules. 1818 */ 1819 protected function addModules() { 1820 $out = $this->getOutput(); 1821 // Styles and behavior for the legend box (see makeLegend()) 1822 $out->addModuleStyles( [ 1823 'mediawiki.interface.helpers.styles', 1824 'mediawiki.special.changeslist.legend', 1825 'mediawiki.special.changeslist', 1826 ] ); 1827 $out->addModules( 'mediawiki.special.changeslist.legend.js' ); 1828 1829 if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) { 1830 $out->addModules( 'mediawiki.rcfilters.filters.ui' ); 1831 $out->addModuleStyles( 'mediawiki.rcfilters.filters.base.styles' ); 1832 } 1833 } 1834 1835 protected function getGroupName() { 1836 return 'changes'; 1837 } 1838 1839 /** 1840 * Filter on users' experience levels; this will not be called if nothing is 1841 * selected. 1842 * 1843 * @param string $specialPageClassName Class name of current special page 1844 * @param IContextSource $context Context, for e.g. user 1845 * @param IDatabase $dbr Database, for addQuotes, makeList, and similar 1846 * @param array &$tables Array of tables; see IDatabase::select $table 1847 * @param array &$fields Array of fields; see IDatabase::select $vars 1848 * @param array &$conds Array of conditions; see IDatabase::select $conds 1849 * @param array &$query_options Array of query options; see IDatabase::select $options 1850 * @param array &$join_conds Array of join conditions; see IDatabase::select $join_conds 1851 * @param array $selectedExpLevels The allowed active values, sorted 1852 * @param int $now Number of seconds since the UNIX epoch, or 0 if not given 1853 * (optional) 1854 */ 1855 public function filterOnUserExperienceLevel( $specialPageClassName, $context, $dbr, 1856 &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedExpLevels, $now = 0 1857 ) { 1858 global $wgLearnerEdits, 1859 $wgExperiencedUserEdits, 1860 $wgLearnerMemberSince, 1861 $wgExperiencedUserMemberSince; 1862 1863 $LEVEL_COUNT = 5; 1864 1865 // If all levels are selected, don't filter 1866 if ( count( $selectedExpLevels ) === $LEVEL_COUNT ) { 1867 return; 1868 } 1869 1870 // both 'registered' and 'unregistered', experience levels, if any, are included in 'registered' 1871 if ( 1872 in_array( 'registered', $selectedExpLevels ) && 1873 in_array( 'unregistered', $selectedExpLevels ) 1874 ) { 1875 return; 1876 } 1877 1878 // 'registered' but not 'unregistered', experience levels, if any, are included in 'registered' 1879 if ( 1880 in_array( 'registered', $selectedExpLevels ) && 1881 !in_array( 'unregistered', $selectedExpLevels ) 1882 ) { 1883 $conds[] = 'actor_user IS NOT NULL'; 1884 return; 1885 } 1886 1887 if ( $selectedExpLevels === [ 'unregistered' ] ) { 1888 $conds['actor_user'] = null; 1889 return; 1890 } 1891 1892 $tables[] = 'user'; 1893 $join_conds['user'] = [ 'LEFT JOIN', 'actor_user=user_id' ]; 1894 1895 if ( $now === 0 ) { 1896 $now = time(); 1897 } 1898 $secondsPerDay = 86400; 1899 $learnerCutoff = $now - $wgLearnerMemberSince * $secondsPerDay; 1900 $experiencedUserCutoff = $now - $wgExperiencedUserMemberSince * $secondsPerDay; 1901 1902 $aboveNewcomer = $dbr->makeList( 1903 [ 1904 'user_editcount >= ' . intval( $wgLearnerEdits ), 1905 $dbr->makeList( [ 1906 'user_registration IS NULL', 1907 'user_registration <= ' . $dbr->addQuotes( $dbr->timestamp( $learnerCutoff ) ), 1908 ], IDatabase::LIST_OR ), 1909 ], 1910 IDatabase::LIST_AND 1911 ); 1912 1913 $aboveLearner = $dbr->makeList( 1914 [ 1915 'user_editcount >= ' . intval( $wgExperiencedUserEdits ), 1916 $dbr->makeList( [ 1917 'user_registration IS NULL', 1918 'user_registration <= ' . 1919 $dbr->addQuotes( $dbr->timestamp( $experiencedUserCutoff ) ), 1920 ], IDatabase::LIST_OR ), 1921 ], 1922 IDatabase::LIST_AND 1923 ); 1924 1925 $conditions = []; 1926 1927 if ( in_array( 'unregistered', $selectedExpLevels ) ) { 1928 $selectedExpLevels = array_diff( $selectedExpLevels, [ 'unregistered' ] ); 1929 $conditions['actor_user'] = null; 1930 } 1931 1932 if ( $selectedExpLevels === [ 'newcomer' ] ) { 1933 $conditions[] = "NOT ( $aboveNewcomer )"; 1934 } elseif ( $selectedExpLevels === [ 'learner' ] ) { 1935 $conditions[] = $dbr->makeList( 1936 [ $aboveNewcomer, "NOT ( $aboveLearner )" ], 1937 IDatabase::LIST_AND 1938 ); 1939 } elseif ( $selectedExpLevels === [ 'experienced' ] ) { 1940 $conditions[] = $aboveLearner; 1941 } elseif ( $selectedExpLevels === [ 'learner', 'newcomer' ] ) { 1942 $conditions[] = "NOT ( $aboveLearner )"; 1943 } elseif ( $selectedExpLevels === [ 'experienced', 'newcomer' ] ) { 1944 $conditions[] = $dbr->makeList( 1945 [ "NOT ( $aboveNewcomer )", $aboveLearner ], 1946 IDatabase::LIST_OR 1947 ); 1948 } elseif ( $selectedExpLevels === [ 'experienced', 'learner' ] ) { 1949 $conditions[] = $aboveNewcomer; 1950 } elseif ( $selectedExpLevels === [ 'experienced', 'learner', 'newcomer' ] ) { 1951 $conditions[] = 'actor_user IS NOT NULL'; 1952 } 1953 1954 if ( count( $conditions ) > 1 ) { 1955 $conds[] = $dbr->makeList( $conditions, IDatabase::LIST_OR ); 1956 } elseif ( count( $conditions ) === 1 ) { 1957 $conds[] = reset( $conditions ); 1958 } 1959 } 1960 1961 /** 1962 * Check whether the structured filter UI is enabled 1963 * 1964 * @return bool 1965 */ 1966 public function isStructuredFilterUiEnabled() { 1967 if ( $this->getRequest()->getBool( 'rcfilters' ) ) { 1968 return true; 1969 } 1970 1971 return static::checkStructuredFilterUiEnabled( $this->getUser() ); 1972 } 1973 1974 /** 1975 * Static method to check whether StructuredFilter UI is enabled for the given user 1976 * 1977 * @since 1.31 1978 * @param User $user 1979 * @return bool 1980 */ 1981 public static function checkStructuredFilterUiEnabled( $user ) { 1982 if ( $user instanceof Config ) { 1983 wfDeprecated( __METHOD__ . ' with Config argument', '1.34' ); 1984 $user = func_get_arg( 1 ); 1985 } 1986 return !$user->getOption( 'rcenhancedfilters-disable' ); 1987 } 1988 1989 /** 1990 * Get the default value of the number of changes to display when loading 1991 * the result set. 1992 * 1993 * @since 1.30 1994 * @return int 1995 */ 1996 public function getDefaultLimit() { 1997 return MediaWikiServices::getInstance() 1998 ->getUserOptionsLookup() 1999 ->getIntOption( $this->getUser(), $this->getLimitPreferenceName() ); 2000 } 2001 2002 /** 2003 * Get the default value of the number of days to display when loading 2004 * the result set. 2005 * Supports fractional values, and should be cast to a float. 2006 * 2007 * @since 1.30 2008 * @return float 2009 */ 2010 public function getDefaultDays() { 2011 return floatval( $this->getUser()->getOption( static::$daysPreferenceName ) ); 2012 } 2013 2014 /** 2015 * Getting the preference name for 'limit'. 2016 * 2017 * @since 1.37 2018 * @return string 2019 */ 2020 abstract protected function getLimitPreferenceName(): string; 2021 2022 /** 2023 * @param array $namespaces 2024 * @return array 2025 */ 2026 private function expandSymbolicNamespaceFilters( array $namespaces ) { 2027 $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo(); 2028 $symbolicFilters = [ 2029 'all-contents' => $nsInfo->getSubjectNamespaces(), 2030 'all-discussions' => $nsInfo->getTalkNamespaces(), 2031 ]; 2032 $additionalNamespaces = []; 2033 foreach ( $symbolicFilters as $name => $values ) { 2034 if ( in_array( $name, $namespaces ) ) { 2035 $additionalNamespaces = array_merge( $additionalNamespaces, $values ); 2036 } 2037 } 2038 $namespaces = array_diff( $namespaces, array_keys( $symbolicFilters ) ); 2039 $namespaces = array_merge( $namespaces, $additionalNamespaces ); 2040 return array_unique( $namespaces ); 2041 } 2042} 2043