Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.13% covered (warning)
71.13%
404 / 568
50.00% covered (danger)
50.00%
13 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialRecentChanges
71.25% covered (warning)
71.25%
404 / 567
50.00% covered (danger)
50.00%
13 / 26
397.47
0.00% covered (danger)
0.00%
0 / 1
 __construct
60.44% covered (warning)
60.44%
55 / 91
0.00% covered (danger)
0.00%
0 / 1
20.92
 execute
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
5.76
 transformFilterDefinition
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 needsWatchlistFeatures
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 registerFilters
84.00% covered (warning)
84.00%
21 / 25
0.00% covered (danger)
0.00%
0 / 1
5.10
 parseParameters
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 addWatchlistJoins
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
3.09
 doMainQuery
91.67% covered (success)
91.67%
44 / 48
0.00% covered (danger)
0.00%
0 / 1
7.03
 isDenseTagFilter
16.67% covered (danger)
16.67%
5 / 30
0.00% covered (danger)
0.00%
0 / 1
45.04
 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
74.47% covered (warning)
74.47%
35 / 47
0.00% covered (danger)
0.00%
0 / 1
26.66
 doHeader
90.14% covered (success)
90.14%
64 / 71
0.00% covered (danger)
0.00%
0 / 1
7.05
 setTopText
4.65% covered (danger)
4.65%
2 / 43
0.00% covered (danger)
0.00%
0 / 1
26.67
 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%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 optionsPanel
