Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
79.18% |
346 / 437 |
|
64.00% |
16 / 25 |
CRAP | |
0.00% |
0 / 1 |
| SpecialRecentChanges | |
79.36% |
346 / 436 |
|
64.00% |
16 / 25 |
143.59 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
| getExtraFilterGroupDefinitions | |
100.00% |
38 / 38 |
|
100.00% |
1 / 1 |
1 | |||
| execute | |
68.75% |
11 / 16 |
|
0.00% |
0 / 1 |
5.76 | |||
| getExtraFilterFactoryConfig | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| needsWatchlistFeatures | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| getFilterDefaultOverrides | |
88.89% |
16 / 18 |
|
0.00% |
0 / 1 |
2.01 | |||
| parseParameters | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
7 | |||
| modifyQuery | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| outputFeedLinks | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getFeedQuery | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
2.01 | |||
| outputChangesList | |
76.09% |
35 / 46 |
|
0.00% |
0 / 1 |
25.47 | |||
| doHeader | |
90.54% |
67 / 74 |
|
0.00% |
0 / 1 |
7.04 | |||
| setTopText | |
4.88% |
2 / 41 |
|
0.00% |
0 / 1 |
26.52 | |||
| getExtraOptions | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
3 | |||
| checkLastModified | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| namespaceFilterForm | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
1 | |||
| makeOptionsLink | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| optionsPanel | |
76.53% |
75 / 98 |
|
0.00% |
0 / 1 |
12.56 | |||
| isIncludable | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getCacheTTL | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getDefaultLimit | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| getLimitPreferenceName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getSavedQueriesPreferenceName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getDefaultDaysPreferenceName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getCollapsedPreferenceName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\Specials; |
| 8 | |
| 9 | use MediaWiki\ChangeTags\ChangeTags; |
| 10 | use MediaWiki\Html\FormOptions; |
| 11 | use MediaWiki\Html\Html; |
| 12 | use MediaWiki\Language\MessageParser; |
| 13 | use MediaWiki\MainConfigNames; |
| 14 | use MediaWiki\MediaWikiServices; |
| 15 | use MediaWiki\Page\PageReferenceValue; |
| 16 | use MediaWiki\RecentChanges\ChangesList; |
| 17 | use MediaWiki\RecentChanges\ChangesListQuery\ChangesListQuery; |
| 18 | use MediaWiki\RecentChanges\ChangesListQuery\ChangesListQueryFactory; |
| 19 | use MediaWiki\RecentChanges\ChangesListStringOptionsFilterGroup; |
| 20 | use MediaWiki\RecentChanges\RecentChange; |
| 21 | use MediaWiki\RecentChanges\RecentChangeFactory; |
| 22 | use MediaWiki\SpecialPage\ChangesListSpecialPage; |
| 23 | use MediaWiki\User\Options\UserOptionsLookup; |
| 24 | use MediaWiki\User\TempUser\TempUserConfig; |
| 25 | use MediaWiki\User\UserIdentityUtils; |
| 26 | use MediaWiki\Utils\MWTimestamp; |
| 27 | use MediaWiki\Watchlist\WatchedItemStoreInterface; |
| 28 | use OOUI\ButtonWidget; |
| 29 | use OOUI\HtmlSnippet; |
| 30 | use Wikimedia\HtmlArmor\HtmlArmor; |
| 31 | use Wikimedia\Rdbms\IResultWrapper; |
| 32 | use Wikimedia\Timestamp\TimestampFormat as TS; |
| 33 | |
| 34 | /** |
| 35 | * List of the last changes made to the wiki |
| 36 | * |
| 37 | * @ingroup RecentChanges |
| 38 | * @ingroup SpecialPage |
| 39 | */ |
| 40 | class SpecialRecentChanges extends ChangesListSpecialPage { |
| 41 | |
| 42 | private WatchedItemStoreInterface $watchedItemStore; |
| 43 | private MessageParser $messageParser; |
| 44 | private UserOptionsLookup $userOptionsLookup; |
| 45 | |
| 46 | public function __construct( |
| 47 | ?WatchedItemStoreInterface $watchedItemStore = null, |
| 48 | ?MessageParser $messageParser = null, |
| 49 | ?UserOptionsLookup $userOptionsLookup = null, |
| 50 | ?UserIdentityUtils $userIdentityUtils = null, |
| 51 | ?TempUserConfig $tempUserConfig = null, |
| 52 | ?RecentChangeFactory $recentChangeFactory = null, |
| 53 | ?ChangesListQueryFactory $changesListQueryFactory = null, |
| 54 | ) { |
| 55 | // This class is extended and therefor fallback to global state - T265310 |
| 56 | $services = MediaWikiServices::getInstance(); |
| 57 | |
| 58 | parent::__construct( |
| 59 | 'Recentchanges', |
| 60 | '', |
| 61 | $userIdentityUtils ?? $services->getUserIdentityUtils(), |
| 62 | $tempUserConfig ?? $services->getTempUserConfig(), |
| 63 | $recentChangeFactory ?? $services->getRecentChangeFactory(), |
| 64 | $changesListQueryFactory ?? $services->getChangesListQueryFactory(), |
| 65 | ); |
| 66 | $this->watchedItemStore = $watchedItemStore ?? $services->getWatchedItemStore(); |
| 67 | $this->messageParser = $messageParser ?? $services->getMessageParser(); |
| 68 | $this->userOptionsLookup = $userOptionsLookup ?? $services->getUserOptionsLookup(); |
| 69 | } |
| 70 | |
| 71 | protected function getExtraFilterGroupDefinitions(): array { |
| 72 | return [ |
| 73 | [ |
| 74 | 'name' => 'watchlist', |
| 75 | 'title' => 'rcfilters-filtergroup-watchlist', |
| 76 | 'class' => ChangesListStringOptionsFilterGroup::class, |
| 77 | 'priority' => -9, |
| 78 | 'isFullCoverage' => true, |
| 79 | 'requireConfig' => [ 'needsWatchlistFeatures' => true ], |
| 80 | 'filters' => [ |
| 81 | [ |
| 82 | 'name' => 'watched', |
| 83 | 'label' => 'rcfilters-filter-watchlist-watched-label', |
| 84 | 'description' => 'rcfilters-filter-watchlist-watched-description', |
| 85 | 'cssClassSuffix' => 'watched', |
| 86 | 'action' => [ |
| 87 | [ 'require', 'watched', 'watchedold' ], |
| 88 | [ 'require', 'watched', 'watchednew' ], |
| 89 | ], |
| 90 | 'subsets' => [ 'watchednew' ], |
| 91 | ], |
| 92 | [ |
| 93 | 'name' => 'watchednew', |
| 94 | 'label' => 'rcfilters-filter-watchlist-watchednew-label', |
| 95 | 'description' => 'rcfilters-filter-watchlist-watchednew-description', |
| 96 | 'cssClassSuffix' => 'watchednew', |
| 97 | 'action' => [ 'require', 'watched', 'watchednew' ], |
| 98 | ], |
| 99 | [ |
| 100 | 'name' => 'notwatched', |
| 101 | 'label' => 'rcfilters-filter-watchlist-notwatched-label', |
| 102 | 'description' => 'rcfilters-filter-watchlist-notwatched-description', |
| 103 | 'cssClassSuffix' => 'notwatched', |
| 104 | 'action' => [ 'require', 'watched', 'notwatched' ], |
| 105 | ] |
| 106 | ], |
| 107 | 'default' => ChangesListStringOptionsFilterGroup::NONE, |
| 108 | ], |
| 109 | ]; |
| 110 | } |
| 111 | |
| 112 | /** |
| 113 | * @param string|null $subpage |
| 114 | */ |
| 115 | public function execute( $subpage ) { |
| 116 | // Backwards-compatibility: redirect to new feed URLs |
| 117 | $feedFormat = $this->getRequest()->getVal( 'feed' ); |
| 118 | if ( !$this->including() && $feedFormat ) { |
| 119 | $query = $this->getFeedQuery(); |
| 120 | $query['feedformat'] = $feedFormat === 'atom' ? 'atom' : 'rss'; |
| 121 | $this->getOutput()->redirect( wfAppendQuery( wfScript( 'api' ), $query ) ); |
| 122 | |
| 123 | return; |
| 124 | } |
| 125 | |
| 126 | // 10 seconds server-side caching max |
| 127 | $out = $this->getOutput(); |
| 128 | $out->setCdnMaxage( 10 ); |
| 129 | // Check if the client has a cached version |
| 130 | $lastmod = $this->checkLastModified(); |
| 131 | if ( $lastmod === false ) { |
| 132 | return; |
| 133 | } |
| 134 | |
| 135 | $this->addHelpLink( |
| 136 | 'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Recent_changes', |
| 137 | true |
| 138 | ); |
| 139 | parent::execute( $subpage ); |
| 140 | } |
| 141 | |
| 142 | protected function getExtraFilterFactoryConfig(): array { |
| 143 | return [ |
| 144 | 'showHidePrefix' => 'rc', |
| 145 | 'needsWatchlistFeatures' => $this->needsWatchlistFeatures(), |
| 146 | ]; |
| 147 | } |
| 148 | |
| 149 | /** |
| 150 | * Whether or not the current query needs to use watchlist data: check that the current user can |
| 151 | * use their watchlist and that this special page isn't being transcluded. |
| 152 | */ |
| 153 | private function needsWatchlistFeatures(): bool { |
| 154 | return !$this->including() |
| 155 | && $this->getUser()->isRegistered() |
| 156 | && $this->getAuthority()->isAllowed( 'viewmywatchlist' ); |
| 157 | } |
| 158 | |
| 159 | protected function getFilterDefaultOverrides(): array { |
| 160 | $opt = fn ( $optName ) => |
| 161 | $this->userOptionsLookup->getBoolOption( $this->getUser(), $optName ); |
| 162 | $defaults = [ |
| 163 | 'significance' => [ |
| 164 | 'hideminor' => $opt( 'hideminor' ), |
| 165 | ], |
| 166 | 'automated' => [ |
| 167 | 'hidebots' => true, |
| 168 | ], |
| 169 | 'changeType' => [ |
| 170 | 'hidecategorization' => $opt( 'hidecategorization' ) |
| 171 | ] |
| 172 | ]; |
| 173 | if ( $opt( 'hidepatrolled' ) ) { |
| 174 | $defaults['reviewStatus'] = 'unpatrolled'; |
| 175 | $defaults['legacyReviewStatus']['hidepatrolled'] = true; |
| 176 | } |
| 177 | $defaults['changeType']['hidecategorization'] = $opt( 'hidecategorization' ); |
| 178 | return $defaults; |
| 179 | } |
| 180 | |
| 181 | /** |
| 182 | * Process the subpage $par and put options found in $opts. |
| 183 | * |
| 184 | * This is a legacy feature predating query parameter emulation in the |
| 185 | * Parser which was introduced in MW 1.19. Before that time, it was |
| 186 | * necessary to do something like |
| 187 | * |
| 188 | * {{Special:RecentChanges/days=3}} |
| 189 | * |
| 190 | * In MediaWiki 1.19+ you can do: |
| 191 | * |
| 192 | * {{Special:RecentChanges | days=3}} |
| 193 | * |
| 194 | * The latter syntax allows the injection of any query parameter. So it is |
| 195 | * not necessary to add new options here, users should be encouraged to |
| 196 | * use the latter syntax instead. |
| 197 | * |
| 198 | * @param string $par |
| 199 | * @param FormOptions $opts |
| 200 | */ |
| 201 | public function parseParameters( $par, FormOptions $opts ) { |
| 202 | parent::parseParameters( $par, $opts ); |
| 203 | |
| 204 | $bits = preg_split( '/\s*,\s*/', trim( $par ) ); |
| 205 | foreach ( $bits as $bit ) { |
| 206 | if ( is_numeric( $bit ) ) { |
| 207 | $opts['limit'] = $bit; |
| 208 | } |
| 209 | |
| 210 | $m = []; |
| 211 | if ( preg_match( '/^limit=(\d+)$/', $bit, $m ) ) { |
| 212 | $opts['limit'] = $m[1]; |
| 213 | } |
| 214 | if ( preg_match( '/^days=(\d+(?:\.\d+)?)$/', $bit, $m ) ) { |
| 215 | $opts['days'] = $m[1]; |
| 216 | } |
| 217 | if ( preg_match( '/^namespace=(.*)$/', $bit, $m ) ) { |
| 218 | $opts['namespace'] = $m[1]; |
| 219 | } |
| 220 | if ( preg_match( '/^tagfilter=(.*)$/', $bit, $m ) ) { |
| 221 | $opts['tagfilter'] = $m[1]; |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | /** |
| 227 | * @inheritDoc |
| 228 | */ |
| 229 | protected function modifyQuery( ChangesListQuery $query, FormOptions $opts ) { |
| 230 | if ( $this->needsWatchlistFeatures() ) { |
| 231 | $query->watchlistFields( [ 'wl_user', 'wl_notificationtimestamp', 'we_expiry' ] ); |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | public function outputFeedLinks() { |
| 236 | $this->addFeedLinks( $this->getFeedQuery() ); |
| 237 | } |
| 238 | |
| 239 | /** |
| 240 | * Get URL query parameters for action=feedrecentchanges API feed of current recent changes view. |
| 241 | * |
| 242 | * @return array |
| 243 | */ |
| 244 | protected function getFeedQuery() { |
| 245 | $query = array_filter( $this->getOptions()->getAllValues(), static function ( $value ) { |
| 246 | // API handles empty parameters in a different way |
| 247 | return $value !== ''; |
| 248 | } ); |
| 249 | $query['action'] = 'feedrecentchanges'; |
| 250 | $feedLimit = $this->getConfig()->get( MainConfigNames::FeedLimit ); |
| 251 | if ( $query['limit'] > $feedLimit ) { |
| 252 | $query['limit'] = $feedLimit; |
| 253 | } |
| 254 | |
| 255 | return $query; |
| 256 | } |
| 257 | |
| 258 | /** |
| 259 | * Build and output the actual changes list. |
| 260 | * |
| 261 | * @param IResultWrapper $rows Database rows |
| 262 | * @param FormOptions $opts |
| 263 | */ |
| 264 | public function outputChangesList( $rows, $opts ) { |
| 265 | $limit = $opts['limit']; |
| 266 | |
| 267 | $showWatcherCount = $this->getConfig()->get( MainConfigNames::RCShowWatchingUsers ) |
| 268 | && $this->userOptionsLookup->getBoolOption( $this->getUser(), 'shownumberswatching' ); |
| 269 | $watcherCache = []; |
| 270 | |
| 271 | $counter = 1; |
| 272 | $list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups ); |
| 273 | $list->initChangesListRows( $rows ); |
| 274 | |
| 275 | $userShowHiddenCats = $this->userOptionsLookup->getBoolOption( $this->getUser(), 'showhiddencats' ); |
| 276 | $rclistOutput = $list->beginRecentChangesList(); |
| 277 | if ( $this->isStructuredFilterUiEnabled() ) { |
| 278 | $rclistOutput .= $this->makeLegend(); |
| 279 | } |
| 280 | |
| 281 | foreach ( $rows as $obj ) { |
| 282 | if ( $limit == 0 ) { |
| 283 | break; |
| 284 | } |
| 285 | $rc = $this->newRecentChangeFromRow( $obj ); |
| 286 | |
| 287 | # Skip CatWatch entries for hidden cats based on user preference |
| 288 | if ( |
| 289 | $rc->getAttribute( 'rc_source' ) == RecentChange::SRC_CATEGORIZE && |
| 290 | !$userShowHiddenCats && |
| 291 | $rc->getParam( 'hidden-cat' ) |
| 292 | ) { |
| 293 | continue; |
| 294 | } |
| 295 | |
| 296 | $rc->counter = $counter++; |
| 297 | # Check if the page has been updated since the last visit |
| 298 | if ( $this->getConfig()->get( MainConfigNames::ShowUpdatedMarker ) |
| 299 | && !empty( $obj->wl_notificationtimestamp ) |
| 300 | ) { |
| 301 | $rc->notificationtimestamp = ( $obj->rc_timestamp >= $obj->wl_notificationtimestamp ); |
| 302 | } else { |
| 303 | $rc->notificationtimestamp = false; // Default |
| 304 | } |
| 305 | # Check the number of users watching the page |
| 306 | $rc->numberofWatchingusers = 0; // Default |
| 307 | if ( $showWatcherCount && $obj->rc_namespace >= 0 ) { |
| 308 | if ( !isset( $watcherCache[$obj->rc_namespace][$obj->rc_title] ) ) { |
| 309 | $watcherCache[$obj->rc_namespace][$obj->rc_title] = |
| 310 | $this->watchedItemStore->countWatchers( PageReferenceValue::localReference( |
| 311 | (int)$obj->rc_namespace, $obj->rc_title ) ); |
| 312 | } |
| 313 | $rc->numberofWatchingusers = $watcherCache[$obj->rc_namespace][$obj->rc_title]; |
| 314 | } |
| 315 | |
| 316 | $watched = !empty( $obj->wl_user ); |
| 317 | if ( $watched && $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) ) { |
| 318 | $notExpired = $obj->we_expiry === null |
| 319 | || MWTimestamp::convert( TS::UNIX, $obj->we_expiry ) > wfTimestamp(); |
| 320 | $watched = $watched && $notExpired; |
| 321 | } |
| 322 | $changeLine = $list->recentChangesLine( $rc, $watched, $counter ); |
| 323 | if ( $changeLine !== false ) { |
| 324 | $rclistOutput .= $changeLine; |
| 325 | --$limit; |
| 326 | } |
| 327 | } |
| 328 | $rclistOutput .= $list->endRecentChangesList(); |
| 329 | |
| 330 | if ( $rows->numRows() === 0 ) { |
| 331 | $this->outputNoResults(); |
| 332 | if ( !$this->including() ) { |
| 333 | $this->getOutput()->setStatusCode( 404 ); |
| 334 | } |
| 335 | } else { |
| 336 | $this->getOutput()->addHTML( $rclistOutput ); |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | /** |
| 341 | * Set the text to be displayed above the changes |
| 342 | * |
| 343 | * @param FormOptions $opts |
| 344 | * @param int $numRows Number of rows in the result to show after this header |
| 345 | */ |
| 346 | public function doHeader( $opts, $numRows ) { |
| 347 | $this->setTopText( $opts ); |
| 348 | |
| 349 | $defaults = $opts->getAllValues(); |
| 350 | $nondefaults = $opts->getChangedValues(); |
| 351 | |
| 352 | $panel = []; |
| 353 | if ( !$this->isStructuredFilterUiEnabled() ) { |
| 354 | $panel[] = $this->makeLegend(); |
| 355 | } |
| 356 | $panel[] = $this->optionsPanel( $defaults, $nondefaults, $numRows ); |
| 357 | $panel[] = '<hr />'; |
| 358 | |
| 359 | $extraOpts = $this->getExtraOptions( $opts ); |
| 360 | $extraOptsCount = count( $extraOpts ); |
| 361 | $count = 0; |
| 362 | $submit = ' ' . Html::submitButton( $this->msg( 'recentchanges-submit' )->text() ); |
| 363 | |
| 364 | $out = Html::openElement( 'table', [ 'class' => 'mw-recentchanges-table' ] ); |
| 365 | foreach ( $extraOpts as $name => $optionRow ) { |
| 366 | # Add submit button to the last row only |
| 367 | ++$count; |
| 368 | $addSubmit = ( $count === $extraOptsCount ) ? $submit : ''; |
| 369 | |
| 370 | $out .= Html::openElement( 'tr', [ 'class' => $name . 'Form' ] ); |
| 371 | if ( is_array( $optionRow ) ) { |
| 372 | $out .= Html::rawElement( |
| 373 | 'td', |
| 374 | [ 'class' => [ 'mw-label', 'mw-' . $name . '-label' ] ], |
| 375 | $optionRow[0] |
| 376 | ); |
| 377 | $out .= Html::rawElement( |
| 378 | 'td', |
| 379 | [ 'class' => 'mw-input' ], |
| 380 | $optionRow[1] . $addSubmit |
| 381 | ); |
| 382 | } else { |
| 383 | $out .= Html::rawElement( |
| 384 | 'td', |
| 385 | [ 'class' => 'mw-input', 'colspan' => 2 ], |
| 386 | $optionRow . $addSubmit |
| 387 | ); |
| 388 | } |
| 389 | $out .= Html::closeElement( 'tr' ); |
| 390 | } |
| 391 | $out .= Html::closeElement( 'table' ); |
| 392 | |
| 393 | $unconsumed = $opts->getUnconsumedValues(); |
| 394 | foreach ( $unconsumed as $key => $value ) { |
| 395 | $out .= Html::hidden( $key, $value ); |
| 396 | } |
| 397 | |
| 398 | $t = $this->getPageTitle(); |
| 399 | $out .= Html::hidden( 'title', $t->getPrefixedText() ); |
| 400 | $form = Html::rawElement( 'form', [ 'action' => wfScript() ], $out ); |
| 401 | $panel[] = $form; |
| 402 | $panelString = implode( "\n", $panel ); |
| 403 | |
| 404 | $rcoptions = Html::rawElement( |
| 405 | 'fieldset', |
| 406 | [ 'class' => 'rcoptions cloptions' ], |
| 407 | Html::element( |
| 408 | 'legend', [], |
| 409 | $this->msg( 'recentchanges-legend' )->text() |
| 410 | ) . $panelString |
| 411 | ); |
| 412 | |
| 413 | // Insert a placeholder for RCFilters |
| 414 | if ( $this->isStructuredFilterUiEnabled() ) { |
| 415 | $rcfilterContainer = Html::element( |
| 416 | 'div', |
| 417 | [ 'class' => 'mw-rcfilters-container' ] |
| 418 | ); |
| 419 | |
| 420 | $loadingContainer = Html::rawElement( |
| 421 | 'div', |
| 422 | [ 'class' => 'mw-rcfilters-spinner' ], |
| 423 | Html::element( |
| 424 | 'div', |
| 425 | [ 'class' => 'mw-rcfilters-spinner-bounce' ] |
| 426 | ) |
| 427 | ); |
| 428 | |
| 429 | // Wrap both with mw-rcfilters-head |
| 430 | $this->getOutput()->addHTML( |
| 431 | Html::rawElement( |
| 432 | 'div', |
| 433 | [ 'class' => 'mw-rcfilters-head' ], |
| 434 | $rcfilterContainer . $rcoptions |
| 435 | ) |
| 436 | ); |
| 437 | |
| 438 | // Add spinner |
| 439 | $this->getOutput()->addHTML( $loadingContainer ); |
| 440 | } else { |
| 441 | $this->getOutput()->addHTML( $rcoptions ); |
| 442 | } |
| 443 | |
| 444 | $this->setBottomText( $opts ); |
| 445 | } |
| 446 | |
| 447 | /** |
| 448 | * Send the text to be displayed above the options |
| 449 | * |
| 450 | * @param FormOptions $opts Unused |
| 451 | */ |
| 452 | public function setTopText( FormOptions $opts ) { |
| 453 | $message = $this->msg( 'recentchangestext' )->inContentLanguage(); |
| 454 | if ( !$message->isDisabled() ) { |
| 455 | $contLang = $this->getContentLanguage(); |
| 456 | // Parse the message in this weird ugly way to preserve the ability to include interlanguage |
| 457 | // links in it (T172461). In the future when T66969 is resolved, perhaps we can just use |
| 458 | // $message->parse() instead. This code is copied from Message::parseText(). |
| 459 | $parserOutput = $this->messageParser->parse( |
| 460 | $message->plain(), |
| 461 | $this->getPageTitle(), |
| 462 | /*linestart*/ true, |
| 463 | // Message class sets the interface flag to false when parsing in a language different than |
| 464 | // user language, and this is wiki content language |
| 465 | /*interface*/ false, |
| 466 | $contLang |
| 467 | ); |
| 468 | $content = $parserOutput->getContentHolderText(); |
| 469 | // Add only metadata here (including the language links), text is added below |
| 470 | $this->getOutput()->addParserOutputMetadata( $parserOutput ); |
| 471 | |
| 472 | $langAttributes = [ |
| 473 | 'lang' => $contLang->getHtmlCode(), |
| 474 | 'dir' => $contLang->getDir(), |
| 475 | ]; |
| 476 | |
| 477 | $topLinksAttributes = [ 'class' => 'mw-recentchanges-toplinks' ]; |
| 478 | |
| 479 | if ( $this->isStructuredFilterUiEnabled() ) { |
| 480 | // Check whether the widget is already collapsed or expanded |
| 481 | $collapsedState = $this->getRequest()->getCookie( 'rcfilters-toplinks-collapsed-state' ); |
| 482 | // Note that an empty/unset cookie means collapsed, so check for !== 'expanded' |
| 483 | $topLinksAttributes[ 'class' ] .= $collapsedState !== 'expanded' ? |
| 484 | ' mw-recentchanges-toplinks-collapsed' : ''; |
| 485 | |
| 486 | $this->getOutput()->enableOOUI(); |
| 487 | $contentTitle = new ButtonWidget( [ |
| 488 | 'classes' => [ 'mw-recentchanges-toplinks-title' ], |
| 489 | 'label' => new HtmlSnippet( $this->msg( 'rcfilters-other-review-tools' )->parse() ), |
| 490 | 'framed' => false, |
| 491 | 'indicator' => $collapsedState !== 'expanded' ? 'down' : 'up', |
| 492 | 'flags' => [ 'progressive' ], |
| 493 | ] ); |
| 494 | |
| 495 | $contentWrapper = Html::rawElement( 'div', |
| 496 | array_merge( |
| 497 | [ 'class' => [ 'mw-recentchanges-toplinks-content', 'mw-collapsible-content' ] ], |
| 498 | $langAttributes |
| 499 | ), |
| 500 | $content |
| 501 | ); |
| 502 | $content = $contentTitle . $contentWrapper; |
| 503 | } else { |
| 504 | // Language direction should be on the top div only |
| 505 | // if the title is not there. If it is there, it's |
| 506 | // interface direction, and the language/dir attributes |
| 507 | // should be on the content itself |
| 508 | $topLinksAttributes = array_merge( $topLinksAttributes, $langAttributes ); |
| 509 | } |
| 510 | |
| 511 | $this->getOutput()->addHTML( |
| 512 | Html::rawElement( 'div', $topLinksAttributes, $content ) |
| 513 | ); |
| 514 | } |
| 515 | } |
| 516 | |
| 517 | /** |
| 518 | * Get options to be displayed in a form |
| 519 | * |
| 520 | * @param FormOptions $opts |
| 521 | * @return array |
| 522 | */ |
| 523 | public function getExtraOptions( $opts ) { |
| 524 | $opts->consumeValues( [ |
| 525 | 'namespace', 'invert', 'associated', 'tagfilter', 'inverttags' |
| 526 | ] ); |
| 527 | |
| 528 | $extraOpts = []; |
| 529 | $extraOpts['namespace'] = $this->namespaceFilterForm( $opts ); |
| 530 | |
| 531 | $tagFilter = ChangeTags::buildTagFilterSelector( |
| 532 | $opts['tagfilter'], false, $this->getContext() |
| 533 | ); |
| 534 | if ( $tagFilter ) { |
| 535 | $tagFilter[1] .= ' ' . Html::rawElement( 'span', [ 'class' => [ 'mw-input-with-label' ] ], |
| 536 | Html::element( 'input', [ |
| 537 | 'type' => 'checkbox', 'name' => 'inverttags', 'value' => '1', 'checked' => $opts['inverttags'], |
| 538 | 'id' => 'inverttags' |
| 539 | ] ) . ' ' . Html::label( $this->msg( 'invert' )->text(), 'inverttags' ) |
| 540 | ); |
| 541 | $extraOpts['tagfilter'] = $tagFilter; |
| 542 | } |
| 543 | |
| 544 | // Don't fire the hook for subclasses. (Or should we?) |
| 545 | if ( $this->getName() === 'Recentchanges' ) { |
| 546 | $this->getHookRunner()->onSpecialRecentChangesPanel( $extraOpts, $opts ); |
| 547 | } |
| 548 | |
| 549 | return $extraOpts; |
| 550 | } |
| 551 | |
| 552 | /** |
| 553 | * Get last modified date, for client caching |
| 554 | * Don't use this if we are using the patrol feature, patrol changes don't |
| 555 | * update the timestamp |
| 556 | * |
| 557 | * @return string|bool |
| 558 | */ |
| 559 | public function checkLastModified() { |
| 560 | $dbr = $this->getDB(); |
| 561 | $lastmod = $dbr->newSelectQueryBuilder() |
| 562 | ->select( 'MAX(rc_timestamp)' ) |
| 563 | ->from( 'recentchanges' ) |
| 564 | ->caller( __METHOD__ )->fetchField(); |
| 565 | |
| 566 | return $lastmod; |
| 567 | } |
| 568 | |
| 569 | /** |
| 570 | * Creates the choose namespace selection |
| 571 | * |
| 572 | * @param FormOptions $opts |
| 573 | * @return string[] |
| 574 | */ |
| 575 | protected function namespaceFilterForm( FormOptions $opts ) { |
| 576 | $nsSelect = Html::namespaceSelector( |
| 577 | [ 'selected' => $opts['namespace'], 'all' => '', 'in-user-lang' => true ], |
| 578 | [ 'name' => 'namespace', 'id' => 'namespace' ] |
| 579 | ); |
| 580 | $nsLabel = Html::label( $this->msg( 'namespace' )->text(), 'namespace' ); |
| 581 | $invert = Html::rawElement( 'label', [ |
| 582 | 'class' => 'mw-input-with-label', 'title' => $this->msg( 'tooltip-invert' )->text(), |
| 583 | ], Html::element( 'input', [ |
| 584 | 'type' => 'checkbox', 'name' => 'invert', 'value' => '1', 'checked' => $opts['invert'], |
| 585 | ] ) . ' ' . $this->msg( 'invert' )->escaped() ); |
| 586 | $associated = Html::rawElement( 'label', [ |
| 587 | 'class' => 'mw-input-with-label', 'title' => $this->msg( 'tooltip-namespace_association' )->text(), |
| 588 | ], Html::element( 'input', [ |
| 589 | 'type' => 'checkbox', 'name' => 'associated', 'value' => '1', 'checked' => $opts['associated'], |
| 590 | ] ) . ' ' . $this->msg( 'namespace_association' )->escaped() ); |
| 591 | |
| 592 | return [ $nsLabel, "$nsSelect $invert $associated" ]; |
| 593 | } |
| 594 | |
| 595 | /** |
| 596 | * Makes change an option link which carries all the other options |
| 597 | * |
| 598 | * @param string $title |
| 599 | * @param array $override Options to override |
| 600 | * @param array $options Current options |
| 601 | * @param bool $active Whether to show the link in bold |
| 602 | * @return string |
| 603 | * Annotations needed to tell taint about HtmlArmor |
| 604 | * @param-taint $title escapes_html |
| 605 | */ |
| 606 | private function makeOptionsLink( $title, $override, $options, $active = false ) { |
| 607 | $params = $this->convertParamsForLink( $override + $options ); |
| 608 | |
| 609 | if ( $active ) { |
| 610 | $title = new HtmlArmor( '<strong>' . htmlspecialchars( $title ) . '</strong>' ); |
| 611 | } |
| 612 | |
| 613 | return $this->getLinkRenderer()->makeKnownLink( $this->getPageTitle(), $title, [ |
| 614 | 'data-params' => json_encode( $override ), |
| 615 | 'data-keys' => implode( ',', array_keys( $override ) ), |
| 616 | 'title' => false |
| 617 | ], $params ); |
| 618 | } |
| 619 | |
| 620 | /** |
| 621 | * Creates the options panel. |
| 622 | * |
| 623 | * @param array $defaults |
| 624 | * @param array $nondefaults |
| 625 | * @param int $numRows Number of rows in the result to show after this header |
| 626 | * @return string |
| 627 | */ |
| 628 | private function optionsPanel( $defaults, $nondefaults, $numRows ) { |
| 629 | $options = $nondefaults + $defaults; |
| 630 | |
| 631 | $note = ''; |
| 632 | $msg = $this->msg( 'rclegend' ); |
| 633 | if ( !$msg->isDisabled() ) { |
| 634 | $note .= Html::rawElement( |
| 635 | 'div', |
| 636 | [ 'class' => 'mw-rclegend' ], |
| 637 | $msg->parse() |
| 638 | ); |
| 639 | } |
| 640 | |
| 641 | $lang = $this->getLanguage(); |
| 642 | $user = $this->getUser(); |
| 643 | $config = $this->getConfig(); |
| 644 | if ( $options['from'] ) { |
| 645 | $resetLink = $this->makeOptionsLink( $this->msg( 'rclistfromreset' )->text(), |
| 646 | [ 'from' => '' ], $nondefaults ); |
| 647 | |
| 648 | $noteFromMsg = $this->msg( 'rcnotefrom' ) |
| 649 | ->numParams( $options['limit'] ) |
| 650 | ->params( |
| 651 | $lang->userTimeAndDate( $options['from'], $user ), |
| 652 | $lang->userDate( $options['from'], $user ), |
| 653 | $lang->userTime( $options['from'], $user ) |
| 654 | ) |
| 655 | ->numParams( $numRows ); |
| 656 | $note .= Html::rawElement( |
| 657 | 'span', |
| 658 | [ 'class' => 'rcnotefrom' ], |
| 659 | $noteFromMsg->parse() |
| 660 | ) . |
| 661 | ' ' . |
| 662 | Html::rawElement( |
| 663 | 'span', |
| 664 | [ 'class' => 'rcoptions-listfromreset' ], |
| 665 | $this->msg( 'parentheses' )->rawParams( $resetLink )->parse() |
| 666 | ) . |
| 667 | '<br />'; |
| 668 | } |
| 669 | |
| 670 | # Sort data for display and make sure it's unique after we've added user data. |
| 671 | $linkLimits = $config->get( MainConfigNames::RCLinkLimits ); |
| 672 | $linkLimits[] = $options['limit']; |
| 673 | sort( $linkLimits ); |
| 674 | $linkLimits = array_unique( $linkLimits ); |
| 675 | |
| 676 | $linkDays = $this->getLinkDays(); |
| 677 | $linkDays[] = $options['days']; |
| 678 | sort( $linkDays ); |
| 679 | $linkDays = array_unique( $linkDays ); |
| 680 | |
| 681 | // limit links |
| 682 | $cl = []; |
| 683 | foreach ( $linkLimits as $value ) { |
| 684 | $cl[] = $this->makeOptionsLink( $lang->formatNum( $value ), |
| 685 | [ 'limit' => $value ], $nondefaults, $value == $options['limit'] ); |
| 686 | } |
| 687 | $cl = $lang->pipeList( $cl ); |
| 688 | |
| 689 | // day links, reset 'from' to none |
| 690 | $dl = []; |
| 691 | foreach ( $linkDays as $value ) { |
| 692 | $dl[] = $this->makeOptionsLink( $lang->formatNum( $value ), |
| 693 | [ 'days' => $value, 'from' => '' ], $nondefaults, $value == $options['days'] ); |
| 694 | } |
| 695 | $dl = $lang->pipeList( $dl ); |
| 696 | |
| 697 | $showhide = [ 'show', 'hide' ]; |
| 698 | |
| 699 | $links = []; |
| 700 | |
| 701 | foreach ( $this->filterGroups->getLegacyShowHideFilters() as $key => $filter ) { |
| 702 | if ( !MediaWikiServices::getInstance() |
| 703 | ->getPermissionManager() |
| 704 | ->isEveryoneAllowed( "edit" ) && |
| 705 | ( $filter->getName() == "hideliu" || $filter->getName() == "hideanons" ) ) { |
| 706 | continue; |
| 707 | } |
| 708 | $msg = $filter->getShowHide(); |
| 709 | $linkMessage = $this->msg( $msg . '-' . $showhide[1 - $options[$key]] ); |
| 710 | // Extensions can define additional filters, but don't need to define the corresponding |
| 711 | // messages. If they don't exist, just fall back to 'show' and 'hide'. |
| 712 | if ( !$linkMessage->exists() ) { |
| 713 | $linkMessage = $this->msg( $showhide[1 - $options[$key]] ); |
| 714 | } |
| 715 | |
| 716 | $link = $this->makeOptionsLink( $linkMessage->text(), |
| 717 | [ $key => 1 - $options[$key] ], $nondefaults ); |
| 718 | |
| 719 | $attribs = [ |
| 720 | 'class' => "$msg rcshowhideoption clshowhideoption", |
| 721 | 'data-filter-name' => $filter->getName(), |
| 722 | ]; |
| 723 | |
| 724 | if ( $filter->isFeatureAvailableOnStructuredUi() ) { |
| 725 | $attribs['data-feature-in-structured-ui'] = true; |
| 726 | } |
| 727 | |
| 728 | $links[] = Html::rawElement( |
| 729 | 'span', |
| 730 | $attribs, |
| 731 | $this->msg( $msg )->rawParams( $link )->parse() |
| 732 | ); |
| 733 | } |
| 734 | |
| 735 | // show from this onward link |
| 736 | $timestamp = wfTimestampNow(); |
| 737 | $now = $lang->userTimeAndDate( $timestamp, $user ); |
| 738 | $timenow = $lang->userTime( $timestamp, $user ); |
| 739 | $datenow = $lang->userDate( $timestamp, $user ); |
| 740 | $pipedLinks = '<span class="rcshowhide">' . $lang->pipeList( $links ) . '</span>'; |
| 741 | |
| 742 | $rclinks = Html::rawElement( |
| 743 | 'span', |
| 744 | [ 'class' => 'rclinks' ], |
| 745 | $this->msg( 'rclinks' )->rawParams( $cl, $dl, '' )->parse() |
| 746 | ); |
| 747 | |
| 748 | $rclistfrom = Html::rawElement( |
| 749 | 'span', |
| 750 | [ 'class' => 'rclistfrom' ], |
| 751 | $this->makeOptionsLink( |
| 752 | $this->msg( 'rclistfrom' )->plaintextParams( $now, $timenow, $datenow )->text(), |
| 753 | [ 'from' => $timestamp, 'fromFormatted' => $now ], |
| 754 | $nondefaults |
| 755 | ) |
| 756 | ); |
| 757 | |
| 758 | return "{$note}$rclinks<br />$pipedLinks<br />$rclistfrom"; |
| 759 | } |
| 760 | |
| 761 | /** @inheritDoc */ |
| 762 | public function isIncludable() { |
| 763 | return true; |
| 764 | } |
| 765 | |
| 766 | /** @inheritDoc */ |
| 767 | protected function getCacheTTL() { |
| 768 | return 60 * 5; |
| 769 | } |
| 770 | |
| 771 | public function getDefaultLimit(): int { |
| 772 | $systemPrefValue = $this->userOptionsLookup->getIntOption( $this->getUser(), 'rclimit' ); |
| 773 | // Prefer the RCFilters-specific preference if RCFilters is enabled |
| 774 | if ( $this->isStructuredFilterUiEnabled() ) { |
| 775 | return $this->userOptionsLookup->getIntOption( |
| 776 | $this->getUser(), $this->getLimitPreferenceName(), $systemPrefValue |
| 777 | ); |
| 778 | } |
| 779 | |
| 780 | // Otherwise, use the system rclimit preference value |
| 781 | return $systemPrefValue; |
| 782 | } |
| 783 | |
| 784 | protected function getLimitPreferenceName(): string { |
| 785 | return 'rcfilters-limit'; // Use RCFilters-specific preference |
| 786 | } |
| 787 | |
| 788 | protected function getSavedQueriesPreferenceName(): string { |
| 789 | return 'rcfilters-saved-queries'; |
| 790 | } |
| 791 | |
| 792 | protected function getDefaultDaysPreferenceName(): string { |
| 793 | return 'rcdays'; // Use general RecentChanges preference |
| 794 | } |
| 795 | |
| 796 | protected function getCollapsedPreferenceName(): string { |
| 797 | return 'rcfilters-rc-collapsed'; |
| 798 | } |
| 799 | |
| 800 | } |
| 801 | |
| 802 | /** |
| 803 | * Retain the old class name for backwards compatibility. |
| 804 | * @deprecated since 1.41 |
| 805 | */ |
| 806 | class_alias( SpecialRecentChanges::class, 'SpecialRecentChanges' ); |