Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.96% covered (warning)
84.96%
689 / 811
55.56% covered (warning)
55.56%
30 / 54
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangesListSpecialPage
85.06% covered (warning)
85.06%
689 / 810
55.56% covered (warning)
55.56%
30 / 54
207.27
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getBaseFilterGroupDefinitions
100.00% covered (success)
100.00%
320 / 320
100.00% covered (success)
100.00%
1 / 1
2
 getExtraFilterGroupDefinitions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
52.08% covered (warning)
52.08%
25 / 48
0.00% covered (danger)
0.00%
0 / 1
39.75
 setTempUserConfig
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 considerActionsForDefaultSavedQuery
39.39% covered (danger)
39.39%
13 / 33
0.00% covered (danger)
0.00%
0 / 1
27.03
 getLinkDays
45.45% covered (danger)
45.45%
5 / 11
0.00% covered (danger)
0.00%
0 / 1
6.60
 includeRcFiltersApp
95.24% covered (success)
95.24%
40 / 42
0.00% covered (danger)
0.00%
0 / 1
5
 getRcFiltersConfigSummary
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getRcFiltersConfigVars
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 outputNoResults
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 outputTimeout
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getRows
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getQueryResult
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
4.43
 newRecentChangeFromRow
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getOptions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getBaseFilterFactoryConfig
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getExtraFilterFactoryConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFilterDefaultOverrides
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFilterFactory
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 registerFilters
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 registerFiltersFromDefinitions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setup
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getDefaultOptions
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 registerFilterGroup
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFilterGroup
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStructuredFilterJsData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchOptionsFromRequest
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parseParameters
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 validateOptions
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 fixContradictoryOptions
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 fixBackwardsCompatibilityOptions
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 replaceOldOptions
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
7
 convertParamsForLink
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 buildQuery
95.89% covered (success)
95.89%
70 / 73
0.00% covered (danger)
0.00%
0 / 1
14
 modifyQuery
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 runMainQueryHook
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 addMainQueryHook
10.00% covered (danger)
10.00%
1 / 10
0.00% covered (danger)
0.00%
0 / 1
4.92
 getDB
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 webOutputHeader
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 webOutput
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 outputFeedLinks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 outputChangesList
n/a
0 / 0
n/a
0 / 0
0
 doHeader
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setTopText
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setBottomText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExtraOptions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeLegend
66.10% covered (warning)
66.10%
39 / 59
0.00% covered (danger)
0.00%
0 / 1
10.49
 addModules
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isStructuredFilterUiEnabled
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 checkStructuredFilterUiEnabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultLimit
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultDays
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getLimitPreferenceName
n/a
0 / 0
n/a
0 / 0
0
 getSavedQueriesPreferenceName
n/a
0 / 0
n/a
0 / 0
0
 getDefaultDaysPreferenceName
n/a
0 / 0
n/a
0 / 0
0
 getCollapsedPreferenceName