75.27% covered (warning)
75.27%
70 / 93
0.00% covered (danger)
0.00%
0 / 1
8.97
 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 * Implements Special:Recentchanges
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup SpecialPage
22 */
23
24namespace MediaWiki\Specials;
25
26use ChangesList;
27use ChangesListBooleanFilter;
28use ChangesListStringOptionsFilterGroup;
29use ChangeTags;
30use HtmlArmor;
31use MediaWiki\ChangeTags\ChangeTagsStore;
32use MediaWiki\Context\IContextSource;
33use MediaWiki\Html\FormOptions;
34use MediaWiki\Html\Html;
35use MediaWiki\MainConfigNames;
36use MediaWiki\MediaWikiServices;
37use MediaWiki\SpecialPage\ChangesListSpecialPage;
38use MediaWiki\Title\TitleValue;
39use MediaWiki\User\Options\UserOptionsLookup;
40use MediaWiki\User\TempUser\TempUserConfig;
41use MediaWiki\User\UserIdentityUtils;
42use MediaWiki\Utils\MWTimestamp;
43use MessageCache;
44use OOUI\ButtonWidget;
45use OOUI\HtmlSnippet;
46use RecentChange;
47use WatchedItemStoreInterface;
48use Wikimedia\Rdbms\IReadableDatabase;
49use Wikimedia\Rdbms\IResultWrapper;
50use Xml;
51
52/**
53 * A special page that lists last changes made to the wiki
54 *
55 * @ingroup SpecialPage
56 */
57class SpecialRecentChanges extends ChangesListSpecialPage {
58
59    private $watchlistFilterGroupDefinition;
60
61    private WatchedItemStoreInterface $watchedItemStore;
62    private MessageCache $messageCache;
63    private UserOptionsLookup $userOptionsLookup;
64
65    /** @var int */
66    public $denseRcSizeThreshold = 10000;
67    private ChangeTagsStore $changeTagsStore;
68
69    /**
70     * @param WatchedItemStoreInterface|null $watchedItemStore
71     * @param MessageCache|null $messageCache
72     * @param UserOptionsLookup|null $userOptionsLookup
73     * @param ChangeTagsStore|null $changeTagsStore
74     * @param UserIdentityUtils|null $userIdentityUtils
75     * @param TempUserConfig|null $tempUserConfig
76     */
77    public function __construct(
78        WatchedItemStoreInterface $watchedItemStore = null,
79        MessageCache $messageCache = null,
80        UserOptionsLookup $userOptionsLookup = null,
81        ChangeTagsStore $changeTagsStore = null,
82        UserIdentityUtils $userIdentityUtils = null,
83        TempUserConfig $tempUserConfig = null
84    ) {
85        // This class is extended and therefor fallback to global state - T265310
86        $services = MediaWikiServices::getInstance();
87
88        parent::__construct(
89            'Recentchanges',
90            '',
91            $userIdentityUtils ?? $services->getUserIdentityUtils(),
92            $tempUserConfig ?? $services->getTempUserConfig()
93        );
94        $this->watchedItemStore = $watchedItemStore ?? $services->getWatchedItemStore();
95        $this->messageCache = $messageCache ?? $services->getMessageCache();
96        $this->userOptionsLookup = $userOptionsLookup ?? $services->getUserOptionsLookup();
97        $this->changeTagsStore = $changeTagsStore ?? $services->getChangeTagsStore();
98
99        $this->watchlistFilterGroupDefinition = [
100            'name' => 'watchlist',
101            'title' => 'rcfilters-filtergroup-watchlist',
102            'class' => ChangesListStringOptionsFilterGroup::class,
103            'priority' => -9,
104            'isFullCoverage' => true,
105            'filters' => [
106                [
107                    'name' => 'watched',
108                    'label' => 'rcfilters-filter-watchlist-watched-label',
109                    'description' => 'rcfilters-filter-watchlist-watched-description',
110                    'cssClassSuffix' => 'watched',
111                    'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
112                        return $rc->getAttribute( 'wl_user' );
113                    }
114                ],
115                [
116                    'name' => 'watchednew',
117                    'label' => 'rcfilters-filter-watchlist-watchednew-label',
118                    'description' => 'rcfilters-filter-watchlist-watchednew-description',
119                    'cssClassSuffix' => 'watchednew',
120                    'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
121                        return $rc->getAttribute( 'wl_user' ) &&
122                            $rc->getAttribute( 'rc_timestamp' ) &&
123                            $rc->getAttribute( 'wl_notificationtimestamp' ) &&
124                            $rc->getAttribute( 'rc_timestamp' ) >= $rc->getAttribute( 'wl_notificationtimestamp' );
125                    },
126                ],
127                [
128                    'name' => 'notwatched',
129                    'label' => 'rcfilters-filter-watchlist-notwatched-label',
130                    'description' => 'rcfilters-filter-watchlist-notwatched-description',
131                    'cssClassSuffix' => 'notwatched',
132                    'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
133                        return $rc->getAttribute( 'wl_user' ) === null;
134                    },
135                ]
136            ],
137            'default' => ChangesListStringOptionsFilterGroup::NONE,
138            'queryCallable' => function ( string $specialClassName, IContextSource $ctx,
139                IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedValues
140            ) {
141                sort( $selectedValues );
142                $notwatchedCond = 'wl_user IS NULL';
143                $watchedCond = 'wl_user IS NOT NULL';
144                if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) ) {
145                    // Expired watchlist items stay in the DB after their expiry time until they're purged,
146                    // so it's not enough to only check for wl_user.
147                    $quotedNow = $dbr->addQuotes( $dbr->timestamp() );
148                    $notwatchedCond = "wl_user IS NULL OR ( we_expiry IS NOT NULL AND we_expiry < $quotedNow )";
149                    $watchedCond = "wl_user IS NOT NULL AND ( we_expiry IS NULL OR we_expiry >= $quotedNow )";
150                }
151                $newCond = 'rc_timestamp >= wl_notificationtimestamp';
152
153                if ( $selectedValues === [ 'notwatched' ] ) {
154                    $conds[] = $notwatchedCond;
155                    return;
156                }
157
158                if ( $selectedValues === [ 'watched' ] ) {
159                    $conds[] = $watchedCond;
160                    return;
161                }
162
163                if ( $selectedValues === [ 'watchednew' ] ) {
164                    $conds[] = $dbr->makeList( [
165                        $watchedCond,
166                        $newCond
167                    ], LIST_AND );
168                    return;
169                }
170
171                if ( $selectedValues === [ 'notwatched', 'watched' ] ) {
172                    // no filters
173                    return;
174                }
175
176                if ( $selectedValues === [ 'notwatched', 'watchednew' ] ) {
177                    $conds[] = $dbr->makeList( [
178                        $notwatchedCond,
179                        $dbr->makeList( [
180                            $watchedCond,
181                            $newCond
182                        ], LIST_AND )
183                    ], LIST_OR );
184                    return;
185                }
186
187                if ( $selectedValues === [ 'watched', 'watchednew' ] ) {
188                    $conds[] = $watchedCond;
189                    return;
190                }
191
192                if ( $selectedValues === [ 'notwatched', 'watched', 'watchednew' ] ) {
193                    // no filters
194                    return;
195                }
196            }
197        ];
198    }
199
200    /**
201     * @param string|null $subpage
202     */
203    public function execute( $subpage ) {
204        // Backwards-compatibility: redirect to new feed URLs
205        $feedFormat = $this->getRequest()->getVal( 'feed' );
206        if ( !$this->including() && $feedFormat ) {
207            $query = $this->getFeedQuery();
208            $query['feedformat'] = $feedFormat === 'atom' ? 'atom' : 'rss';
209            $this->getOutput()->redirect( wfAppendQuery( wfScript( 'api' ), $query ) );
210
211            return;
212        }
213
214        // 10 seconds server-side caching max
215        $out = $this->getOutput();
216        $out->setCdnMaxage( 10 );
217        // Check if the client has a cached version
218        $lastmod = $this->checkLastModified();
219        if ( $lastmod === false ) {
220            return;
221        }
222
223        $this->addHelpLink(
224            'https://meta.wikimedia.org/wiki/Special:MyLanguage/Help:Recent_changes',
225            true
226        );
227        parent::execute( $subpage );
228    }
229
230    /**
231     * @inheritDoc
232     */
233    protected function transformFilterDefinition( array $filterDefinition ) {
234        if ( isset( $filterDefinition['showHideSuffix'] ) ) {
235            $filterDefinition['showHide'] = 'rc' . $filterDefinition['showHideSuffix'];
236        }
237
238        return $filterDefinition;
239    }
240
241    /**
242     * Whether or not the current query needs to use watchlist data: check that the current user can
243     * use their watchlist and that this special page isn't being transcluded.
244     *
245     * @return bool
246     */
247    private function needsWatchlistFeatures(): bool {
248        return !$this->including()
249            && $this->getUser()->isRegistered()
250            && $this->getAuthority()->isAllowed( 'viewmywatchlist' );
251    }
252
253    /**
254     * @inheritDoc
255     */
256    protected function registerFilters() {
257        parent::registerFilters();
258
259        if ( $this->needsWatchlistFeatures() ) {
260            $this->registerFiltersFromDefinitions( [ $this->watchlistFilterGroupDefinition ] );
261            $watchlistGroup = $this->getFilterGroup( 'watchlist' );
262            $watchlistGroup->getFilter( 'watched' )->setAsSupersetOf(
263                $watchlistGroup->getFilter( 'watchednew' )
264            );
265        }
266
267        $user = $this->getUser();
268
269        $significance = $this->getFilterGroup( 'significance' );
270        /** @var ChangesListBooleanFilter $hideMinor */
271        $hideMinor = $significance->getFilter( 'hideminor' );
272        '@phan-var ChangesListBooleanFilter $hideMinor';
273        $hideMinor->setDefault( $this->userOptionsLookup->getBoolOption( $user, 'hideminor' ) );
274
275        $automated = $this->getFilterGroup( 'automated' );
276        /** @var ChangesListBooleanFilter $hideBots */
277        $hideBots = $automated->getFilter( 'hidebots' );
278        '@phan-var ChangesListBooleanFilter $hideBots';
279        $hideBots->setDefault( true );
280
281        /** @var ChangesListStringOptionsFilterGroup|null $reviewStatus */
282        $reviewStatus = $this->getFilterGroup( 'reviewStatus' );
283        '@phan-var ChangesListStringOptionsFilterGroup|null $reviewStatus';
284        if ( $reviewStatus !== null ) {
285            // Conditional on feature being available and rights
286            if ( $this->userOptionsLookup->getBoolOption( $user, 'hidepatrolled' ) ) {
287                $reviewStatus->setDefault( 'unpatrolled' );
288                $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' );
289                /** @var ChangesListBooleanFilter $legacyHidePatrolled */
290                $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' );
291                '@phan-var ChangesListBooleanFilter $legacyHidePatrolled';
292                $legacyHidePatrolled->setDefault( true );
293            }
294        }
295
296        $changeType = $this->getFilterGroup( 'changeType' );
297        /** @var ChangesListBooleanFilter $hideCategorization */
298        $hideCategorization = $changeType->getFilter( 'hidecategorization' );
299        '@phan-var ChangesListBooleanFilter $hideCategorization';
300        if ( $hideCategorization !== null ) {
301            // Conditional on feature being available
302            $hideCategorization->setDefault( $this->userOptionsLookup->getBoolOption( $user, 'hidecategorization' ) );
303        }
304    }
305
306    /**
307     * Process $par and put options found in $opts. Used when including the page.
308     *
309     * @param string $par
310     * @param FormOptions $opts
311     */
312    public function parseParameters( $par, FormOptions $opts ) {
313        parent::parseParameters( $par, $opts );
314
315        $bits = preg_split( '/\s*,\s*/', trim( $par ) );
316        foreach ( $bits as $bit ) {
317            if ( is_numeric( $bit ) ) {
318                $opts['limit'] = $bit;
319            }
320
321            $m = [];
322            if ( preg_match( '/^limit=(\d+)$/', $bit, $m ) ) {
323                $opts['limit'] = $m[1];
324            }
325            if ( preg_match( '/^days=(\d+(?:\.\d+)?)$/', $bit, $m ) ) {
326                $opts['days'] = $m[1];
327            }
328            if ( preg_match( '/^namespace=(.*)$/', $bit, $m ) ) {
329                $opts['namespace'] = $m[1];
330            }
331            if ( preg_match( '/^tagfilter=(.*)$/', $bit, $m ) ) {
332                $opts['tagfilter'] = $m[1];
333            }
334        }
335    }
336
337    /**
338     * Add required values to a query's $tables, $fields, $joinConds, and $conds arrays to join to
339     * the watchlist and watchlist_expiry tables where appropriate.
340     *
341     * SpecialRecentChangesLinked should also be updated accordingly when something changed here.
342     *
343     * @param IReadableDatabase $dbr
344     * @param string[] &$tables
345     * @param string[] &$fields
346     * @param mixed[] &$joinConds
347     * @param mixed[] &$conds
348     */
349    protected function addWatchlistJoins( IReadableDatabase $dbr, &$tables, &$fields, &$joinConds, &$conds ) {
350        if ( !$this->needsWatchlistFeatures() ) {
351            return;
352        }
353
354        // Join on watchlist table.
355        $tables[] = 'watchlist';
356        $fields[] = 'wl_user';
357        $fields[] = 'wl_notificationtimestamp';
358        $joinConds['watchlist'] = [ 'LEFT JOIN', [
359            'wl_user' => $this->getUser()->getId(),
360            'wl_title=rc_title',
361            'wl_namespace=rc_namespace'
362        ] ];
363
364        // Exclude expired watchlist items.
365        if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) ) {
366            $tables[] = 'watchlist_expiry';
367            $fields[] = 'we_expiry';
368            $joinConds['watchlist_expiry'] = [ 'LEFT JOIN', 'wl_id = we_item' ];
369        }
370    }
371
372    /**
373     * @inheritDoc
374     */
375    protected function doMainQuery( $tables, $fields, $conds, $query_options,
376        $join_conds, FormOptions $opts
377    ) {
378        $dbr = $this->getDB();
379
380        $rcQuery = RecentChange::getQueryInfo();
381        $tables = array_merge( $rcQuery['tables'], $tables );
382        $fields = array_merge( $rcQuery['fields'], $fields );
383        $join_conds = array_merge( $rcQuery['joins'], $join_conds );
384
385        // Join with watchlist and watchlist_expiry tables to highlight watched rows.
386        $this->addWatchlistJoins( $dbr, $tables, $fields, $join_conds, $conds );
387
388        // JOIN on page, used for 'last revision' filter highlight
389        $tables[] = 'page';
390        $fields[] = 'page_latest';
391        $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
392
393        $tagFilter = $opts['tagfilter'] !== '' ? explode( '|', $opts['tagfilter'] ) : [];
394        $this->changeTagsStore->modifyDisplayQuery(
395            $tables,
396            $fields,
397            $conds,
398            $join_conds,
399            $query_options,
400            $tagFilter,
401            $opts['inverttags']
402        );
403
404        if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
405            $opts )
406        ) {
407            return false;
408        }
409
410        if ( $this->areFiltersInConflict() ) {
411            return false;
412        }
413
414        $orderByAndLimit = [
415            'ORDER BY' => 'rc_timestamp DESC',
416            'LIMIT' => $opts['limit']
417        ];
418
419        // Workaround for T298225: MySQL's lack of awareness of LIMIT when
420        // choosing the join order.
421        $ctTableName = ChangeTags::DISPLAY_TABLE_ALIAS;
422        if ( isset( $join_conds[$ctTableName] )
423            && $this->isDenseTagFilter( $conds["$ctTableName.ct_tag_id"] ?? [], $opts['limit'] )
424        ) {
425            $join_conds[$ctTableName][0] = 'STRAIGHT_JOIN';
426        }
427
428        if ( in_array( 'DISTINCT', $query_options ) ) {
429            // ChangeTagsStore::modifyDisplayQuery() adds DISTINCT when filtering on multiple tags.
430            // In order to prevent DISTINCT from causing query performance problems,
431            // we have to GROUP BY the primary key. This in turn requires us to add
432            // the primary key to the end of the ORDER BY, and the old ORDER BY to the
433            // start of the GROUP BY
434            $orderByAndLimit['ORDER BY'] = 'rc_timestamp DESC, rc_id DESC';
435            $orderByAndLimit['GROUP BY'] = 'rc_timestamp, rc_id';
436        }
437
438        // rc_new is not an ENUM, but adding a redundant rc_new IN (0,1) gives mysql enough
439        // knowledge to use an index merge if it wants (it may use some other index though).
440        $conds += [ 'rc_new' => [ 0, 1 ] ];
441
442        // array_merge() is used intentionally here so that hooks can, should
443        // they so desire, override the ORDER BY / LIMIT condition(s); prior to
444        // MediaWiki 1.26 this used to use the plus operator instead, which meant
445        // that extensions weren't able to change these conditions
446        $query_options = array_merge( $orderByAndLimit, $query_options );
447        $query_options['MAX_EXECUTION_TIME'] =
448            $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries );
449        $rows = $dbr->select(
450            $tables,
451            $fields,
452            $conds,
453            __METHOD__,
454            $query_options,
455            $join_conds
456        );
457
458        return $rows;
459    }
460
461    /**
462     * Determine whether a tag filter matches a high proportion of the rows in
463     * recentchanges. If so, it is more efficient to scan recentchanges,
464     * filtering out non-matching rows, rather than scanning change_tag and
465     * then filesorting on rc_timestamp. MySQL is especially bad at making this
466     * judgement (T298225).
467     *
468     * @param int[] $tagIds
469     * @param int $limit
470     * @return bool
471     */
472    protected function isDenseTagFilter( $tagIds, $limit ) {
473        $dbr = $this->getDB();
474        if ( !$tagIds
475            // This is a MySQL-specific hack
476            || $dbr->getType() !== 'mysql'
477            // Unnecessary for small wikis
478            || !$this->getConfig()->get( MainConfigNames::MiserMode )
479        ) {
480            return false;
481        }
482
483        $rcInfo = $dbr->newSelectQueryBuilder()
484            ->select( [
485                'min_id' => 'MIN(rc_id)',
486                'max_id' => 'MAX(rc_id)',
487            ] )
488            ->from( 'recentchanges' )
489            ->caller( __METHOD__ )
490            ->fetchRow();
491        if ( !$rcInfo || $rcInfo->min_id === null ) {
492            return false;
493        }
494        $rcSize = $rcInfo->max_id - $rcInfo->min_id;
495        if ( $rcSize < $this->denseRcSizeThreshold ) {
496            // RC is too small to worry about
497            return false;
498        }
499        $tagCount = $dbr->newSelectQueryBuilder()
500            ->table( 'change_tag' )
501            ->where( [
502                $dbr->expr( 'ct_rc_id', '>=', $rcInfo->min_id ),
503                'ct_tag_id' => $tagIds
504            ] )
505            ->caller( __METHOD__ )
506            ->estimateRowCount();
507
508        // If we scan recentchanges first, the number of rows examined will be
509        // approximately the limit divided by the proportion of tagged rows,
510        // i.e. $limit / ( $tagCount / $rcSize ). If that's less than $tagCount,
511        // use a straight join. The inequality below is rearranged for
512        // simplicity and to avoid division by zero.
513        $isDense = $limit * $rcSize < $tagCount * $tagCount;
514
515        wfDebug( __METHOD__ . ": rcSize = $rcSize, tagCount = $tagCount, limit = $limit => " .
516            ( $isDense ? 'dense' : 'sparse' ) );
517        return $isDense;
518    }
519
520    public function outputFeedLinks() {
521        $this->addFeedLinks( $this->getFeedQuery() );
522    }
523
524    /**
525     * Get URL query parameters for action=feedrecentchanges API feed of current recent changes view.
526     *
527     * @return array
528     */
529    protected function getFeedQuery() {
530        $query = array_filter( $this->getOptions()->getAllValues(), static function ( $value ) {
531            // API handles empty parameters in a different way
532            return $value !== '';
533        } );
534        $query['action'] = 'feedrecentchanges';
535        $feedLimit = $this->getConfig()->get( MainConfigNames::FeedLimit );
536        if ( $query['limit'] > $feedLimit ) {
537            $query['limit'] = $feedLimit;
538        }
539
540        return $query;
541    }
542
543    /**
544     * Build and output the actual changes list.
545     *
546     * @param IResultWrapper $rows Database rows
547     * @param FormOptions $opts
548     */
549    public function outputChangesList( $rows, $opts ) {
550        $limit = $opts['limit'];
551
552        $showWatcherCount = $this->getConfig()->get( MainConfigNames::RCShowWatchingUsers )
553            && $this->userOptionsLookup->getBoolOption( $this->getUser(), 'shownumberswatching' );
554        $watcherCache = [];
555
556        $counter = 1;
557        $list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups );
558        $list->initChangesListRows( $rows );
559
560        $userShowHiddenCats = $this->userOptionsLookup->getBoolOption( $this->getUser(), 'showhiddencats' );
561        $rclistOutput = $list->beginRecentChangesList();
562        if ( $this->isStructuredFilterUiEnabled() ) {
563            $rclistOutput .= $this->makeLegend();
564        }
565
566        foreach ( $rows as $obj ) {
567            if ( $limit == 0 ) {
568                break;
569            }
570            $rc = RecentChange::newFromRow( $obj );
571
572            # Skip CatWatch entries for hidden cats based on user preference
573            if (
574                $rc->getAttribute( 'rc_type' ) == RC_CATEGORIZE &&
575                !$userShowHiddenCats &&
576                $rc->getParam( 'hidden-cat' )
577            ) {
578                continue;
579            }
580
581            $rc->counter = $counter++;
582            # Check if the page has been updated since the last visit
583            if ( $this->getConfig()->get( MainConfigNames::ShowUpdatedMarker )
584                && !empty( $obj->wl_notificationtimestamp )
585            ) {
586                $rc->notificationtimestamp = ( $obj->rc_timestamp >= $obj->wl_notificationtimestamp );
587            } else {
588                $rc->notificationtimestamp = false; // Default
589            }
590            # Check the number of users watching the page
591            $rc->numberofWatchingusers = 0; // Default
592            if ( $showWatcherCount && $obj->rc_namespace >= 0 ) {
593                if ( !isset( $watcherCache[$obj->rc_namespace][$obj->rc_title] ) ) {
594                    $watcherCache[$obj->rc_namespace][$obj->rc_title] =
595                        $this->watchedItemStore->countWatchers(
596                            new TitleValue( (int)$obj->rc_namespace, $obj->rc_title )
597                        );
598                }
599                $rc->numberofWatchingusers = $watcherCache[$obj->rc_namespace][$obj->rc_title];
600            }
601
602            $watched = !empty( $obj->wl_user );
603            if ( $watched && $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) ) {
604                $notExpired = $obj->we_expiry === null
605                    || MWTimestamp::convert( TS_UNIX, $obj->we_expiry ) > wfTimestamp();
606                $watched = $watched && $notExpired;
607            }
608            $changeLine = $list->recentChangesLine( $rc, $watched, $counter );
609            if ( $changeLine !== false ) {
610                $rclistOutput .= $changeLine;
611                --$limit;
612            }
613        }
614        $rclistOutput .= $list->endRecentChangesList();
615
616        if ( $rows->numRows() === 0 ) {
617            $this->outputNoResults();
618            if ( !$this->including() ) {
619                $this->getOutput()->setStatusCode( 404 );
620            }
621        } else {
622            $this->getOutput()->addHTML( $rclistOutput );
623        }
624    }
625
626    /**
627     * Set the text to be displayed above the changes
628     *
629     * @param FormOptions $opts
630     * @param int $numRows Number of rows in the result to show after this header
631     */
632    public function doHeader( $opts, $numRows ) {
633        $this->setTopText( $opts );
634
635        $defaults = $opts->getAllValues();
636        $nondefaults = $opts->getChangedValues();
637
638        $panel = [];
639        if ( !$this->isStructuredFilterUiEnabled() ) {
640            $panel[] = $this->makeLegend();
641        }
642        $panel[] = $this->optionsPanel( $defaults, $nondefaults, $numRows );
643        $panel[] = '<hr />';
644
645        $extraOpts = $this->getExtraOptions( $opts );
646        $extraOptsCount = count( $extraOpts );
647        $count = 0;
648        $submit = ' ' . Html::submitButton( $this->msg( 'recentchanges-submit' )->text() );
649
650        $out = Html::openElement( 'table', [ 'class' => 'mw-recentchanges-table' ] );
651        foreach ( $extraOpts as $name => $optionRow ) {
652            # Add submit button to the last row only
653            ++$count;
654            $addSubmit = ( $count === $extraOptsCount ) ? $submit : '';
655
656            $out .= Html::openElement( 'tr', [ 'class' => $name . 'Form' ] );
657            if ( is_array( $optionRow ) ) {
658                $out .= Html::rawElement(
659                    'td',
660                    [ 'class' => 'mw-label mw-' . $name . '-label' ],
661                    $optionRow[0]
662                );
663                $out .= Html::rawElement(
664                    'td',
665                    [ 'class' => 'mw-input' ],
666                    $optionRow[1] . $addSubmit
667                );
668            } else {
669                $out .= Html::rawElement(
670                    'td',
671                    [ 'class' => 'mw-input', 'colspan' => 2 ],
672                    $optionRow . $addSubmit
673                );
674            }
675            $out .= Html::closeElement( 'tr' );
676        }
677        $out .= Html::closeElement( 'table' );
678
679        $unconsumed = $opts->getUnconsumedValues();
680        foreach ( $unconsumed as $key => $value ) {
681            $out .= Html::hidden( $key, $value );
682        }
683
684        $t = $this->getPageTitle();
685        $out .= Html::hidden( 'title', $t->getPrefixedText() );
686        $form = Html::rawElement( 'form', [ 'action' => wfScript() ], $out );
687        $panel[] = $form;
688        $panelString = implode( "\n", $panel );
689
690        $rcoptions = Xml::fieldset(
691            $this->msg( 'recentchanges-legend' )->text(),
692            $panelString,
693            [ 'class' => 'rcoptions cloptions' ]
694        );
695
696        // Insert a placeholder for RCFilters
697        if ( $this->isStructuredFilterUiEnabled() ) {
698            $rcfilterContainer = Html::element(
699                'div',
700                [ 'class' => 'mw-rcfilters-container' ]
701            );
702
703            $loadingContainer = Html::rawElement(
704                'div',
705                [ 'class' => 'mw-rcfilters-spinner' ],
706                Html::element(
707                    'div',
708                    [ 'class' => 'mw-rcfilters-spinner-bounce' ]
709                )
710            );
711
712            // Wrap both with mw-rcfilters-head
713            $this->getOutput()->addHTML(
714                Html::rawElement(
715                    'div',
716                    [ 'class' => 'mw-rcfilters-head' ],
717                    $rcfilterContainer . $rcoptions
718                )
719            );
720
721            // Add spinner
722            $this->getOutput()->addHTML( $loadingContainer );
723        } else {
724            $this->getOutput()->addHTML( $rcoptions );
725        }
726
727        $this->setBottomText( $opts );
728    }
729
730    /**
731     * Send the text to be displayed above the options
732     *
733     * @param FormOptions $opts Unused
734     */
735    public function setTopText( FormOptions $opts ) {
736        $message = $this->msg( 'recentchangestext' )->inContentLanguage();
737        if ( !$message->isDisabled() ) {
738            $contLang = $this->getContentLanguage();
739            // Parse the message in this weird ugly way to preserve the ability to include interlanguage
740            // links in it (T172461). In the future when T66969 is resolved, perhaps we can just use
741            // $message->parse() instead. This code is copied from Message::parseText().
742            $parserOutput = $this->messageCache->parse(
743                $message->plain(),
744                $this->getPageTitle(),
745                /*linestart*/true,
746                // Message class sets the interface flag to false when parsing in a language different than
747                // user language, and this is wiki content language
748                /*interface*/false,
749                $contLang
750            );
751            $content = $parserOutput->getText( [
752                'enableSectionEditLinks' => false,
753            ] );
754            // Add only metadata here (including the language links), text is added below
755            $this->getOutput()->addParserOutputMetadata( $parserOutput );
756
757            $langAttributes = [
758                'lang' => $contLang->getHtmlCode(),
759                'dir' => $contLang->getDir(),
760            ];
761
762            $topLinksAttributes = [ 'class' => 'mw-recentchanges-toplinks' ];
763
764            if ( $this->isStructuredFilterUiEnabled() ) {
765                // Check whether the widget is already collapsed or expanded
766                $collapsedState = $this->getRequest()->getCookie( 'rcfilters-toplinks-collapsed-state' );
767                // Note that an empty/unset cookie means collapsed, so check for !== 'expanded'
768                $topLinksAttributes[ 'class' ] .= $collapsedState !== 'expanded' ?
769                    ' mw-recentchanges-toplinks-collapsed' : '';
770
771                $this->getOutput()->enableOOUI();
772                $contentTitle = new ButtonWidget( [
773                    'classes' => [ 'mw-recentchanges-toplinks-title' ],
774                    'label' => new HtmlSnippet( $this->msg( 'rcfilters-other-review-tools' )->parse() ),
775                    'framed' => false,
776                    'indicator' => $collapsedState !== 'expanded' ? 'down' : 'up',
777                    'flags' => [ 'progressive' ],
778                ] );
779
780                $contentWrapper = Html::rawElement( 'div',
781                    array_merge(
782                        [ 'class' => 'mw-recentchanges-toplinks-content mw-collapsible-content' ],
783                        $langAttributes
784                    ),
785                    $content
786                );
787                $content = $contentTitle . $contentWrapper;
788            } else {
789                // Language direction should be on the top div only
790                // if the title is not there. If it is there, it's
791                // interface direction, and the language/dir attributes
792                // should be on the content itself
793                $topLinksAttributes = array_merge( $topLinksAttributes, $langAttributes );
794            }
795
796            $this->getOutput()->addHTML(
797                Html::rawElement( 'div', $topLinksAttributes, $content )
798            );
799        }
800    }
801
802    /**
803     * Get options to be displayed in a form
804     *
805     * @param FormOptions $opts
806     * @return array
807     */
808    public function getExtraOptions( $opts ) {
809        $opts->consumeValues( [
810            'namespace', 'invert', 'associated', 'tagfilter', 'inverttags'
811        ] );
812
813        $extraOpts = [];
814        $extraOpts['namespace'] = $this->namespaceFilterForm( $opts );
815
816        $tagFilter = ChangeTags::buildTagFilterSelector(
817            $opts['tagfilter'], false, $this->getContext()
818        );
819        if ( $tagFilter ) {
820            $tagFilter[1] .= ' ' . Html::rawElement( 'span', [ 'class' => [ 'mw-input-with-label' ] ],
821                Html::element( 'input', [
822                    'type' => 'checkbox', 'name' => 'inverttags', 'value' => '1', 'checked' => $opts['inverttags'],
823                    'id' => 'inverttags'
824                ] ) . '&nbsp;' . Html::label( $this->msg( 'invert' )->text(), 'inverttags' )
825            );
826            $extraOpts['tagfilter'] = $tagFilter;
827        }
828
829        // Don't fire the hook for subclasses. (Or should we?)
830        if ( $this->getName() === 'Recentchanges' ) {
831            $this->getHookRunner()->onSpecialRecentChangesPanel( $extraOpts, $opts );
832        }
833
834        return $extraOpts;
835    }
836
837    /**
838     * Get last modified date, for client caching
839     * Don't use this if we are using the patrol feature, patrol changes don't
840     * update the timestamp
841     *
842     * @return string|bool
843     */
844    public function checkLastModified() {
845        $dbr = $this->getDB();
846        $lastmod = $dbr->newSelectQueryBuilder()
847            ->select( 'MAX(rc_timestamp)' )
848            ->from( 'recentchanges' )
849            ->caller( __METHOD__ )->fetchField();
850
851        return $lastmod;
852    }
853
854    /**
855     * Creates the choose namespace selection
856     *
857     * @param FormOptions $opts
858     * @return string[]
859     */
860    protected function namespaceFilterForm( FormOptions $opts ) {
861        $nsSelect = Html::namespaceSelector(
862            [ 'selected' => $opts['namespace'], 'all' => '', 'in-user-lang' => true ],
863            [ 'name' => 'namespace', 'id' => 'namespace' ]
864        );
865        $nsLabel = Html::label( $this->msg( 'namespace' )->text(), 'namespace' );
866        $invert = Html::rawElement( 'label', [
867            'class' => 'mw-input-with-label', 'title' => $this->msg( 'tooltip-invert' )->text(),
868        ], Html::element( 'input', [
869            'type' => 'checkbox', 'name' => 'invert', 'value' => '1', 'checked' => $opts['invert'],
870        ] ) . '&nbsp;' . $this->msg( 'invert' )->escaped() );
871        $associated = Html::rawElement( 'label', [
872            'class' => 'mw-input-with-label', 'title' => $this->msg( 'tooltip-namespace_association' )->text(),
873        ], Html::element( 'input', [
874            'type' => 'checkbox', 'name' => 'associated', 'value' => '1', 'checked' => $opts['associated'],
875        ] ) . '&nbsp;' . $this->msg( 'namespace_association' )->escaped() );
876
877        return [ $nsLabel, "$nsSelect $invert $associated" ];
878    }
879
880    /**
881     * Makes change an option link which carries all the other options
882     *
883     * @param string $title
884     * @param array $override Options to override
885     * @param array $options Current options
886     * @param bool $active Whether to show the link in bold
887     * @return string
888     * Annotations needed to tell taint about HtmlArmor
889     * @param-taint $title escapes_html
890     */
891    private function makeOptionsLink( $title, $override, $options, $active = false ) {
892        $params = $this->convertParamsForLink( $override + $options );
893
894        if ( $active ) {
895            $title = new HtmlArmor( '<strong>' . htmlspecialchars( $title ) . '</strong>' );
896        }
897
898        return $this->getLinkRenderer()->makeKnownLink( $this->getPageTitle(), $title, [
899            'data-params' => json_encode( $override ),
900            'data-keys' => implode( ',', array_keys( $override ) ),
901        ], $params );
902    }
903
904    /**
905     * Creates the options panel.
906     *
907     * @param array $defaults
908     * @param array $nondefaults
909     * @param int $numRows Number of rows in the result to show after this header
910     * @return string
911     */
912    private function optionsPanel( $defaults, $nondefaults, $numRows ) {
913        $options = $nondefaults + $defaults;
914
915        $note = '';
916        $msg = $this->msg( 'rclegend' );
917        if ( !$msg->isDisabled() ) {
918            $note .= Html::rawElement(
919                'div',
920                [ 'class' => 'mw-rclegend' ],
921                $msg->parse()
922            );
923        }
924
925        $lang = $this->getLanguage();
926        $user = $this->getUser();
927        $config = $this->getConfig();
928        if ( $options['from'] ) {
929            $resetLink = $this->makeOptionsLink( $this->msg( 'rclistfromreset' )->text(),
930                [ 'from' => '' ], $nondefaults );
931
932            $noteFromMsg = $this->msg( 'rcnotefrom' )
933                ->numParams( $options['limit'] )
934                ->params(
935                    $lang->userTimeAndDate( $options['from'], $user ),
936                    $lang->userDate( $options['from'], $user ),
937                    $lang->userTime( $options['from'], $user )
938                )
939                ->numParams( $numRows );
940            $note .= Html::rawElement(
941                    'span',
942                    [ 'class' => 'rcnotefrom' ],
943                    $noteFromMsg->parse()
944                ) .
945                ' ' .
946                Html::rawElement(
947                    'span',
948                    [ 'class' => 'rcoptions-listfromreset' ],
949                    $this->msg( 'parentheses' )->rawParams( $resetLink )->parse()
950                ) .
951                '<br />';
952        }
953
954        # Sort data for display and make sure it's unique after we've added user data.
955        $linkLimits = $config->get( MainConfigNames::RCLinkLimits );
956        $linkLimits[] = $options['limit'];
957        sort( $linkLimits );
958        $linkLimits = array_unique( $linkLimits );
959
960        $linkDays = $this->getLinkDays();
961        $linkDays[] = $options['days'];
962        sort( $linkDays );
963        $linkDays = array_unique( $linkDays );
964
965        // limit links
966        $cl = [];
967        foreach ( $linkLimits as $value ) {
968            $cl[] = $this->makeOptionsLink( $lang->formatNum( $value ),
969                [ 'limit' => $value ], $nondefaults, $value == $options['limit'] );
970        }
971        $cl = $lang->pipeList( $cl );
972
973        // day links, reset 'from' to none
974        $dl = [];
975        foreach ( $linkDays as $value ) {
976            $dl[] = $this->makeOptionsLink( $lang->formatNum( $value ),
977                [ 'days' => $value, 'from' => '' ], $nondefaults, $value == $options['days'] );
978        }
979        $dl = $lang->pipeList( $dl );
980
981        $showhide = [ 'show', 'hide' ];
982
983        $links = [];
984
985        foreach ( $this->getLegacyShowHideFilters() as $key => $filter ) {
986            $msg = $filter->getShowHide();
987            $linkMessage = $this->msg( $msg . '-' . $showhide[1 - $options[$key]] );
988            // Extensions can define additional filters, but don't need to define the corresponding
989            // messages. If they don't exist, just fall back to 'show' and 'hide'.
990            if ( !$linkMessage->exists() ) {
991                $linkMessage = $this->msg( $showhide[1 - $options[$key]] );
992            }
993
994            $link = $this->makeOptionsLink( $linkMessage->text(),
995                [ $key => 1 - $options[$key] ], $nondefaults );
996
997            $attribs = [
998                'class' => "$msg rcshowhideoption clshowhideoption",
999                'data-filter-name' => $filter->getName(),
1000            ];
1001
1002            if ( $filter->isFeatureAvailableOnStructuredUi() ) {
1003                $attribs['data-feature-in-structured-ui'] = true;
1004            }
1005
1006            $links[] = Html::rawElement(
1007                'span',
1008                $attribs,
1009                $this->msg( $msg )->rawParams( $link )->parse()
1010            );
1011        }
1012
1013        // show from this onward link
1014        $timestamp = wfTimestampNow();
1015        $now = $lang->userTimeAndDate( $timestamp, $user );
1016        $timenow = $lang->userTime( $timestamp, $user );
1017        $datenow = $lang->userDate( $timestamp, $user );
1018        $pipedLinks = '<span class="rcshowhide">' . $lang->pipeList( $links ) . '</span>';
1019
1020        $rclinks = Html::rawElement(
1021            'span',
1022            [ 'class' => 'rclinks' ],
1023            $this->msg( 'rclinks' )->rawParams( $cl, $dl, '' )->parse()
1024        );
1025
1026        $rclistfrom = Html::rawElement(
1027            'span',
1028            [ 'class' => 'rclistfrom' ],
1029            $this->makeOptionsLink(
1030                $this->msg( 'rclistfrom' )->plaintextParams( $now, $timenow, $datenow )->text(),
1031                [ 'from' => $timestamp, 'fromFormatted' => $now ],
1032                $nondefaults
1033            )
1034        );
1035
1036        return "{$note}$rclinks<br />$pipedLinks<br />$rclistfrom";
1037    }
1038
1039    public function isIncludable() {
1040        return true;
1041    }
1042
1043    protected function getCacheTTL() {
1044        return 60 * 5;
1045    }
1046
1047    public function getDefaultLimit() {
1048        $systemPrefValue = $this->userOptionsLookup->getIntOption( $this->getUser(), 'rclimit' );
1049        // Prefer the RCFilters-specific preference if RCFilters is enabled
1050        if ( $this->isStructuredFilterUiEnabled() ) {
1051            return $this->userOptionsLookup->getIntOption(
1052                $this->getUser(), $this->getLimitPreferenceName(), $systemPrefValue
1053            );
1054        }
1055
1056        // Otherwise, use the system rclimit preference value
1057        return $systemPrefValue;
1058    }
1059
1060    /**
1061     * @return string
1062     */
1063    protected function getLimitPreferenceName(): string {
1064        return 'rcfilters-limit'; // Use RCFilters-specific preference
1065    }
1066
1067    /**
1068     * @return string
1069     */
1070    protected function getSavedQueriesPreferenceName(): string {
1071        return 'rcfilters-saved-queries';
1072    }
1073
1074    /**
1075     * @return string
1076     */
1077    protected function getDefaultDaysPreferenceName(): string {
1078        return 'rcdays'; // Use general RecentChanges preference
1079    }
1080
1081    /**
1082     * @return string
1083     */
1084    protected function getCollapsedPreferenceName(): string {
1085        return 'rcfilters-rc-collapsed';
1086    }
1087
1088}
1089
1090/**
1091 * Retain the old class name for backwards compatibility.
1092 * @deprecated since 1.41
1093 */
1094class_alias( SpecialRecentChanges::class, 'SpecialRecentChanges' );