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