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