n/a
0 / 0
n/a
0 / 0
0
 expandSymbolicNamespaceFilters
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
6.04
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\SpecialPage;
8
9use MediaWiki\ChangeTags\ChangeTags;
10use MediaWiki\Exception\MWExceptionHandler;
11use MediaWiki\Html\FormOptions;
12use MediaWiki\Html\Html;
13use MediaWiki\Json\FormatJson;
14use MediaWiki\MainConfigNames;
15use MediaWiki\MediaWikiServices;
16use MediaWiki\Parser\Sanitizer;
17use MediaWiki\RecentChanges\ChangesListBooleanFilterGroup;
18use MediaWiki\RecentChanges\ChangesListFilterFactory;
19use MediaWiki\RecentChanges\ChangesListFilterGroup;
20use MediaWiki\RecentChanges\ChangesListFilterGroupContainer;
21use MediaWiki\RecentChanges\ChangesListQuery\ChangesListQuery;
22use MediaWiki\RecentChanges\ChangesListQuery\ChangesListQueryFactory;
23use MediaWiki\RecentChanges\ChangesListQuery\ChangesListResult;
24use MediaWiki\RecentChanges\ChangesListStringOptionsFilterGroup;
25use MediaWiki\RecentChanges\RecentChange;
26use MediaWiki\RecentChanges\RecentChangeFactory;
27use MediaWiki\ResourceLoader as RL;
28use MediaWiki\Title\MalformedTitleException;
29use MediaWiki\User\TempUser\TempUserConfig;
30use MediaWiki\User\UserArray;
31use MediaWiki\User\UserIdentity;
32use MediaWiki\User\UserIdentityUtils;
33use MediaWiki\User\UserIdentityValue;
34use OOUI\IconWidget;
35use stdClass;
36use Wikimedia\Rdbms\DBQueryTimeoutError;
37use Wikimedia\Rdbms\IReadableDatabase;
38use Wikimedia\Rdbms\IResultWrapper;
39use Wikimedia\Timestamp\ConvertibleTimestamp;
40use Wikimedia\Timestamp\TimestampFormat as TS;
41
42/**
43 * Special page which uses a ChangesList to show query results.
44 *
45 * @todo Most of the functions here should be protected instead of public.
46 *
47 * @ingroup RecentChanges
48 * @ingroup SpecialPage
49 */
50abstract class ChangesListSpecialPage extends SpecialPage {
51
52    /** @var string */
53    protected $rcSubpage;
54
55    /** @var FormOptions */
56    protected $rcOptions;
57
58    protected UserIdentityUtils $userIdentityUtils;
59    protected TempUserConfig $tempUserConfig;
60
61    protected ChangesListFilterGroupContainer $filterGroups;
62    protected RecentChangeFactory $recentChangeFactory;
63    protected ChangesListQueryFactory $changesListQueryFactory;
64
65    private ?ChangesListResult $queryResult = null;
66
67    /** @var bool */
68    private $mainQueryHookRegistered = false;
69    /** @var bool */
70    private $mainQueryHookCalled = false;
71
72    /**
73     * @param string $name
74     * @param string $restriction
75     * @param UserIdentityUtils $userIdentityUtils
76     * @param TempUserConfig $tempUserConfig
77     * @param RecentChangeFactory $recentChangeFactory
78     * @param ChangesListQueryFactory $changesListQueryFactory
79     */
80    public function __construct(
81        $name,
82        $restriction,
83        UserIdentityUtils $userIdentityUtils,
84        TempUserConfig $tempUserConfig,
85        RecentChangeFactory $recentChangeFactory,
86        ChangesListQueryFactory $changesListQueryFactory,
87    ) {
88        parent::__construct( $name, $restriction );
89
90        $this->userIdentityUtils = $userIdentityUtils;
91        $this->tempUserConfig = $tempUserConfig;
92        $this->recentChangeFactory = $recentChangeFactory;
93        $this->changesListQueryFactory = $changesListQueryFactory;
94        $this->filterGroups = new ChangesListFilterGroupContainer();
95    }
96
97    /**
98     * Definitions for the filters and their groups.
99     *
100     * This is extended by overriding getExtraFilterGroupDefinitions() in
101     * subclasses.
102     *
103     * @see ChangesListFilterFactory::registerFiltersFromDefinitions()
104     *
105     * @return array
106     */
107    private function getBaseFilterGroupDefinitions() {
108        return [
109            [
110                'name' => 'registration',
111                'title' => 'rcfilters-filtergroup-registration',
112                'class' => ChangesListBooleanFilterGroup::class,
113                'filters' => [
114                    [
115                        'name' => 'hideliu',
116                        // rcshowhideliu-show, rcshowhideliu-hide,
117                        // wlshowhideliu
118                        'showHideSuffix' => 'showhideliu',
119                        'default' => false,
120                        'action' => [ 'exclude', 'named' ],
121                        'isReplacedInStructuredUi' => true,
122                    ],
123                    [
124                        'name' => 'hideanons',
125                        // rcshowhideanons-show, rcshowhideanons-hide,
126                        // wlshowhideanons
127                        'showHideSuffix' => 'showhideanons',
128                        'default' => false,
129                        'action' => [ 'require', 'named' ],
130                        'isReplacedInStructuredUi' => true,
131                    ]
132                ],
133            ],
134
135            [
136                'name' => 'userExpLevel',
137                'title' => 'rcfilters-filtergroup-user-experience-level',
138                'class' => ChangesListStringOptionsFilterGroup::class,
139                'isFullCoverage' => true,
140                'filters' => [
141                    [
142                        'name' => 'unregistered',
143                        'requireConfig' => [ 'isRegistrationRequiredToEdit' => false ],
144                        'label' => 'rcfilters-filter-user-experience-level-unregistered-label',
145                        'description' => $this->tempUserConfig->isKnown() ?
146                            'rcfilters-filter-user-experience-level-unregistered-description-temp' :
147                            'rcfilters-filter-user-experience-level-unregistered-description',
148                        'cssClassSuffix' => 'user-unregistered',
149                        'action' => [ 'require', 'experience', 'unregistered' ],
150                    ],
151                    [
152                        'name' => 'registered',
153                        'requireConfig' => [ 'isRegistrationRequiredToEdit' => false ],
154                        'label' => 'rcfilters-filter-user-experience-level-registered-label',
155                        'description' => 'rcfilters-filter-user-experience-level-registered-description',
156                        'cssClassSuffix' => 'user-registered',
157                        'action' => [ 'require', 'experience', 'registered' ],
158                        'subsets' => [ 'newcomer', 'learner', 'experienced' ],
159                    ],
160                    [
161                        'name' => 'newcomer',
162                        'label' => 'rcfilters-filter-user-experience-level-newcomer-label',
163                        'description' => 'rcfilters-filter-user-experience-level-newcomer-description',
164                        'cssClassSuffix' => 'user-newcomer',
165                        'action' => [ 'require', 'experience', 'newcomer' ],
166                    ],
167                    [
168                        'name' => 'learner',
169                        'label' => 'rcfilters-filter-user-experience-level-learner-label',
170                        'description' => 'rcfilters-filter-user-experience-level-learner-description',
171                        'cssClassSuffix' => 'user-learner',
172                        'action' => [ 'require', 'experience', 'learner' ],
173                    ],
174                    [
175                        'name' => 'experienced',
176                        'label' => 'rcfilters-filter-user-experience-level-experienced-label',
177                        'description' => 'rcfilters-filter-user-experience-level-experienced-description',
178                        'cssClassSuffix' => 'user-experienced',
179                        'action' => [ 'require', 'experience', 'experienced' ],
180                    ]
181                ],
182                'default' => ChangesListStringOptionsFilterGroup::NONE,
183            ],
184
185            [
186                'name' => 'authorship',
187                'title' => 'rcfilters-filtergroup-authorship',
188                'class' => ChangesListBooleanFilterGroup::class,
189                'filters' => [
190                    [
191                        'name' => 'hidemyself',
192                        'label' => 'rcfilters-filter-editsbyself-label',
193                        'description' => 'rcfilters-filter-editsbyself-description',
194                        // rcshowhidemine-show, rcshowhidemine-hide,
195                        // wlshowhidemine
196                        'showHideSuffix' => 'showhidemine',
197                        'default' => false,
198                        'action' => [ 'exclude', 'user', $this->getUser() ],
199                        'highlight' => [ 'require', 'user', $this->getUser() ],
200                        'cssClassSuffix' => 'self',
201                    ],
202                    [
203                        'name' => 'hidebyothers',
204                        'label' => 'rcfilters-filter-editsbyother-label',
205                        'description' => 'rcfilters-filter-editsbyother-description',
206                        'default' => false,
207                        'action' => [ 'require', 'user', $this->getUser() ],
208                        'highlight' => [ 'exclude', 'user', $this->getUser() ],
209                        'cssClassSuffix' => 'others',
210                    ]
211                ]
212            ],
213
214            [
215                'name' => 'automated',
216                'title' => 'rcfilters-filtergroup-automated',
217                'class' => ChangesListBooleanFilterGroup::class,
218                'filters' => [
219                    [
220                        'name' => 'hidebots',
221                        'label' => 'rcfilters-filter-bots-label',
222                        'description' => 'rcfilters-filter-bots-description',
223                        // rcshowhidebots-show, rcshowhidebots-hide,
224                        // wlshowhidebots
225                        'showHideSuffix' => 'showhidebots',
226                        'default' => false,
227                        'action' => [ 'exclude', 'bot' ],
228                        'highlight' => [ 'require', 'bot' ],
229                        'cssClassSuffix' => 'bot',
230                    ],
231                    [
232                        'name' => 'hidehumans',
233                        'label' => 'rcfilters-filter-humans-label',
234                        'description' => 'rcfilters-filter-humans-description',
235                        'default' => false,
236                        'action' => [ 'require', 'bot' ],
237                        'highlight' => [ 'exclude', 'bot' ],
238                        'cssClassSuffix' => 'human',
239                    ]
240                ]
241            ],
242
243            // significance (conditional)
244
245            [
246                'name' => 'significance',
247                'title' => 'rcfilters-filtergroup-significance',
248                'class' => ChangesListBooleanFilterGroup::class,
249                'priority' => -6,
250                'filters' => [
251                    [
252                        'name' => 'hideminor',
253                        'label' => 'rcfilters-filter-minor-label',
254                        'description' => 'rcfilters-filter-minor-description',
255                        // rcshowhideminor-show, rcshowhideminor-hide,
256                        // wlshowhideminor
257                        'showHideSuffix' => 'showhideminor',
258                        'default' => false,
259                        'action' => [ 'exclude', 'minor' ],
260                        'highlight' => [ 'require', 'minor' ],
261                        'cssClassSuffix' => 'minor',
262                        'conflictOptions' => [
263                            'globalKey' => 'rcfilters-hideminor-conflicts-typeofchange-global',
264                            'forwardKey' => 'rcfilters-hideminor-conflicts-typeofchange',
265                            'backwardKey' => 'rcfilters-typeofchange-conflicts-hideminor',
266                        ],
267                        'conflictsWith' => [
268                            'changeType' => [
269                                'hidecategorization' => [],
270                                'hidelog' => [],
271                                'hidenewuserlog' => [],
272                                'hidenewpages' => []
273                            ],
274                        ],
275                    ],
276                    [
277                        'name' => 'hidemajor',
278                        'label' => 'rcfilters-filter-major-label',
279                        'description' => 'rcfilters-filter-major-description',
280                        'default' => false,
281                        'action' => [ 'require', 'minor' ],
282                        'highlight' => [ 'exclude', 'minor' ],
283                        'cssClassSuffix' => 'major',
284                    ]
285                ]
286            ],
287
288            [
289                'name' => 'lastRevision',
290                'title' => 'rcfilters-filtergroup-lastrevision',
291                'class' => ChangesListBooleanFilterGroup::class,
292                'priority' => -7,
293                'filters' => [
294                    [
295                        'name' => 'hidelastrevision',
296                        'label' => 'rcfilters-filter-lastrevision-label',
297                        'description' => 'rcfilters-filter-lastrevision-description',
298                        'default' => false,
299                        'action' => [
300                            [ 'require', 'revisionType', 'old' ],
301                            [ 'require', 'revisionType', 'none' ],
302                        ],
303                        'highlight' => [ 'require', 'revisionType', 'latest' ],
304                        'cssClassSuffix' => 'last',
305                    ],
306                    [
307                        'name' => 'hidepreviousrevisions',
308                        'label' => 'rcfilters-filter-previousrevision-label',
309                        'description' => 'rcfilters-filter-previousrevision-description',
310                        'default' => false,
311                        'action' => [
312                            [ 'require', 'revisionType', 'latest' ],
313                            [ 'require', 'revisionType', 'none' ],
314                        ],
315                        'highlight' => [ 'require', 'revisionType', 'old' ],
316                        'cssClassSuffix' => 'previous',
317                    ]
318                ]
319            ],
320
321            // With extensions, there can be change types that will not be hidden by any of these.
322            [
323                'name' => 'changeType',
324                'title' => 'rcfilters-filtergroup-changetype',
325                'class' => ChangesListBooleanFilterGroup::class,
326                'priority' => -8,
327                'filters' => [
328                    [
329                        'name' => 'hidepageedits',
330                        'label' => 'rcfilters-filter-pageedits-label',
331                        'description' => 'rcfilters-filter-pageedits-description',
332                        'default' => false,
333                        'priority' => -2,
334                        'action' => [ 'exclude', 'source', RecentChange::SRC_EDIT ],
335                        'highlight' => [ 'require', 'source', RecentChange::SRC_EDIT ],
336                        'cssClassSuffix' => 'src-mw-edit',
337                    ],
338                    [
339                        'name' => 'hidenewpages',
340                        'label' => 'rcfilters-filter-newpages-label',
341                        'description' => 'rcfilters-filter-newpages-description',
342                        'default' => false,
343                        'priority' => -3,
344                        'action' => [ 'exclude', 'source', RecentChange::SRC_NEW ],
345                        'highlight' => [ 'require', 'source', RecentChange::SRC_NEW ],
346                        'cssClassSuffix' => 'src-mw-new',
347                    ],
348                    [
349                        'name' => 'hidecategorization',
350                        'label' => 'rcfilters-filter-categorization-label',
351                        'description' => 'rcfilters-filter-categorization-description',
352                        // rcshowhidecategorization-show, rcshowhidecategorization-hide.
353                        // wlshowhidecategorization
354                        'showHideSuffix' => 'showhidecategorization',
355                        'default' => false,
356                        'priority' => -4,
357                        'requireConfig' => [ 'RCWatchCategoryMembership' => true ],
358                        'action' => [ 'exclude', 'source', RecentChange::SRC_CATEGORIZE ],
359                        'highlight' => [ 'require', 'source', RecentChange::SRC_CATEGORIZE ],
360                        'cssClassSuffix' => 'src-mw-categorize',
361                        'conflictOptions' => [
362                            'globalKey' => 'rcfilters-hidecategorization-conflicts-reviewstatus-global',
363                            'forwardKey' => 'rcfilters-hidecategorization-conflicts-reviewstatus',
364                            'backwardKey' => 'rcfilters-reviewstatus-conflicts-reviewstatus',
365                        ],
366                        'conflictsWith' => [
367                            'reviewStatus' => [
368                                'unpatrolled' => [],
369                                'manual' => [],
370                            ],
371                        ],
372                    ],
373                    [
374                        'name' => 'hidelog',
375                        'label' => 'rcfilters-filter-logactions-label',
376                        'description' => 'rcfilters-filter-logactions-description',
377                        'default' => false,
378                        'priority' => -5,
379                        'action' => [ 'exclude', 'source', RecentChange::SRC_LOG ],
380                        'highlight' => [ 'require', 'source', RecentChange::SRC_LOG ],
381                        'cssClassSuffix' => 'src-mw-log',
382                    ],
383                    [
384                        'name' => 'hidenewuserlog',
385                        'label' => 'rcfilters-filter-accountcreations-label',
386                        'description' => 'rcfilters-filter-accountcreations-description',
387                        'default' => false,
388                        'priority' => -6,
389                        'action' => [ 'exclude', 'logType', 'newusers' ],
390                        'highlight' => [ 'require', 'logType', 'newusers' ],
391                        'cssClassSuffix' => 'src-mw-newuserlog',
392                    ],
393                ],
394            ],
395
396            [
397                'name' => 'legacyReviewStatus',
398                'title' => 'rcfilters-filtergroup-reviewstatus',
399                'class' => ChangesListBooleanFilterGroup::class,
400                'requireConfig' => [ 'useRCPatrol' => true ],
401                'filters' => [
402                    [
403                        'name' => 'hidepatrolled',
404                        // rcshowhidepatr-show, rcshowhidepatr-hide
405                        // wlshowhidepatr
406                        'showHideSuffix' => 'showhidepatr',
407                        'default' => false,
408                        'action' => [ 'require', 'patrolled', RecentChange::PRC_UNPATROLLED ],
409                        'isReplacedInStructuredUi' => true,
410                    ],
411                    [
412                        'name' => 'hideunpatrolled',
413                        'default' => false,
414                        'action' => [ 'exclude', 'patrolled', RecentChange::PRC_UNPATROLLED ],
415                        'isReplacedInStructuredUi' => true,
416                    ],
417                ],
418            ],
419
420            [
421                'name' => 'reviewStatus',
422                'title' => 'rcfilters-filtergroup-reviewstatus',
423                'class' => ChangesListStringOptionsFilterGroup::class,
424                'isFullCoverage' => true,
425                'priority' => -5,
426                'requireConfig' => [ 'useRCPatrol' => true ],
427                'filters' => [
428                    [
429                        'name' => 'unpatrolled',
430                        'label' => 'rcfilters-filter-reviewstatus-unpatrolled-label',
431                        'description' => 'rcfilters-filter-reviewstatus-unpatrolled-description',
432                        'cssClassSuffix' => 'reviewstatus-unpatrolled',
433                        'action' => [ 'require', 'patrolled', RecentChange::PRC_UNPATROLLED ],
434                    ],
435                    [
436                        'name' => 'manual',
437                        'label' => 'rcfilters-filter-reviewstatus-manual-label',
438                        'description' => 'rcfilters-filter-reviewstatus-manual-description',
439                        'cssClassSuffix' => 'reviewstatus-manual',
440                        'action' => [ 'require', 'patrolled', RecentChange::PRC_PATROLLED ],
441                    ],
442                    [
443                        'name' => 'auto',
444                        'label' => 'rcfilters-filter-reviewstatus-auto-label',
445                        'description' => 'rcfilters-filter-reviewstatus-auto-description',
446                        'cssClassSuffix' => 'reviewstatus-auto',
447                        'action' => [ 'require', 'patrolled', RecentChange::PRC_AUTOPATROLLED ],
448                    ],
449                ],
450                'default' => ChangesListStringOptionsFilterGroup::NONE,
451            ],
452        ];
453    }
454
455    /**
456     * This may be overridden by subclasses to add more filter groups.
457     *
458     * @see ChangesListFilterFactory::registerFiltersFromDefinitions()
459     * @return array
460     */
461    protected function getExtraFilterGroupDefinitions(): array {
462        return [];
463    }
464
465    /**
466     * @param string|null $subpage
467     */
468    public function execute( $subpage ) {
469        $this->rcSubpage = $subpage;
470
471        if ( $this->considerActionsForDefaultSavedQuery( $subpage ) ) {
472            // Don't bother rendering the page if we'll be performing a redirect (T330100).
473            return;
474        }
475
476        // Enable OOUI and module for the clock icon.
477        if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) && !$this->including() ) {
478            $this->getOutput()->enableOOUI();
479            $this->getOutput()->addModules( 'mediawiki.special.changeslist.watchlistexpiry' );
480        }
481
482        $opts = $this->getOptions();
483        try {
484            $result = $this->getQueryResult();
485            $rows = $result->getResultWrapper();
486
487            // Used by Structured UI app to get results without MW chrome
488            if ( $this->getRequest()->getRawVal( 'action' ) === 'render' ) {
489                $this->getOutput()->setArticleBodyOnly( true );
490            }
491
492            // Used by "live update" and "view newest" to check
493            // if there's new changes with minimal data transfer
494            if ( $this->getRequest()->getBool( 'peek' ) ) {
495                $code = $rows->numRows() > 0 ? 200 : 204;
496                $this->getOutput()->setStatusCode( $code );
497
498                if ( $this->getUser()->isAnon() !==
499                    $this->getRequest()->getFuzzyBool( 'isAnon' )
500                ) {
501                    $this->getOutput()->setStatusCode( 205 );
502                }
503
504                return;
505            }
506
507            $services = MediaWikiServices::getInstance();
508            $logFormatterFactory = $services->getLogFormatterFactory();
509            $linkBatchFactory = $services->getLinkBatchFactory();
510            $batch = $linkBatchFactory->newLinkBatch();
511            $userNames = [];
512            foreach ( $rows as $row ) {
513                $batch->addUser( new UserIdentityValue( $row->rc_user ?? 0, $row->rc_user_text ) );
514                $userNames[] = $row->rc_user_text;
515                $batch->add( $row->rc_namespace, $row->rc_title );
516                if ( $row->rc_source === RecentChange::SRC_LOG ) {
517                    $formatter = $logFormatterFactory->newFromRow( $row );
518                    foreach ( $formatter->getPreloadTitles() as $title ) {
519                        $batch->addObj( $title );
520                        if ( $title->inNamespace( NS_USER ) || $title->inNamespace( NS_USER_TALK ) ) {
521                            $userNames[] = $title->getText();
522                        }
523                    }
524                }
525            }
526            $batch->execute();
527            foreach ( UserArray::newFromNames( $userNames ) as $_ ) {
528                // Trigger UserEditTracker::setCachedUserEditCount via User::loadFromRow
529                // Preloads edit count for User::getExperienceLevel() and Linker::userToolLinks()
530            }
531
532            $this->setHeaders();
533            $this->outputHeader();
534            $this->addModules();
535            $this->webOutput( $rows, $opts );
536        } catch ( DBQueryTimeoutError $timeoutException ) {
537            MWExceptionHandler::logException( $timeoutException );
538
539            $this->setHeaders();
540            $this->outputHeader();
541            $this->addModules();
542
543            $this->getOutput()->setStatusCode( 500 );
544            $this->webOutputHeader( 0, $opts );
545            $this->outputTimeout();
546        }
547
548        $this->includeRcFiltersApp();
549    }
550
551    /**
552     * Set the temp user config.
553     *
554     * @internal
555     * @param TempUserConfig $tempUserConfig
556     * @since 1.42
557     */
558    public function setTempUserConfig( TempUserConfig $tempUserConfig ) {
559        $this->tempUserConfig = $tempUserConfig;
560        $this->changesListQueryFactory->setTempUserConfig( $tempUserConfig );
561    }
562
563    /**
564     * Check whether or not the page should load defaults, and if so, whether
565     * a default saved query is relevant to be redirected to. If it is relevant,
566     * redirect properly with all necessary query parameters.
567     *
568     * @param string $subpage
569     * @return bool Whether a redirect will be performed.
570     */
571    protected function considerActionsForDefaultSavedQuery( $subpage ) {
572        if ( !$this->isStructuredFilterUiEnabled() || $this->including() ) {
573            return false;
574        }
575
576        $knownParams = $this->getRequest()->getValues(
577            ...array_keys( $this->getOptions()->getAllValues() )
578        );
579
580        // HACK: Temporarily until we can properly define "sticky" filters and parameters,
581        // we need to exclude several parameters we know should not be counted towards preventing
582        // the loading of defaults.
583        $excludedParams = [ 'limit' => '', 'days' => '', 'enhanced' => '', 'from' => '' ];
584        $knownParams = array_diff_key( $knownParams, $excludedParams );
585
586        if (
587            // If there are NO known parameters in the URL request
588            // (that are not excluded) then we need to check into loading
589            // the default saved query
590            count( $knownParams ) === 0
591        ) {
592            $prefJson = MediaWikiServices::getInstance()
593                ->getUserOptionsLookup()
594                ->getOption( $this->getUser(), $this->getSavedQueriesPreferenceName() );
595
596            // Get the saved queries data and parse it
597            $savedQueries = $prefJson ? FormatJson::decode( $prefJson, true ) : false;
598
599            if ( $savedQueries && isset( $savedQueries[ 'default' ] ) ) {
600                // Only load queries that are 'version' 2, since those
601                // have parameter representation
602                if ( isset( $savedQueries[ 'version' ] ) && $savedQueries[ 'version' ] === '2' ) {
603                    $savedQueryDefaultID = $savedQueries[ 'default' ];
604                    $defaultQuery = $savedQueries[ 'queries' ][ $savedQueryDefaultID ][ 'data' ];
605
606                    // Build the entire parameter list
607                    $query = array_merge(
608                        $defaultQuery[ 'params' ],
609                        $defaultQuery[ 'highlights' ],
610                        [
611                            'urlversion' => '2',
612                        ]
613                    );
614                    // Add to the query any parameters that we may have ignored before
615                    // but are still valid and requested in the URL
616                    $query = array_merge( $this->getRequest()->getQueryValues(), $query );
617                    unset( $query[ 'title' ] );
618                    $this->getOutput()->redirect( $this->getPageTitle( $subpage )->getCanonicalURL( $query ) );
619
620                    // Signal that we only need to redirect to the full URL
621                    // and can skip rendering the actual page (T330100).
622                    return true;
623                } else {
624                    // There's a default, but the version is not 2, and the server can't
625                    // actually recognize the query itself. This happens if it is before
626                    // the conversion, so we need to tell the UI to reload saved query as
627                    // it does the conversion to version 2
628                    $this->getOutput()->addJsConfigVars(
629                        'wgStructuredChangeFiltersDefaultSavedQueryExists',
630                        true
631                    );
632
633                    // Add the class that tells the frontend it is still loading
634                    // another query
635                    $this->getOutput()->addBodyClasses( 'mw-rcfilters-ui-loading' );
636                }
637            }
638        }
639
640        return false;
641    }
642
643    /**
644     * @see \MediaWiki\MainConfigSchema::RCLinkDays and \MediaWiki\MainConfigSchema::RCFilterByAge.
645     * @return int[]
646     */
647    protected function getLinkDays() {
648        $linkDays = $this->getConfig()->get( MainConfigNames::RCLinkDays );
649        $filterByAge = $this->getConfig()->get( MainConfigNames::RCFilterByAge );
650        $maxAge = $this->getConfig()->get( MainConfigNames::RCMaxAge );
651        if ( $filterByAge ) {
652            // Trim it to only links which are within $wgRCMaxAge.
653            // Note that we allow one link higher than the max for things like
654            // "age 56 days" being accessible through the "60 days" link.
655            sort( $linkDays );
656
657            $maxAgeDays = $maxAge / ( 3600 * 24 );
658            foreach ( $linkDays as $i => $days ) {
659                if ( $days >= $maxAgeDays ) {
660                    array_splice( $linkDays, $i + 1 );
661                    break;
662                }
663            }
664        }
665
666        return $linkDays;
667    }
668
669    /**
670     * Include the modules and configuration for the RCFilters app.
671     * Conditional on the user having the feature enabled.
672     *
673     * If it is disabled, add a <body> class marking that
674     */
675    protected function includeRcFiltersApp() {
676        $out = $this->getOutput();
677        if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
678            $jsData = $this->filterGroups->getJsData();
679            $messages = [];
680            foreach ( $jsData['messageKeys'] as $key ) {
681                $messages[$key] = $this->msg( $key )->plain();
682            }
683
684            $out->addBodyClasses( 'mw-rcfilters-enabled' );
685            $collapsed = MediaWikiServices::getInstance()->getUserOptionsLookup()
686                ->getBoolOption( $this->getUser(), $this->getCollapsedPreferenceName() );
687            if ( $collapsed ) {
688                $out->addBodyClasses( 'mw-rcfilters-collapsed' );
689            }
690
691            // These config and message exports should be moved into a ResourceLoader data module (T201574)
692            $out->addJsConfigVars( 'wgStructuredChangeFilters', $jsData['groups'] );
693            $out->addJsConfigVars( 'wgStructuredChangeFiltersMessages', $messages );
694            $out->addJsConfigVars( 'wgStructuredChangeFiltersCollapsedState', $collapsed );
695
696            $out->addJsConfigVars(
697                'StructuredChangeFiltersDisplayConfig',
698                [
699                    'maxDays' => // Translate to days
700                        (int)$this->getConfig()->get( MainConfigNames::RCMaxAge ) / ( 24 * 3600 ),
701                    'limitArray' => $this->getConfig()->get( MainConfigNames::RCLinkLimits ),
702                    'limitDefault' => $this->getDefaultLimit(),
703                    'daysArray' => $this->getLinkDays(),
704                    'daysDefault' => $this->getDefaultDays(),
705                ]
706            );
707
708            $out->addJsConfigVars(
709                'wgStructuredChangeFiltersSavedQueriesPreferenceName',
710                $this->getSavedQueriesPreferenceName()
711            );
712            $out->addJsConfigVars(
713                'wgStructuredChangeFiltersLimitPreferenceName',
714                $this->getLimitPreferenceName()
715            );
716            $out->addJsConfigVars(
717                'wgStructuredChangeFiltersDaysPreferenceName',
718                $this->getDefaultDaysPreferenceName()
719            );
720            $out->addJsConfigVars(
721                'wgStructuredChangeFiltersCollapsedPreferenceName',
722                $this->getCollapsedPreferenceName()
723            );
724        } else {
725            $out->addBodyClasses( 'mw-rcfilters-disabled' );
726        }
727    }
728
729    /**
730     * Get essential data about getRcFiltersConfigVars() for change detection.
731     *
732     * @internal For use by Resources.php only.
733     * @see Module::getDefinitionSummary() and Module::getVersionHash()
734     * @param RL\Context $context
735     * @return array
736     */
737    public static function getRcFiltersConfigSummary( RL\Context $context ) {
738        $lang = MediaWikiServices::getInstance()->getLanguageFactory()
739            ->getLanguage( $context->getLanguage() );
740        return [
741            // Reduce version computation by avoiding Message parsing
742            'RCFiltersChangeTags' => ChangeTags::getChangeTagListSummary( $context, $lang ),
743            'StructuredChangeFiltersEditWatchlistUrl' =>
744                SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
745        ];
746    }
747
748    /**
749     * Get config vars to export with the mediawiki.rcfilters.filters.ui module.
750     *
751     * @internal For use by Resources.php only.
752     * @param RL\Context $context
753     * @return array
754     */
755    public static function getRcFiltersConfigVars( RL\Context $context ) {
756        $lang = MediaWikiServices::getInstance()->getLanguageFactory()
757            ->getLanguage( $context->getLanguage() );
758        return [
759            'RCFiltersChangeTags' => ChangeTags::getChangeTagList( $context, $lang ),
760            'StructuredChangeFiltersEditWatchlistUrl' =>
761                SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
762        ];
763    }
764
765    /**
766     * Add the "no results" message to the output
767     */
768    protected function outputNoResults() {
769        $this->getOutput()->addHTML(
770            Html::rawElement(
771                'div',
772                [ 'class' => 'mw-changeslist-empty' ],
773                $this->msg( 'recentchanges-noresult' )->parse()
774            )
775        );
776    }
777
778    /**
779     * Add the "timeout" message to the output
780     */
781    protected function outputTimeout() {
782        $this->getOutput()->addHTML(
783            '<div class="mw-changeslist-empty mw-changeslist-timeout">' .
784            $this->msg( 'recentchanges-timeout' )->parse() .
785            '</div>'
786        );
787    }
788
789    /**
790     * Get the database result for this special page instance. Used by ApiFeedRecentChanges.
791     *
792     * @return IResultWrapper
793     */
794    public function getRows() {
795        return $this->getQueryResult()->getResultWrapper();
796    }
797
798    /**
799     * Perform and cache the main query.
800     *
801     * @return ChangesListResult
802     */
803    protected function getQueryResult(): ChangesListResult {
804        if ( !$this->queryResult ) {
805            $opts = $this->getOptions();
806            $query = $this->buildQuery( $opts );
807            $this->modifyQuery( $query, $opts );
808            $this->queryResult = $query->fetchResult();
809
810            if ( $this->mainQueryHookRegistered && !$this->mainQueryHookCalled ) {
811                // When an empty result set is forced, ChangesListQuery doesn't run
812                // the hook, but some extensions need us to run it anyway to register
813                // form options.
814                // FIXME: risky to pass empty arrays here, and inefficient to
815                //  call this hook when most of what it does is not needed.
816                //  We need to deprecate it.
817                $tables = $fields = $conds = $options = $joins = [];
818                $this->runMainQueryHook( $tables, $fields, $conds, $options,
819                    $joins, $opts );
820            }
821        }
822        return $this->queryResult;
823    }
824
825    /**
826     * Create a RecentChange object from a row, injecting highlights from the
827     * current ChangesListQuery.
828     *
829     * @param stdClass $row
830     * @return RecentChange
831     */
832    protected function newRecentChangeFromRow( $row ) {
833        $rc = $this->recentChangeFactory->newRecentChangeFromRow( $row );
834        $rc->setHighlights( $this->getQueryResult()->getHighlightsFromRow( $row ) );
835        return $rc;
836    }
837
838    /**
839     * Get the current FormOptions for this request
840     *
841     * @return FormOptions
842     */
843    public function getOptions() {
844        if ( $this->rcOptions === null ) {
845            $this->rcOptions = $this->setup( $this->rcSubpage );
846        }
847
848        return $this->rcOptions;
849    }
850
851    /**
852     * Get configuration to be passed to the filter factory. The values here
853     * are matched against the "requireConfig" values in the filter group
854     * definitions.
855     *
856     * @return array
857     */
858    private function getBaseFilterFactoryConfig() {
859        return [
860            'showHidePrefix' => '',
861            'isRegistrationRequiredToEdit' => !MediaWikiServices::getInstance()
862                ->getPermissionManager()
863                ->isEveryoneAllowed( "edit" ),
864            'useRCPatrol' => !$this->including() && $this->getUser()->useRCPatrol(),
865            'RCWatchCategoryMembership' =>
866                $this->getConfig()->get( MainConfigNames::RCWatchCategoryMembership ),
867        ];
868    }
869
870    /**
871     * Subclasses may override this to add configuration to the filter factory.
872     *
873     * @return array
874     */
875    protected function getExtraFilterFactoryConfig(): array {
876        return [];
877    }
878
879    /**
880     * Subclasses may override this to provide an array of filter group defaults,
881     * overriding the defaults in the filter definitions.
882     *
883     * @return array<string,string|array<string,bool>>
884     */
885    protected function getFilterDefaultOverrides(): array {
886        return [];
887    }
888
889    protected function getFilterFactory(): ChangesListFilterFactory {
890