Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.18% covered (warning)
79.18%
346 / 437
64.00% covered (warning)
64.00%
16 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialRecentChanges
79.36% covered (warning)
79.36%
346 / 436
64.00% covered (warning)
64.00%
16 / 25
143.59
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 getExtraFilterGroupDefinitions
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
1
 execute
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
5.76
 getExtraFilterFactoryConfig
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 needsWatchlistFeatures
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getFilterDefaultOverrides
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
2.01
 parseParameters
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 modifyQuery
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 outputFeedLinks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFeedQuery
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 outputChangesList
76.09% covered (warning)
76.09%
35 / 46
0.00% covered (danger)
0.00%
0 / 1
25.47
 doHeader
90.54% covered (success)
90.54%
67 / 74
0.00% covered (danger)
0.00%
0 / 1
7.04
 setTopText
4.88% covered (danger)
4.88%
2 / 41
0.00% covered (danger)
0.00%
0 / 1
26.52
 getExtraOptions
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 checkLastModified
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 namespaceFilterForm
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 makeOptionsLink
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 optionsPanel
76.53% covered (warning)
76.53%
75 / 98
0.00% covered (danger)
0.00%
0 / 1
12.56
 isIncludable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCacheTTL
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultLimit
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getLimitPreferenceName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSavedQueriesPreferenceName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultDaysPreferenceName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCollapsedPreferenceName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Specials;
8
9use MediaWiki\ChangeTags\ChangeTags;
10use MediaWiki\Html\FormOptions;
11use MediaWiki\Html\Html;
12use MediaWiki\Language\MessageParser;
13use MediaWiki\MainConfigNames;
14use MediaWiki\MediaWikiServices;
15use MediaWiki\Page\PageReferenceValue;
16use MediaWiki\RecentChanges\ChangesList;
17use MediaWiki\RecentChanges\ChangesListQuery\ChangesListQuery;
18use MediaWiki\RecentChanges\ChangesListQuery\ChangesListQueryFactory;
19use MediaWiki\RecentChanges\ChangesListStringOptionsFilterGroup;
20use MediaWiki\RecentChanges\RecentChange;
21use MediaWiki\RecentChanges\RecentChangeFactory;
22use MediaWiki\SpecialPage\ChangesListSpecialPage;
23use MediaWiki\User\Options\UserOptionsLookup;
24use MediaWiki\User\TempUser\TempUserConfig;
25use MediaWiki\User\UserIdentityUtils;
26use MediaWiki\Utils\MWTimestamp;
27use MediaWiki\Watchlist\WatchedItemStoreInterface;
28use OOUI\ButtonWidget;
29use OOUI\HtmlSnippet;
30use Wikimedia\HtmlArmor\HtmlArmor;
31use Wikimedia\Rdbms\IResultWrapper;
32use 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 */
40class 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                ] ) . '&nbsp;' . 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        ] ) . '&nbsp;' . $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        ] ) . '&nbsp;' . $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 */
806class_alias( SpecialRecentChanges::class, 'SpecialRecentChanges' );