Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.12% covered (warning)
86.12%
912 / 1059
62.26% covered (warning)
62.26%
33 / 53
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangesListSpecialPage
86.20% covered (warning)
86.20%
912 / 1058
62.26% covered (warning)
62.26%
33 / 53
269.05
0.00% covered (danger)
0.00%
0 / 1
 __construct
96.40% covered (success)
96.40%
428 / 444
0.00% covered (danger)
0.00%
0 / 1
7
 removeRegistrationFilterDefinitions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 areFiltersInConflict
62.50% covered (warning)
62.50%
10 / 16
0.00% covered (danger)
0.00%
0 / 1
13.27
 execute
52.94% covered (warning)
52.94%
27 / 51
0.00% covered (danger)
0.00%
0 / 1
42.68
 setTempUserConfig
100.00% covered (success)
100.00%
1 / 1
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
100.00% covered (success)
100.00%
8 / 8
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
 registerFilters
100.00% covered (success)
100.00%
57 / 57
100.00% covered (success)
100.00%
1 / 1
7
 transformFilterDefinition
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 registerFiltersFromDefinitions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 getLegacyShowHideFilters
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 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
2
 registerFilterGroup
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFilterGroups
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 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%
18 / 18
100.00% covered (success)
100.00%
1 / 1
10
 validateOptions
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 fixContradictoryOptions
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
7.01
 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
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
8
 doMainQuery
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
6
 runMainQueryHook
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 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
67.80% covered (warning)
67.80%
40 / 59
0.00% covered (danger)
0.00%
0 / 1
10.14
 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
 getRegisteredExpr
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getExperienceExpr
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 filterOnUserExperienceLevel
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
11
 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
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
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\SpecialPage;
22
23use ChangesListBooleanFilter;
24use ChangesListBooleanFilterGroup;
25use ChangesListFilterGroup;
26use ChangesListStringOptionsFilterGroup;
27use ChangeTags;
28use MediaWiki\Context\IContextSource;
29use MediaWiki\Html\FormOptions;
30use MediaWiki\Html\Html;
31use MediaWiki\Json\FormatJson;
32use MediaWiki\MainConfigNames;
33use MediaWiki\MediaWikiServices;
34use MediaWiki\Parser\Sanitizer;
35use MediaWiki\ResourceLoader as RL;
36use MediaWiki\User\TempUser\TempUserConfig;
37use MediaWiki\User\UserArray;
38use MediaWiki\User\UserIdentity;
39use MediaWiki\User\UserIdentityUtils;
40use MWExceptionHandler;
41use OOUI\IconWidget;
42use RecentChange;
43use Wikimedia\Rdbms\DBQueryTimeoutError;
44use Wikimedia\Rdbms\FakeResultWrapper;
45use Wikimedia\Rdbms\IExpression;
46use Wikimedia\Rdbms\IReadableDatabase;
47use Wikimedia\Rdbms\IResultWrapper;
48use Wikimedia\Rdbms\RawSQLValue;
49use Wikimedia\Timestamp\ConvertibleTimestamp;
50
51/**
52 * Special page which uses a ChangesList to show query results.
53 *
54 * @todo Most of the functions here should be protected instead of public.
55 *
56 * @ingroup RecentChanges
57 * @ingroup SpecialPage
58 */
59abstract class ChangesListSpecialPage extends SpecialPage {
60
61    /** @var string */
62    protected $rcSubpage;
63
64    /** @var FormOptions */
65    protected $rcOptions;
66
67    protected UserIdentityUtils $userIdentityUtils;
68    protected TempUserConfig $tempUserConfig;
69
70    // Order of both groups and filters is significant; first is top-most priority,
71    // descending from there.
72    // 'showHideSuffix' is a shortcut to and avoid spelling out
73    // details specific to subclasses here.
74    /**
75     * Definition information for the filters and their groups
76     *
77     * The value is $groupDefinition, a parameter to the ChangesListFilterGroup constructor.
78     * However, priority is dynamically added for the core groups, to ease maintenance.
79     *
80     * Groups are displayed to the user in the structured UI.  However, if necessary,
81     * all of the filters in a group can be configured to only display on the
82     * unstuctured UI, in which case you don't need a group title.
83     *
84     * @var array
85     */
86    private $filterGroupDefinitions;
87
88    /**
89     * @var array Same format as filterGroupDefinitions, but for a single group (reviewStatus)
90     * that is registered conditionally.
91     */
92    private $legacyReviewStatusFilterGroupDefinition;
93
94    /** @var array Single filter group registered conditionally */
95    private $reviewStatusFilterGroupDefinition;
96
97    /** @var array Single filter group registered conditionally */
98    private $hideCategorizationFilterDefinition;
99
100    /**
101     * Filter groups, and their contained filters
102     * This is an associative array (with group name as key) of ChangesListFilterGroup objects.
103     *
104     * @var ChangesListFilterGroup[]
105     */
106    protected $filterGroups = [];
107
108    /**
109     * @param string $name
110     * @param string $restriction
111     * @param UserIdentityUtils $userIdentityUtils
112     * @param TempUserConfig $tempUserConfig
113     */
114    public function __construct(
115        $name,
116        $restriction,
117        UserIdentityUtils $userIdentityUtils,
118        TempUserConfig $tempUserConfig
119    ) {
120        parent::__construct( $name, $restriction );
121
122        $this->userIdentityUtils = $userIdentityUtils;
123        $this->tempUserConfig = $tempUserConfig;
124
125        $nonRevisionTypes = [ RC_LOG ];
126        $this->getHookRunner()->onSpecialWatchlistGetNonRevisionTypes( $nonRevisionTypes );
127
128        $this->filterGroupDefinitions = [
129            [
130                'name' => 'registration',
131                'title' => 'rcfilters-filtergroup-registration',
132                'class' => ChangesListBooleanFilterGroup::class,
133                'filters' => [
134                    [
135                        'name' => 'hideliu',
136                        // rcshowhideliu-show, rcshowhideliu-hide,
137                        // wlshowhideliu
138                        'showHideSuffix' => 'showhideliu',
139                        'default' => false,
140                        'queryCallable' => function ( string $specialClassName, IContextSource $ctx,
141                            IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
142                        ) {
143                            $conds[] = $this->getRegisteredExpr( false, $dbr );
144                            $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
145                        },
146                        'isReplacedInStructuredUi' => true,
147
148                    ],
149                    [
150                        'name' => 'hideanons',
151                        // rcshowhideanons-show, rcshowhideanons-hide,
152                        // wlshowhideanons
153                        'showHideSuffix' => 'showhideanons',
154                        'default' => false,
155                        'queryCallable' => function ( string $specialClassName, IContextSource $ctx,
156                            IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
157                        ) {
158                            $conds[] = $this->getRegisteredExpr( true, $dbr );
159                            $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
160                        },
161                        'isReplacedInStructuredUi' => true,
162                    ]
163                ],
164            ],
165
166            [
167                'name' => 'userExpLevel',
168                'title' => 'rcfilters-filtergroup-user-experience-level',
169                'class' => ChangesListStringOptionsFilterGroup::class,
170                'isFullCoverage' => true,
171                'filters' => [
172                    [
173                        'name' => 'unregistered',
174                        'label' => 'rcfilters-filter-user-experience-level-unregistered-label',
175                        'description' => $this->tempUserConfig->isKnown() ?
176                            'rcfilters-filter-user-experience-level-unregistered-description-temp' :
177                            'rcfilters-filter-user-experience-level-unregistered-description',
178                        'cssClassSuffix' => 'user-unregistered',
179                        'isRowApplicableCallable' => function ( IContextSource $ctx, RecentChange $rc ) {
180                            return !$this->userIdentityUtils->isNamed( $rc->getPerformerIdentity() );
181                        }
182                    ],
183                    [
184                        'name' => 'registered',
185                        'label' => 'rcfilters-filter-user-experience-level-registered-label',
186                        'description' => 'rcfilters-filter-user-experience-level-registered-description',
187                        'cssClassSuffix' => 'user-registered',
188                        'isRowApplicableCallable' => function ( IContextSource $ctx, RecentChange $rc ) {
189                            return $this->userIdentityUtils->isNamed( $rc->getPerformerIdentity() );
190                        }
191                    ],
192                    [
193                        'name' => 'newcomer',
194                        'label' => 'rcfilters-filter-user-experience-level-newcomer-label',
195                        'description' => 'rcfilters-filter-user-experience-level-newcomer-description',
196                        'cssClassSuffix' => 'user-newcomer',
197                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
198                            $performer = $rc->getPerformerIdentity();
199                            return $performer->isRegistered() &&
200                                MediaWikiServices::getInstance()
201                                    ->getUserFactory()
202                                    ->newFromUserIdentity( $performer )
203                                    ->getExperienceLevel() === 'newcomer';
204                        }
205                    ],
206                    [
207                        'name' => 'learner',
208                        'label' => 'rcfilters-filter-user-experience-level-learner-label',
209                        'description' => 'rcfilters-filter-user-experience-level-learner-description',
210                        'cssClassSuffix' => 'user-learner',
211                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
212                            $performer = $rc->getPerformerIdentity();
213                            return $performer->isRegistered() &&
214                                MediaWikiServices::getInstance()
215                                    ->getUserFactory()
216                                    ->newFromUserIdentity( $performer )
217                                    ->getExperienceLevel() === 'learner';
218                        },
219                    ],
220                    [
221                        'name' => 'experienced',
222                        'label' => 'rcfilters-filter-user-experience-level-experienced-label',
223                        'description' => 'rcfilters-filter-user-experience-level-experienced-description',
224                        'cssClassSuffix' => 'user-experienced',
225                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
226                            $performer = $rc->getPerformerIdentity();
227                            return $performer->isRegistered() &&
228                                MediaWikiServices::getInstance()
229                                    ->getUserFactory()
230                                    ->newFromUserIdentity( $performer )
231                                    ->getExperienceLevel() === 'experienced';
232                        },
233                    ]
234                ],
235                'default' => ChangesListStringOptionsFilterGroup::NONE,
236                'queryCallable' => [ $this, 'filterOnUserExperienceLevel' ],
237            ],
238
239            [
240                'name' => 'authorship',
241                'title' => 'rcfilters-filtergroup-authorship',
242                'class' => ChangesListBooleanFilterGroup::class,
243                'filters' => [
244                    [
245                        'name' => 'hidemyself',
246                        'label' => 'rcfilters-filter-editsbyself-label',
247                        'description' => 'rcfilters-filter-editsbyself-description',
248                        // rcshowhidemine-show, rcshowhidemine-hide,
249                        // wlshowhidemine
250                        'showHideSuffix' => 'showhidemine',
251                        'default' => false,
252                        'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
253                            IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
254                        ) {
255                            $user = $ctx->getUser();
256                            $conds[] = $dbr->expr( 'actor_name', '!=', $user->getName() );
257                            $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
258                        },
259                        'cssClassSuffix' => 'self',
260                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
261                            return $ctx->getUser()->equals( $rc->getPerformerIdentity() );
262                        },
263                    ],
264                    [
265                        'name' => 'hidebyothers',
266                        'label' => 'rcfilters-filter-editsbyother-label',
267                        'description' => 'rcfilters-filter-editsbyother-description',
268                        'default' => false,
269                        'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
270                            IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
271                        ) {
272                            $user = $ctx->getUser();
273                            if ( $user->isAnon() ) {
274                                $conds['actor_name'] = $user->getName();
275                            } else {
276                                $conds['actor_user'] = $user->getId();
277                            }
278                            $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
279                        },
280                        'cssClassSuffix' => 'others',
281                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
282                            return !$ctx->getUser()->equals( $rc->getPerformerIdentity() );
283                        },
284                    ]
285                ]
286            ],
287
288            [
289                'name' => 'automated',
290                'title' => 'rcfilters-filtergroup-automated',
291                'class' => ChangesListBooleanFilterGroup::class,
292                'filters' => [
293                    [
294                        'name' => 'hidebots',
295                        'label' => 'rcfilters-filter-bots-label',
296                        'description' => 'rcfilters-filter-bots-description',
297                        // rcshowhidebots-show, rcshowhidebots-hide,
298                        // wlshowhidebots
299                        'showHideSuffix' => 'showhidebots',
300                        'default' => false,
301                        'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
302                            IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
303                        ) {
304                            $conds['rc_bot'] = 0;
305                        },
306                        'cssClassSuffix' => 'bot',
307                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
308                            return $rc->getAttribute( 'rc_bot' );
309                        },
310                    ],
311                    [
312                        'name' => 'hidehumans',
313                        'label' => 'rcfilters-filter-humans-label',
314                        'description' => 'rcfilters-filter-humans-description',
315                        'default' => false,
316                        'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
317                            IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
318                        ) {
319                            $conds['rc_bot'] = 1;
320                        },
321                        'cssClassSuffix' => 'human',
322                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
323                            return !$rc->getAttribute( 'rc_bot' );
324                        },
325                    ]
326                ]
327            ],
328
329            // significance (conditional)
330
331            [
332                'name' => 'significance',
333                'title' => 'rcfilters-filtergroup-significance',
334                'class' => ChangesListBooleanFilterGroup::class,
335                'priority' => -6,
336                'filters' => [
337                    [
338                        'name' => 'hideminor',
339                        'label' => 'rcfilters-filter-minor-label',
340                        'description' => 'rcfilters-filter-minor-description',
341                        // rcshowhideminor-show, rcshowhideminor-hide,
342                        // wlshowhideminor
343                        'showHideSuffix' => 'showhideminor',
344                        'default' => false,
345                        'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
346                            IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
347                        ) {
348                            $conds[] = $dbr->expr( 'rc_minor', '=', 0 );
349                        },
350                        'cssClassSuffix' => 'minor',
351                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
352                            return $rc->getAttribute( 'rc_minor' );
353                        }
354                    ],
355                    [
356                        'name' => 'hidemajor',
357                        'label' => 'rcfilters-filter-major-label',
358                        'description' => 'rcfilters-filter-major-description',
359                        'default' => false,
360                        'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
361                            IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
362                        ) {
363                            $conds[] = $dbr->expr( 'rc_minor', '=', 1 );
364                        },
365                        'cssClassSuffix' => 'major',
366                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
367                            return !$rc->getAttribute( 'rc_minor' );
368                        }
369                    ]
370                ]
371            ],
372
373            [
374                'name' => 'lastRevision',
375                'title' => 'rcfilters-filtergroup-lastrevision',
376                'class' => ChangesListBooleanFilterGroup::class,
377                'priority' => -7,
378                'filters' => [
379                    [
380                        'name' => 'hidelastrevision',
381                        'label' => 'rcfilters-filter-lastrevision-label',
382                        'description' => 'rcfilters-filter-lastrevision-description',
383                        'default' => false,
384                        'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
385                            IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
386                        ) use ( $nonRevisionTypes ) {
387                            $conds[] = $dbr->expr( 'rc_this_oldid', '!=', new RawSQLValue( 'page_latest' ) )
388                                ->or( 'rc_type', '=', $nonRevisionTypes );
389                        },
390                        'cssClassSuffix' => 'last',
391                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
392                            return $rc->getAttribute( 'rc_this_oldid' ) === $rc->getAttribute( 'page_latest' );
393                        }
394                    ],
395                    [
396                        'name' => 'hidepreviousrevisions',
397                        'label' => 'rcfilters-filter-previousrevision-label',
398                        'description' => 'rcfilters-filter-previousrevision-description',
399                        'default' => false,
400                        'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
401                            IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
402                        ) use ( $nonRevisionTypes ) {
403                            $conds[] = $dbr->expr( 'rc_this_oldid', '=', new RawSQLValue( 'page_latest' ) )
404                                ->or( 'rc_type', '=', $nonRevisionTypes );
405                        },
406                        'cssClassSuffix' => 'previous',
407                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
408                            return $rc->getAttribute( 'rc_this_oldid' ) !== $rc->getAttribute( 'page_latest' );
409                        }
410                    ]
411                ]
412            ],
413
414            // With extensions, there can be change types that will not be hidden by any of these.
415            [
416                'name' => 'changeType',
417                'title' => 'rcfilters-filtergroup-changetype',
418                'class' => ChangesListBooleanFilterGroup::class,
419                'priority' => -8,
420                'filters' => [
421                    [
422                        'name' => 'hidepageedits',
423                        'label' => 'rcfilters-filter-pageedits-label',
424                        'description' => 'rcfilters-filter-pageedits-description',
425                        'default' => false,
426                        'priority' => -2,
427                        'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
428                            IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
429                        ) {
430                            $conds[] = $dbr->expr( 'rc_type', '!=', RC_EDIT );
431                        },
432                        'cssClassSuffix' => 'src-mw-edit',
433                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
434                            return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_EDIT;
435                        },
436                    ],
437                    [
438                        'name' => 'hidenewpages',
439                        'label' => 'rcfilters-filter-newpages-label',
440                        'description' => 'rcfilters-filter-newpages-description',
441                        'default' => false,
442                        'priority' => -3,
443                        'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
444                            IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
445                        ) {
446                            $conds[] = $dbr->expr( 'rc_type', '!=', RC_NEW );
447                        },
448                        'cssClassSuffix' => 'src-mw-new',
449                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
450                            return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_NEW;
451                        },
452                    ],
453
454                    // hidecategorization
455
456                    [
457                        'name' => 'hidelog',
458                        'label' => 'rcfilters-filter-logactions-label',
459                        'description' => 'rcfilters-filter-logactions-description',
460                        'default' => false,
461                        'priority' => -5,
462                        'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
463                            IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
464                        ) {
465                            $conds[] = $dbr->expr( 'rc_type', '!=', RC_LOG );
466                        },
467                        'cssClassSuffix' => 'src-mw-log',
468                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
469                            return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_LOG;
470                        }
471                    ],
472                    [
473                        'name' => 'hidenewuserlog',
474                        'label' => 'rcfilters-filter-accountcreations-label',
475                        'description' => 'rcfilters-filter-accountcreations-description',
476                        'default' => false,
477                        'priority' => -6,
478                        'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
479                            IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
480                        ) {
481                            $conds[] = $dbr->expr( 'rc_log_type', '!=', 'newusers' )
482                                ->or( 'rc_log_type', '=', null );
483                        },
484                        'cssClassSuffix' => 'src-mw-newuserlog',
485                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
486                            return $rc->getAttribute( 'rc_log_type' ) === "newusers";
487                        },
488                    ],
489                ],
490            ],
491
492        ];
493
494        $this->legacyReviewStatusFilterGroupDefinition = [
495            [
496                'name' => 'legacyReviewStatus',
497                'title' => 'rcfilters-filtergroup-reviewstatus',
498                'class' => ChangesListBooleanFilterGroup::class,
499                'filters' => [
500                    [
501                        'name' => 'hidepatrolled',
502                        // rcshowhidepatr-show, rcshowhidepatr-hide
503                        // wlshowhidepatr
504                        'showHideSuffix' => 'showhidepatr',
505                        'default' => false,
506                        'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
507                            IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
508                        ) {
509                            $conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED;
510                        },
511                        'isReplacedInStructuredUi' => true,
512                    ],
513                    [
514                        'name' => 'hideunpatrolled',
515                        'default' => false,
516                        'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
517                            IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
518                        ) {
519                            $conds[] = $dbr->expr( 'rc_patrolled', '!=', RecentChange::PRC_UNPATROLLED );
520                        },
521                        'isReplacedInStructuredUi' => true,
522                    ],
523                ],
524            ]
525        ];
526
527        $this->reviewStatusFilterGroupDefinition = [
528            [
529                'name' => 'reviewStatus',
530                'title' => 'rcfilters-filtergroup-reviewstatus',
531                'class' => ChangesListStringOptionsFilterGroup::class,
532                'isFullCoverage' => true,
533                'priority' => -5,
534                'filters' => [
535                    [
536                        'name' => 'unpatrolled',
537                        'label' => 'rcfilters-filter-reviewstatus-unpatrolled-label',
538                        'description' => 'rcfilters-filter-reviewstatus-unpatrolled-description',
539                        'cssClassSuffix' => 'reviewstatus-unpatrolled',
540                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
541                            return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED;
542                        },
543                    ],
544                    [
545                        'name' => 'manual',
546                        'label' => 'rcfilters-filter-reviewstatus-manual-label',
547                        'description' => 'rcfilters-filter-reviewstatus-manual-description',
548                        'cssClassSuffix' => 'reviewstatus-manual',
549                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
550                            return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_PATROLLED;
551                        },
552                    ],
553                    [
554                        'name' => 'auto',
555                        'label' => 'rcfilters-filter-reviewstatus-auto-label',
556                        'description' => 'rcfilters-filter-reviewstatus-auto-description',
557                        'cssClassSuffix' => 'reviewstatus-auto',
558                        'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
559                            return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_AUTOPATROLLED;
560                        },
561                    ],
562                ],
563                'default' => ChangesListStringOptionsFilterGroup::NONE,
564                'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
565                    IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selected
566                ) {
567                    if ( $selected === [] ) {
568                        return;
569                    }
570                    $rcPatrolledValues = [
571                        'unpatrolled' => RecentChange::PRC_UNPATROLLED,
572                        'manual' => RecentChange::PRC_PATROLLED,
573                        'auto' => RecentChange::PRC_AUTOPATROLLED,
574                    ];
575                    // e.g. rc_patrolled IN (0, 2)
576                    $conds['rc_patrolled'] = array_map( static function ( $s ) use ( $rcPatrolledValues ) {
577                        return $rcPatrolledValues[ $s ];
578                    }, $selected );
579                }
580            ]
581        ];
582
583        $this->hideCategorizationFilterDefinition = [
584            'name' => 'hidecategorization',
585            'label' => 'rcfilters-filter-categorization-label',
586            'description' => 'rcfilters-filter-categorization-description',
587            // rcshowhidecategorization-show, rcshowhidecategorization-hide.
588            // wlshowhidecategorization
589            'showHideSuffix' => 'showhidecategorization',
590            'default' => false,
591            'priority' => -4,
592            'queryCallable' => static function ( string $specialClassName, IContextSource $ctx,
593                IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
594            ) {
595                $conds[] = $dbr->expr( 'rc_type', '!=', RC_CATEGORIZE );
596            },
597            'cssClassSuffix' => 'src-mw-categorize',
598            'isRowApplicableCallable' => static function ( IContextSource $ctx, RecentChange $rc ) {
599                return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_CATEGORIZE;
600            },
601        ];
602    }
603
604    /**
605     * Removes registration filters from filterGroupDefinitions
606     * @return void
607     */
608    private function removeRegistrationFilterDefinitions(): void {
609        foreach ( $this->filterGroupDefinitions as $key => $value ) {
610            if ( $value['name'] == "userExpLevel" ) {
611                $this->filterGroupDefinitions[ $key ][ 'filters' ] = array_filter(
612                    $this->filterGroupDefinitions[ $key ][ 'filters' ],
613                    fn ( $val, $key ) => $val[ 'name' ] != 'registered'
614                        && $val[ 'name' ] != 'unregistered', ARRAY_FILTER_USE_BOTH );
615            }
616        }
617    }
618
619    /**
620     * Check if filters are in conflict and guaranteed to return no results.
621     *
622     * @return bool
623     */
624    protected function areFiltersInConflict() {
625        $opts = $this->getOptions();
626        foreach ( $this->getFilterGroups() as $group ) {
627            if ( $group->getConflictingGroups() ) {
628                wfLogWarning(
629                    $group->getName() .
630                    " specifies conflicts with other groups but these are not supported yet."
631                );
632            }
633
634            foreach ( $group->getConflictingFilters() as $conflictingFilter ) {
635                if ( $conflictingFilter->activelyInConflictWithGroup( $group, $opts ) ) {
636                    return true;
637                }
638            }
639
640            foreach ( $group->getFilters() as $filter ) {
641                foreach ( $filter->getConflictingFilters() as $conflictingFilter ) {
642                    if (
643                        $conflictingFilter->activelyInConflictWithFilter( $filter, $opts ) &&
644                        $filter->activelyInConflictWithFilter( $conflictingFilter, $opts )
645                    ) {
646                        return true;
647                    }
648                }
649
650            }
651
652        }
653
654        return false;
655    }
656
657    /**
658     * @param string|null $subpage
659     */
660    public function execute( $subpage ) {
661        $this->rcSubpage = $subpage;
662
663        if ( $this->considerActionsForDefaultSavedQuery( $subpage ) ) {
664            // Don't bother rendering the page if we'll be performing a redirect (T330100).
665            return;
666        }
667
668        // Enable OOUI and module for the clock icon.
669        if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) && !$this->including() ) {
670            $this->getOutput()->enableOOUI();
671            $this->getOutput()->addModules( 'mediawiki.special.changeslist.watchlistexpiry' );
672        }
673
674        $opts = $this->getOptions();
675        try {
676            $rows = $this->getRows();
677            if ( $rows === false ) {
678                $rows = new FakeResultWrapper( [] );
679            }
680
681            // Used by Structured UI app to get results without MW chrome
682            if ( $this->getRequest()->getRawVal( 'action' ) === 'render' ) {
683                $this->getOutput()->setArticleBodyOnly( true );
684            }
685
686            // Used by "live update" and "view newest" to check
687            // if there's new changes with minimal data transfer
688            if ( $this->getRequest()->getBool( 'peek' ) ) {
689                $code = $rows->numRows() > 0 ? 200 : 204;
690                $this->getOutput()->setStatusCode( $code );
691
692                if ( $this->getUser()->isAnon() !==
693                    $this->getRequest()->getFuzzyBool( 'isAnon' )
694                ) {
695                    $this->getOutput()->setStatusCode( 205 );
696                }
697
698                return;
699            }
700
701            $services = MediaWikiServices::getInstance();
702            $logFormatterFactory = $services->getLogFormatterFactory();
703            $linkBatchFactory = $services->getLinkBatchFactory();
704            $batch = $linkBatchFactory->newLinkBatch();
705            $userNames = [];
706            foreach ( $rows as $row ) {
707                $batch->add( NS_USER, $row->rc_user_text );
708                $batch->add( NS_USER_TALK, $row->rc_user_text );
709                $userNames[] = $row->rc_user_text;
710                $batch->add( $row->rc_namespace, $row->rc_title );
711                if ( $row->rc_source === RecentChange::SRC_LOG ) {
712                    $formatter = $logFormatterFactory->newFromRow( $row );
713                    foreach ( $formatter->getPreloadTitles() as $title ) {
714                        $batch->addObj( $title );
715                        if ( $title->inNamespace( NS_USER ) || $title->inNamespace( NS_USER_TALK ) ) {
716                            $userNames[] = $title->getText();
717                        }
718                    }
719                }
720            }
721            $batch->execute();
722            foreach ( UserArray::newFromNames( $userNames ) as $_ ) {
723                // Trigger UserEditTracker::setCachedUserEditCount via User::loadFromRow
724                // Preloads edit count for User::getExperienceLevel() and Linker::userToolLinks()
725            }
726
727            $this->setHeaders();
728            $this->outputHeader();
729            $this->addModules();
730            $this->webOutput( $rows, $opts );
731
732            $rows->free();
733        } catch ( DBQueryTimeoutError $timeoutException ) {
734            MWExceptionHandler::logException( $timeoutException );
735
736            $this->setHeaders();
737            $this->outputHeader();
738            $this->addModules();
739
740            $this->getOutput()->setStatusCode( 500 );
741            $this->webOutputHeader( 0, $opts );
742            $this->outputTimeout();
743        }
744
745        $this->includeRcFiltersApp();
746    }
747
748    /**
749     * Set the temp user config.
750     *
751     * @internal
752     * @param TempUserConfig $tempUserConfig
753     * @since 1.42
754     */
755    public function setTempUserConfig( TempUserConfig $tempUserConfig ) {
756        $this->tempUserConfig = $tempUserConfig;
757    }
758
759    /**
760     * Check whether or not the page should load defaults, and if so, whether
761     * a default saved query is relevant to be redirected to. If it is relevant,
762     * redirect properly with all necessary query parameters.
763     *
764     * @param string $subpage
765     * @return bool Whether a redirect will be performed.
766     */
767    protected function considerActionsForDefaultSavedQuery( $subpage ) {
768        if ( !$this->isStructuredFilterUiEnabled() || $this->including() ) {
769            return false;
770        }
771
772        $knownParams = $this->getRequest()->getValues(
773            ...array_keys( $this->getOptions()->getAllValues() )
774        );
775
776        // HACK: Temporarily until we can properly define "sticky" filters and parameters,
777        // we need to exclude several parameters we know should not be counted towards preventing
778        // the loading of defaults.
779        $excludedParams = [ 'limit' => '', 'days' => '', 'enhanced' => '', 'from' => '' ];
780        $knownParams = array_diff_key( $knownParams, $excludedParams );
781
782        if (
783            // If there are NO known parameters in the URL request
784            // (that are not excluded) then we need to check into loading
785            // the default saved query
786            count( $knownParams ) === 0
787        ) {
788            $prefJson = MediaWikiServices::getInstance()
789                ->getUserOptionsLookup()
790                ->getOption( $this->getUser(), $this->getSavedQueriesPreferenceName() );
791
792            // Get the saved queries data and parse it
793            $savedQueries = $prefJson ? FormatJson::decode( $prefJson, true ) : false;
794
795            if ( $savedQueries && isset( $savedQueries[ 'default' ] ) ) {
796                // Only load queries that are 'version' 2, since those
797                // have parameter representation
798                if ( isset( $savedQueries[ 'version' ] ) && $savedQueries[ 'version' ] === '2' ) {
799                    $savedQueryDefaultID = $savedQueries[ 'default' ];
800                    $defaultQuery = $savedQueries[ 'queries' ][ $savedQueryDefaultID ][ 'data' ];
801
802                    // Build the entire parameter list
803                    $query = array_merge(
804                        $defaultQuery[ 'params' ],
805                        $defaultQuery[ 'highlights' ],
806                        [
807                            'urlversion' => '2',
808                        ]
809                    );
810                    // Add to the query any parameters that we may have ignored before
811                    // but are still valid and requested in the URL
812                    $query = array_merge( $this->getRequest()->getQueryValues(), $query );
813                    unset( $query[ 'title' ] );
814                    $this->getOutput()->redirect( $this->getPageTitle( $subpage )->getCanonicalURL( $query ) );
815
816                    // Signal that we only need to redirect to the full URL
817                    // and can skip rendering the actual page (T330100).
818                    return true;
819                } else {
820                    // There's a default, but the version is not 2, and the server can't
821                    // actually recognize the query itself. This happens if it is before
822                    // the conversion, so we need to tell the UI to reload saved query as
823                    // it does the conversion to version 2
824                    $this->getOutput()->addJsConfigVars(
825                        'wgStructuredChangeFiltersDefaultSavedQueryExists',
826                        true
827                    );
828
829                    // Add the class that tells the frontend it is still loading
830                    // another query
831                    $this->getOutput()->addBodyClasses( 'mw-rcfilters-ui-loading' );
832                }
833            }
834        }
835
836        return false;
837    }
838
839    /**
840     * @see \MediaWiki\MainConfigSchema::RCLinkDays and \MediaWiki\MainConfigSchema::RCFilterByAge.
841     * @return int[]
842     */
843    protected function getLinkDays() {
844        $linkDays = $this->getConfig()->get( MainConfigNames::RCLinkDays );
845        $filterByAge = $this->getConfig()->get( MainConfigNames::RCFilterByAge );
846        $maxAge = $this->getConfig()->get( MainConfigNames::RCMaxAge );
847        if ( $filterByAge ) {
848            // Trim it to only links which are within $wgRCMaxAge.
849            // Note that we allow one link higher than the max for things like
850            // "age 56 days" being accessible through the "60 days" link.
851            sort( $linkDays );
852
853            $maxAgeDays = $maxAge / ( 3600 * 24 );
854            foreach ( $linkDays as $i => $days ) {
855                if ( $days >= $maxAgeDays ) {
856                    array_splice( $linkDays, $i + 1 );
857                    break;
858                }
859            }
860        }
861
862        return $linkDays;
863    }
864
865    /**
866     * Include the modules and configuration for the RCFilters app.
867     * Conditional on the user having the feature enabled.
868     *
869     * If it is disabled, add a <body> class marking that
870     */
871    protected function includeRcFiltersApp() {
872        $out = $this->getOutput();
873        if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
874            $jsData = $this->getStructuredFilterJsData();
875            $messages = [];
876            foreach ( $jsData['messageKeys'] as $key ) {
877                $messages[$key] = $this->msg( $key )->plain();
878            }
879
880            $out->addBodyClasses( 'mw-rcfilters-enabled' );
881            $collapsed = MediaWikiServices::getInstance()->getUserOptionsLookup()
882                ->getBoolOption( $this->getUser(), $this->getCollapsedPreferenceName() );
883            if ( $collapsed ) {
884                $out->addBodyClasses( 'mw-rcfilters-collapsed' );
885            }
886
887            // These config and message exports should be moved into a ResourceLoader data module (T201574)
888            $out->addJsConfigVars( 'wgStructuredChangeFilters', $jsData['groups'] );
889            $out->addJsConfigVars( 'wgStructuredChangeFiltersMessages', $messages );
890            $out->addJsConfigVars( 'wgStructuredChangeFiltersCollapsedState', $collapsed );
891
892            $out->addJsConfigVars(
893                'StructuredChangeFiltersDisplayConfig',
894                [
895                    'maxDays' => // Translate to days
896                        (int)$this->getConfig()->get( MainConfigNames::RCMaxAge ) / ( 24 * 3600 ),
897                    'limitArray' => $this->getConfig()->get( MainConfigNames::RCLinkLimits ),
898                    'limitDefault' => $this->getDefaultLimit(),
899                    'daysArray' => $this->getLinkDays(),
900                    'daysDefault' => $this->getDefaultDays(),
901                ]
902            );
903
904            $out->addJsConfigVars(
905                'wgStructuredChangeFiltersSavedQueriesPreferenceName',
906                $this->getSavedQueriesPreferenceName()
907            );
908            $out->addJsConfigVars(
909                'wgStructuredChangeFiltersLimitPreferenceName',
910                $this->getLimitPreferenceName()
911            );
912            $out->addJsConfigVars(
913                'wgStructuredChangeFiltersDaysPreferenceName',
914                $this->getDefaultDaysPreferenceName()
915            );
916            $out->addJsConfigVars(
917                'wgStructuredChangeFiltersCollapsedPreferenceName',
918                $this->getCollapsedPreferenceName()
919            );
920        } else {
921            $out->addBodyClasses( 'mw-rcfilters-disabled' );
922        }
923    }
924
925    /**
926     * Get essential data about getRcFiltersConfigVars() for change detection.
927     *
928     * @internal For use by Resources.php only.
929     * @see Module::getDefinitionSummary() and Module::getVersionHash()
930     * @param RL\Context $context
931     * @return array
932     */
933    public static function getRcFiltersConfigSummary( RL\Context $context ) {
934        $lang = MediaWikiServices::getInstance()->getLanguageFactory()
935            ->getLanguage( $context->getLanguage() );
936        return [
937            // Reduce version computation by avoiding Message parsing
938            'RCFiltersChangeTags' => ChangeTags::getChangeTagListSummary( $context, $lang ),
939            'StructuredChangeFiltersEditWatchlistUrl' =>
940                SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
941        ];
942    }
943
944    /**
945     * Get config vars to export with the mediawiki.rcfilters.filters.ui module.
946     *
947     * @internal For use by Resources.php only.
948     * @param RL\Context $context
949     * @return array
950     */
951    public static function getRcFiltersConfigVars( RL\Context $context ) {
952        $lang = MediaWikiServices::getInstance()->getLanguageFactory()
953            ->getLanguage( $context->getLanguage() );
954        return [
955            'RCFiltersChangeTags' => ChangeTags::getChangeTagList( $context, $lang ),
956            'StructuredChangeFiltersEditWatchlistUrl' =>
957                SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
958        ];
959    }
960
961    /**
962     * Add the "no results" message to the output
963     */
964    protected function outputNoResults() {
965        $this->getOutput()->addHTML(
966            Html::rawElement(
967                'div',
968                [ 'class' => 'mw-changeslist-empty' ],
969                $this->msg( 'recentchanges-noresult' )->parse()
970            )
971        );
972    }
973
974    /**
975     * Add the "timeout" message to the output
976     */
977    protected function outputTimeout() {
978        $this->getOutput()->addHTML(
979            '<div class="mw-changeslist-empty mw-changeslist-timeout">' .
980            $this->msg( 'recentchanges-timeout' )->parse() .
981            '</div>'
982        );
983    }
984
985    /**
986     * Get the database result for this special page instance. Used by ApiFeedRecentChanges.
987     *
988     * @return IResultWrapper|false
989     */
990    public function getRows() {
991        $opts = $this->getOptions();
992
993        $tables = [];
994        $fields = [];
995        $conds = [];
996        $query_options = [];
997        $join_conds = [];
998        $this->buildQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
999
1000        return $this->doMainQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
1001    }
1002
1003    /**
1004     * Get the current FormOptions for this request
1005     *
1006     * @return FormOptions
1007     */
1008    public function getOptions() {
1009        if ( $this->rcOptions === null ) {
1010            $this->rcOptions = $this->setup( $this->rcSubpage );
1011        }
1012
1013        return $this->rcOptions;
1014    }
1015
1016    /**
1017     * Register all filters and their groups (including those from hooks), plus handle
1018     * conflicts and defaults.
1019     *
1020     * You might want to customize these in the same method, in subclasses.  You can
1021     * call getFilterGroup to access a group, and (on the group) getFilter to access a
1022     * filter, then make necessary modfications to the filter or group (e.g. with
1023     * setDefault).
1024     */
1025    protected function registerFilters() {
1026        $isRegistrationRequiredToEdit = !MediaWikiServices::getInstance()
1027            ->getPermissionManager()
1028            ->isEveryoneAllowed( "edit" );
1029        if ( $isRegistrationRequiredToEdit ) {
1030            $this->removeRegistrationFilterDefinitions();
1031        }
1032        $this->registerFiltersFromDefinitions( $this->filterGroupDefinitions );
1033
1034        // Make sure this is not being transcluded (we don't want to show this
1035        // information to all users just because the user that saves the edit can
1036        // patrol or is logged in)
1037        if ( !$this->including() && $this->getUser()->useRCPatrol() ) {
1038            $this->registerFiltersFromDefinitions( $this->legacyReviewStatusFilterGroupDefinition );
1039            $this->registerFiltersFromDefinitions( $this->reviewStatusFilterGroupDefinition );
1040        }
1041
1042        $changeTypeGroup = $this->getFilterGroup( 'changeType' );
1043
1044        $categoryFilter = null;
1045        if ( $this->getConfig()->get( MainConfigNames::RCWatchCategoryMembership ) ) {
1046            $transformedHideCategorizationDef = $this->transformFilterDefinition(
1047                $this->hideCategorizationFilterDefinition
1048            );
1049
1050            $transformedHideCategorizationDef['group'] = $changeTypeGroup;
1051
1052            $categoryFilter = new ChangesListBooleanFilter(
1053                $transformedHideCategorizationDef
1054            );
1055        }
1056
1057        $this->getHookRunner()->onChangesListSpecialPageStructuredFilters( $this );
1058
1059        $this->registerFiltersFromDefinitions( [] );
1060
1061        $userExperienceLevel = $this->getFilterGroup( 'userExpLevel' );
1062        if ( !$isRegistrationRequiredToEdit ) {
1063            $registered = $userExperienceLevel->getFilter( 'registered' );
1064            $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'newcomer' ) );
1065            $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'learner' ) );
1066            $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'experienced' ) );
1067        }
1068
1069        $logactionsFilter = $changeTypeGroup->getFilter( 'hidelog' );
1070        $lognewuserFilter = $changeTypeGroup->getFilter( 'hidenewuserlog' );
1071        $pagecreationFilter = $changeTypeGroup->getFilter( 'hidenewpages' );
1072
1073        $significanceTypeGroup = $this->getFilterGroup( 'significance' );
1074        $hideMinorFilter = $significanceTypeGroup->getFilter( 'hideminor' );
1075
1076        if ( $categoryFilter !== null ) {
1077            $hideMinorFilter->conflictsWith(
1078                $categoryFilter,
1079                'rcfilters-hideminor-conflicts-typeofchange-global',
1080                'rcfilters-hideminor-conflicts-typeofchange',
1081                'rcfilters-typeofchange-conflicts-hideminor'
1082            );
1083        }
1084        $hideMinorFilter->conflictsWith(
1085            $logactionsFilter,
1086            'rcfilters-hideminor-conflicts-typeofchange-global',
1087            'rcfilters-hideminor-conflicts-typeofchange',
1088            'rcfilters-typeofchange-conflicts-hideminor'
1089        );
1090        $hideMinorFilter->conflictsWith(
1091            $lognewuserFilter,
1092            'rcfilters-hideminor-conflicts-typeofchange-global',
1093            'rcfilters-hideminor-conflicts-typeofchange',
1094            'rcfilters-typeofchange-conflicts-hideminor'
1095        );
1096        $hideMinorFilter->conflictsWith(
1097            $pagecreationFilter,
1098            'rcfilters-hideminor-conflicts-typeofchange-global',
1099            'rcfilters-hideminor-conflicts-typeofchange',
1100            'rcfilters-typeofchange-conflicts-hideminor'
1101        );
1102    }
1103
1104    /**
1105     * Transforms filter definition to prepare it for constructor.
1106     *
1107     * See overrides of this method as well.
1108     *
1109     * @param array $filterDefinition Original filter definition
1110     *
1111     * @return array Transformed definition
1112     */
1113    protected function transformFilterDefinition( array $filterDefinition ) {
1114        return $filterDefinition;
1115    }
1116
1117    /**
1118     * Register filters from a definition object
1119     *
1120     * Array specifying groups and their filters; see Filter and
1121     * ChangesListFilterGroup constructors.
1122     *
1123     * There is light processing to simplify core maintenance.
1124     * @param array $definition
1125     * @phan-param array<int,array{class:class-string<ChangesListFilterGroup>,filters:array}> $definition
1126     */
1127    protected function registerFiltersFromDefinitions( array $definition ) {
1128        $autoFillPriority = -1;
1129        foreach ( $definition as $groupDefinition ) {
1130            if ( !isset( $groupDefinition['priority'] ) ) {
1131                $groupDefinition['priority'] = $autoFillPriority;
1132            } else {
1133                // If it's explicitly specified, start over the auto-fill
1134                $autoFillPriority = $groupDefinition['priority'];
1135            }
1136
1137            $autoFillPriority--;
1138
1139            $className = $groupDefinition['class'];
1140            unset( $groupDefinition['class'] );
1141
1142            foreach ( $groupDefinition['filters'] as &$filterDefinition ) {
1143                $filterDefinition = $this->transformFilterDefinition( $filterDefinition );
1144            }
1145
1146            $this->registerFilterGroup( new $className( $groupDefinition ) );
1147        }
1148    }
1149
1150    /**
1151     * @return ChangesListBooleanFilter[] The legacy show/hide toggle filters
1152     */
1153    protected function getLegacyShowHideFilters() {
1154        $filters = [];
1155        foreach ( $this->filterGroups as $group ) {
1156            if ( $group instanceof ChangesListBooleanFilterGroup ) {
1157                foreach ( $group->getFilters() as $key => $filter ) {
1158                    if ( $filter->displaysOnUnstructuredUi() ) {
1159                        $filters[ $key ] = $filter;
1160                    }
1161                }
1162            }
1163        }
1164        return $filters;
1165    }
1166
1167    /**
1168     * Register all the filters, including legacy hook-driven ones.
1169     * Then create a FormOptions object with options as specified by the user
1170     *
1171     * @param string $parameters
1172     *
1173     * @return FormOptions
1174     */
1175    public function setup( $parameters ) {
1176        $this->registerFilters();
1177
1178        $opts = $this->getDefaultOptions();
1179
1180        $opts = $this->fetchOptionsFromRequest( $opts );
1181
1182        // Give precedence to subpage syntax
1183        if ( $parameters !== null ) {
1184            $this->parseParameters( $parameters, $opts );
1185        }
1186
1187        $this->validateOptions( $opts );
1188
1189        return $opts;
1190    }
1191
1192    /**
1193     * Get a FormOptions object containing the default options. By default, returns
1194     * some basic options.  The filters listed explicitly here are overridden in this
1195     * method, in subclasses, but most filters (e.g. hideminor, userExpLevel filters,
1196     * and more) are structured.  Structured filters are overridden in registerFilters.
1197     * not here.
1198     *
1199     * @return FormOptions
1200     */
1201    public function getDefaultOptions() {
1202        $opts = new FormOptions();
1203        $structuredUI = $this->isStructuredFilterUiEnabled();
1204        // If urlversion=2 is set, ignore the filter defaults and set them all to false/empty
1205        $useDefaults = $this->getRequest()->getInt( 'urlversion' ) !== 2;
1206
1207        /** @var ChangesListFilterGroup $filterGroup */
1208        foreach ( $this->filterGroups as $filterGroup ) {
1209            $filterGroup->addOptions( $opts, $useDefaults, $structuredUI );
1210        }
1211
1212        $opts->add( 'namespace', '', FormOptions::STRING );
1213        // TODO: Rename this option to 'invertnamespaces'?
1214        $opts->add( 'invert', false );
1215        $opts->add( 'associated', false );
1216        $opts->add( 'urlversion', 1 );
1217        $opts->add( 'tagfilter', '' );
1218        $opts->add( 'inverttags', false );
1219
1220        $opts->add( 'days', $this->getDefaultDays(), FormOptions::FLOAT );
1221        $opts->add( 'limit', $this->getDefaultLimit(), FormOptions::INT );
1222
1223        $opts->add( 'from', '' );
1224
1225        return $opts;
1226    }
1227
1228    /**
1229     * Register a structured changes list filter group
1230     *
1231     * @param ChangesListFilterGroup $group
1232     */
1233    public function registerFilterGroup( ChangesListFilterGroup $group ) {
1234        $groupName = $group->getName();
1235
1236        $this->filterGroups[$groupName] = $group;
1237    }
1238
1239    /**
1240     * Gets the currently registered filters groups
1241     *
1242     * @return ChangesListFilterGroup[] Associative array of ChangesListFilterGroup objects, with group name as key
1243     */
1244    protected function getFilterGroups() {
1245        return $this->filterGroups;
1246    }
1247
1248    /**
1249     * Gets a specified ChangesListFilterGroup by name
1250     *
1251     * @param string $groupName Name of group
1252     *
1253     * @return ChangesListFilterGroup|null Group, or null if not registered
1254     */
1255    public function getFilterGroup( $groupName ) {
1256        return $this->filterGroups[$groupName] ?? null;
1257    }
1258
1259    // Currently, this intentionally only includes filters that display
1260    // in the structured UI.  This can be changed easily, though, if we want
1261    // to include data on filters that use the unstructured UI.  messageKeys is a
1262    // special top-level value, with the value being an array of the message keys to
1263    // send to the client.
1264
1265    /**
1266     * Gets structured filter information needed by JS
1267     *
1268     * @return array Associative array
1269     * * array $return['groups'] Group data
1270     * * array $return['messageKeys'] Array of message keys
1271     */
1272    public function getStructuredFilterJsData() {
1273        $output = [
1274            'groups' => [],
1275            'messageKeys' => [],
1276        ];
1277
1278        usort( $this->filterGroups, static function ( ChangesListFilterGroup $a, ChangesListFilterGroup $b ) {
1279            return $b->getPriority() <=> $a->getPriority();
1280        } );
1281
1282        foreach ( $this->filterGroups as $group ) {
1283            $groupOutput = $group->getJsData();
1284            if ( $groupOutput !== null ) {
1285                $output['messageKeys'] = array_merge(
1286                    $output['messageKeys'],
1287                    $groupOutput['messageKeys']
1288                );
1289
1290                unset( $groupOutput['messageKeys'] );
1291                $output['groups'][] = $groupOutput;
1292            }
1293        }
1294
1295        return $output;
1296    }
1297
1298    /**
1299     * Fetch values for a FormOptions object from the WebRequest associated with this instance.
1300     *
1301     * Intended for subclassing, e.g. to add a backwards-compatibility layer.
1302     *
1303     * @param FormOptions $opts
1304     * @return FormOptions
1305     */
1306    protected function fetchOptionsFromRequest( $opts ) {
1307        $opts->fetchValuesFromRequest( $this->getRequest() );
1308
1309        return $opts;
1310    }
1311
1312    /**
1313     * Process $par and put options found in $opts. Used when including the page.
1314     *
1315     * @param string $par
1316     * @param FormOptions $opts
1317     */
1318    public function parseParameters( $par, FormOptions $opts ) {
1319        $stringParameterNameSet = [];
1320        $hideParameterNameSet = [];
1321
1322        // URL parameters can be per-group, like 'userExpLevel',
1323        // or per-filter, like 'hideminor'.
1324
1325        foreach ( $this->filterGroups as $filterGroup ) {
1326            if ( $filterGroup instanceof ChangesListStringOptionsFilterGroup ) {
1327                $stringParameterNameSet[$filterGroup->getName()] = true;
1328            } elseif ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
1329                foreach ( $filterGroup->getFilters() as $filter ) {
1330                    $hideParameterNameSet[$filter->getName()] = true;
1331                }
1332            }
1333        }
1334
1335        $bits = preg_split( '/\s*,\s*/', trim( $par ) );
1336        foreach ( $bits as $bit ) {
1337            $m = [];
1338            if ( isset( $hideParameterNameSet[$bit] ) ) {
1339                // hidefoo => hidefoo=true
1340                $opts[$bit] = true;
1341            } elseif ( isset( $hideParameterNameSet["hide$bit"] ) ) {
1342                // foo => hidefoo=false
1343                $opts["hide$bit"] = false;
1344            } elseif ( preg_match( '/^(.*)=(.*)$/', $bit, $m ) ) {
1345                if ( isset( $stringParameterNameSet[$m[1]] ) ) {
1346                    $opts[$m[1]] = $m[2];
1347                }
1348            }
1349        }
1350    }
1351
1352    /**
1353     * Validate a FormOptions object generated by getDefaultOptions() with values already populated.
1354     *
1355     * @param FormOptions $opts
1356     */
1357    public function validateOptions( FormOptions $opts ) {
1358        $isContradictory = $this->fixContradictoryOptions( $opts );
1359        $isReplaced = $this->replaceOldOptions( $opts );
1360
1361        if ( $isContradictory || $isReplaced ) {
1362            $query = wfArrayToCgi( $this->convertParamsForLink( $opts->getChangedValues() ) );
1363            $this->getOutput()->redirect( $this->getPageTitle()->getCanonicalURL( $query ) );
1364        }
1365
1366        $opts->validateIntBounds( 'limit', 0, 5000 );
1367        $opts->validateBounds( 'days', 0,
1368            $this->getConfig()->get( MainConfigNames::RCMaxAge ) / ( 3600 * 24 ) );
1369    }
1370
1371    /**
1372     * Fix invalid options by resetting pairs that should never appear together.
1373     *
1374     * @param FormOptions $opts
1375     * @return bool True if any option was reset
1376     */
1377    private function fixContradictoryOptions( FormOptions $opts ) {
1378        $fixed = $this->fixBackwardsCompatibilityOptions( $opts );
1379
1380        foreach ( $this->filterGroups as $filterGroup ) {
1381            if ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
1382                $filters = $filterGroup->getFilters();
1383
1384                if ( count( $filters ) === 1 ) {
1385                    // legacy boolean filters should not be considered
1386                    continue;
1387                }
1388
1389                $allInGroupEnabled = array_reduce(
1390                    $filters,
1391                    static function ( bool $carry, ChangesListBooleanFilter $filter ) use ( $opts ) {
1392                        return $carry && $opts[ $filter->getName() ];
1393                    },
1394                    /* initialValue */ count( $filters ) > 0
1395                );
1396
1397                if ( $allInGroupEnabled ) {
1398                    foreach ( $filters as $filter ) {
1399                        $opts[ $filter->getName() ] = false;
1400                    }
1401
1402                    $fixed = true;
1403                }
1404            }
1405        }
1406
1407        return $fixed;
1408    }
1409
1410    /**
1411     * Fix a special case (hideanons=1 and hideliu=1) in a special way, for backwards
1412     * compatibility.
1413     *
1414     * This is deprecated and may be removed.
1415     *
1416     * @param FormOptions $opts
1417     * @return bool True if this change was mode
1418     */
1419    private function fixBackwardsCompatibilityOptions( FormOptions $opts ) {
1420        if ( $opts['hideanons'] && $opts['hideliu'] ) {
1421            $opts->reset( 'hideanons' );
1422            if ( !$opts['hidebots'] ) {
1423                $opts->reset( 'hideliu' );
1424                $opts['hidehumans'] = 1;
1425            }
1426
1427            return true;
1428        }
1429
1430        return false;
1431    }
1432
1433    /**
1434     * Replace old options with their structured UI equivalents
1435     *
1436     * @param FormOptions $opts
1437     * @return bool True if the change was made
1438     */
1439    public function replaceOldOptions( FormOptions $opts ) {
1440        if ( !$this->isStructuredFilterUiEnabled() ) {
1441            return false;
1442        }
1443
1444        $changed = false;
1445
1446        // At this point 'hideanons' and 'hideliu' cannot be both true,
1447        // because fixBackwardsCompatibilityOptions resets (at least) 'hideanons' in such case
1448        if ( $opts[ 'hideanons' ] ) {
1449            $opts->reset( 'hideanons' );
1450            $opts[ 'userExpLevel' ] = 'registered';
1451            $changed = true;
1452        }
1453
1454        if ( $opts[ 'hideliu' ] ) {
1455            $opts->reset( 'hideliu' );
1456            $opts[ 'userExpLevel' ] = 'unregistered';
1457            $changed = true;
1458        }
1459
1460        if ( $this->getFilterGroup( 'legacyReviewStatus' ) ) {
1461            if ( $opts[ 'hidepatrolled' ] ) {
1462                $opts->reset( 'hidepatrolled' );
1463                $opts[ 'reviewStatus' ] = 'unpatrolled';
1464                $changed = true;
1465            }
1466
1467            if ( $opts[ 'hideunpatrolled' ] ) {
1468                $opts->reset( 'hideunpatrolled' );
1469                $opts[ 'reviewStatus' ] = implode(
1470                    ChangesListStringOptionsFilterGroup::SEPARATOR,
1471                    [ 'manual', 'auto' ]
1472                );
1473                $changed = true;
1474            }
1475        }
1476
1477        return $changed;
1478    }
1479
1480    /**
1481     * Convert parameters values from true/false to 1/0
1482     * so they are not omitted by wfArrayToCgi()
1483     * T38524
1484     *
1485     * @param array $params
1486     * @return array
1487     */
1488    protected function convertParamsForLink( $params ) {
1489        foreach ( $params as &$value ) {
1490            if ( $value === false ) {
1491                $value = '0';
1492            }
1493        }
1494        unset( $value );
1495        return $params;
1496    }
1497
1498    /**
1499     * Sets appropriate tables, fields, conditions, etc. depending on which filters
1500     * the user requested.
1501     *
1502     * @param array &$tables Array of tables; see IReadableDatabase::select $table
1503     * @param array &$fields Array of fields; see IReadableDatabase::select $vars
1504     * @param array &$conds Array of conditions; see IReadableDatabase::select $conds
1505     * @param array &$query_options Array of query options; see IReadableDatabase::select $options
1506     * @param array &$join_conds Array of join conditions; see IReadableDatabase::select $join_conds
1507     * @param FormOptions $opts
1508     */
1509    protected function buildQuery( &$tables, &$fields, &$conds, &$query_options,
1510        &$join_conds, FormOptions $opts
1511    ) {
1512        $dbr = $this->getDB();
1513        $isStructuredUI = $this->isStructuredFilterUiEnabled();
1514
1515        /** @var ChangesListFilterGroup $filterGroup */
1516        foreach ( $this->filterGroups as $filterGroup ) {
1517            $filterGroup->modifyQuery( $dbr, $this, $tables, $fields, $conds,
1518                $query_options, $join_conds, $opts, $isStructuredUI );
1519        }
1520
1521        // Namespace filtering
1522        if ( $opts[ 'namespace' ] !== '' ) {
1523            $namespaces = explode( ';', $opts[ 'namespace' ] );
1524
1525            $namespaces = $this->expandSymbolicNamespaceFilters( $namespaces );
1526
1527            $namespaceInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
1528            $namespaces = array_filter( $namespaces, [ $namespaceInfo, 'exists' ] );
1529
1530            if ( $namespaces !== [] ) {
1531                // Namespaces are just ints, use them as int when acting with the database
1532                $namespaces = array_map( 'intval', $namespaces );
1533
1534                if ( $opts[ 'associated' ] ) {
1535                    $associatedNamespaces = array_map(
1536                        [ $namespaceInfo, 'getAssociated' ],
1537                        array_filter( $namespaces, [ $namespaceInfo, 'hasTalkNamespace' ] )
1538                    );
1539                    $namespaces = array_unique( array_merge( $namespaces, $associatedNamespaces ) );
1540                }
1541
1542                $operator = $opts[ 'invert' ] ? '!=' : '=';
1543                sort( $namespaces );
1544                $conds[] = $dbr->expr( 'rc_namespace', $operator, $namespaces );
1545            }
1546        }
1547
1548        // Calculate cutoff
1549        $cutoff_unixtime = ConvertibleTimestamp::time() - $opts['days'] * 3600 * 24;
1550        $cutoff = $dbr->timestamp( $cutoff_unixtime );
1551
1552        $fromValid = preg_match( '/^[0-9]{14}$/', $opts['from'] );
1553        if ( $fromValid && $opts['from'] > wfTimestamp( TS_MW, $cutoff ) ) {
1554            $cutoff = $dbr->timestamp( $opts['from'] );
1555        } else {
1556            $opts->reset( 'from' );
1557        }
1558
1559        $conds[] = $dbr->expr( 'rc_timestamp', '>=', $cutoff );
1560    }
1561
1562    /**
1563     * Process the query
1564     *
1565     * @param array $tables Array of tables; see IReadableDatabase::select $table
1566     * @param array $fields Array of fields; see IReadableDatabase::select $vars
1567     * @param array $conds Array of conditions; see IReadableDatabase::select $conds
1568     * @param array $query_options Array of query options; see IReadableDatabase::select $options
1569     * @param array $join_conds Array of join conditions; see IReadableDatabase::select $join_conds
1570     * @param FormOptions $opts
1571     * @return bool|IResultWrapper Result or false
1572     */
1573    protected function doMainQuery( $tables, $fields, $conds,
1574        $query_options, $join_conds, FormOptions $opts
1575    ) {
1576        $rcQuery = RecentChange::getQueryInfo();
1577        $tables = array_merge( $tables, $rcQuery['tables'] );
1578        $fields = array_merge( $rcQuery['fields'], $fields );
1579        $join_conds = array_merge( $join_conds, $rcQuery['joins'] );
1580
1581        MediaWikiServices::getInstance()->getChangeTagsStore()->modifyDisplayQuery(
1582            $tables,
1583            $fields,
1584            $conds,
1585            $join_conds,
1586            $query_options,
1587            '',
1588            $opts[ 'inverttags' ]
1589        );
1590
1591        if (
1592            !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds, $opts )
1593        ) {
1594            return false;
1595        }
1596
1597        $dbr = $this->getDB();
1598
1599        return $dbr->newSelectQueryBuilder()
1600            ->tables( $tables )
1601            ->fields( $fields )
1602            ->conds( $conds )
1603            ->caller( __METHOD__ )
1604            ->options( $query_options )
1605            ->joinConds( $join_conds )
1606            ->fetchResultSet();
1607    }
1608
1609    protected function runMainQueryHook( &$tables, &$fields, &$conds,
1610        &$query_options, &$join_conds, $opts
1611    ) {
1612        return $this->getHookRunner()->onChangesListSpecialPageQuery(
1613            $this->getName(), $tables, $fields, $conds, $query_options, $join_conds, $opts );
1614    }
1615
1616    /**
1617     * Which database to use for read queries
1618     *
1619     * @return IReadableDatabase
1620     */
1621    protected function getDB(): IReadableDatabase {
1622        return MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
1623    }
1624
1625    /**
1626     * Send header output to the OutputPage object, only called if not using feeds
1627     *
1628     * @param int $rowCount Number of database rows
1629     * @param FormOptions $opts
1630     */
1631    private function webOutputHeader( $rowCount, $opts ) {
1632        if ( !$this->including() ) {
1633            $this->outputFeedLinks();
1634            $this->doHeader( $opts, $rowCount );
1635        }
1636    }
1637
1638    /**
1639     * Send output to the OutputPage object, only called if not used feeds
1640     *
1641     * @param IResultWrapper $rows Database rows
1642     * @param FormOptions $opts
1643     */
1644    public function webOutput( $rows, $opts ) {
1645        $this->webOutputHeader( $rows->numRows(), $opts );
1646
1647        $this->outputChangesList( $rows, $opts );
1648    }
1649
1650    public function outputFeedLinks() {
1651        // nothing by default
1652    }
1653
1654    /**
1655     * Build and output the actual changes list.
1656     *
1657     * @param IResultWrapper $rows Database rows
1658     * @param FormOptions $opts
1659     */
1660    abstract public function outputChangesList( $rows, $opts );
1661
1662    /**
1663     * Set the text to be displayed above the changes
1664     *
1665     * @param FormOptions $opts
1666     * @param int $numRows Number of rows in the result to show after this header
1667     */
1668    public function doHeader( $opts, $numRows ) {
1669        $this->setTopText( $opts );
1670
1671        // @todo Lots of stuff should be done here.
1672
1673        $this->setBottomText( $opts );
1674    }
1675
1676    /**
1677     * Send the text to be displayed before the options.
1678     * Should use $this->getOutput()->addWikiTextAsInterface()
1679     * or similar methods to print the text.
1680     *
1681     * @param FormOptions $opts
1682     */
1683    public function setTopText( FormOptions $opts ) {
1684        // nothing by default
1685    }
1686
1687    /**
1688     * Send the text to be displayed after the options.
1689     * Should use $this->getOutput()->addWikiTextAsInterface()
1690     * or similar methods to print the text.
1691     *
1692     * @param FormOptions $opts
1693     */
1694    public function setBottomText( FormOptions $opts ) {
1695        // nothing by default
1696    }
1697
1698    /**
1699     * Get options to be displayed in a form
1700     * @todo This should handle options returned by getDefaultOptions().
1701     * @todo Not called by anything in this class (but is in subclasses), should be
1702     * called by something… doHeader() maybe?
1703     *
1704     * @param FormOptions $opts
1705     * @return array
1706     */
1707    public function getExtraOptions( $opts ) {
1708        return [];
1709    }
1710
1711    /**
1712     * Return the legend displayed within the fieldset
1713     *
1714     * @return string
1715     */
1716    public function makeLegend() {
1717        $context = $this->getContext();
1718        $user = $context->getUser();
1719        # The legend showing what the letters and stuff mean
1720        $legend = Html::openElement( 'dl' ) . "\n";
1721        # Iterates through them and gets the messages for both letter and tooltip
1722        $legendItems = $context->getConfig()->get( MainConfigNames::RecentChangesFlags );
1723        if ( !( $user->useRCPatrol() || $user->useNPPatrol() ) ) {
1724            unset( $legendItems['unpatrolled'] );
1725        }
1726        foreach ( $legendItems as $key => $item ) { # generate items of the legend
1727            $label = $item['legend'] ?? $item['title'];
1728            $letter = $item['letter'];
1729            $cssClass = $item['class'] ?? $key;
1730
1731            $legend .= Html::element( 'dt',
1732                [ 'class' => $cssClass ], $context->msg( $letter )->text()
1733            ) . "\n" .
1734            Html::rawElement( 'dd',
1735                [ 'class' => Sanitizer::escapeClass( 'mw-changeslist-legend-' . $key ) ],
1736                $context->msg( $label )->parse()
1737            ) . "\n";
1738        }
1739        # (+-123)
1740        $legend .= Html::rawElement( 'dt',
1741            [ 'class' => 'mw-plusminus-pos' ],
1742            $context->msg( 'recentchanges-legend-plusminus' )->parse()
1743        ) . "\n";
1744        $legend .= Html::element(
1745            'dd',
1746            [ 'class' => 'mw-changeslist-legend-plusminus' ],
1747            $context->msg( 'recentchanges-label-plusminus' )->text()
1748        ) . "\n";
1749        // Watchlist expiry clock icon.
1750        if ( $context->getConfig()->get( MainConfigNames::WatchlistExpiry ) && !$this->including() ) {
1751            $widget = new IconWidget( [
1752                'icon' => 'clock',
1753                'classes' => [ 'mw-changesList-watchlistExpiry' ],
1754            ] );
1755            // Link the image to its label for assistive technologies.
1756            $watchlistLabelId = 'mw-changeslist-watchlistExpiry-label';
1757            $widget->getIconElement()->setAttributes( [
1758                'role' => 'img',
1759                'aria-labelledby' => $watchlistLabelId,
1760            ] );
1761            $legend .= Html::rawElement(
1762                'dt',
1763                [ 'class' => 'mw-changeslist-legend-watchlistexpiry' ],
1764                $widget
1765            );
1766            $legend .= Html::element(
1767                'dd',
1768                [ 'class' => 'mw-changeslist-legend-watchlistexpiry', 'id' => $watchlistLabelId ],
1769                $context->msg( 'recentchanges-legend-watchlistexpiry' )->text()
1770            );
1771        }
1772        $legend .= Html::closeElement( 'dl' ) . "\n";
1773
1774        $legendHeading = $this->isStructuredFilterUiEnabled() ?
1775            $context->msg( 'rcfilters-legend-heading' )->parse() :
1776            $context->msg( 'recentchanges-legend-heading' )->parse();
1777
1778        # Collapsible
1779        $collapsedState = $this->getRequest()->getCookie( 'changeslist-state' );
1780
1781        $legend = Html::rawElement( 'details', [
1782                'class' => 'mw-changeslist-legend',
1783                'open' => $collapsedState !== 'collapsed' ? 'open' : null,
1784            ],
1785            Html::rawElement( 'summary', [], $legendHeading ) .
1786                $legend
1787        );
1788
1789        return $legend;
1790    }
1791
1792    /**
1793     * Add page-specific modules.
1794     */
1795    protected function addModules() {
1796        $out = $this->getOutput();
1797        // Styles and behavior for the legend box (see makeLegend())
1798        $out->addModuleStyles( [
1799            'mediawiki.interface.helpers.styles',
1800            'mediawiki.special.changeslist.legend',
1801            'mediawiki.special.changeslist',
1802        ] );
1803        $out->addModules( 'mediawiki.special.changeslist.legend.js' );
1804
1805        if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
1806            $out->addModules( 'mediawiki.rcfilters.filters.ui' );
1807            $out->addModuleStyles( 'mediawiki.rcfilters.filters.base.styles' );
1808        }
1809    }
1810
1811    protected function getGroupName() {
1812        return 'changes';
1813    }
1814
1815    /**
1816     * Return expression that is true when the user is or isn't registered.
1817     * @param bool $isRegistered
1818     * @param IReadableDatabase $dbr
1819     * @return IExpression
1820     */
1821    private function getRegisteredExpr( $isRegistered, $dbr ): IExpression {
1822        $expr = $dbr->expr( 'actor_user', $isRegistered ? '!=' : '=', null );
1823        if ( !$this->tempUserConfig->isKnown() ) {
1824            return $expr;
1825        }
1826        if ( $isRegistered ) {
1827            return $expr->andExpr( $this->tempUserConfig->getMatchCondition( $dbr,
1828                'actor_name', IExpression::NOT_LIKE ) );
1829        } else {
1830            return $expr->orExpr( $this->tempUserConfig->getMatchCondition( $dbr,
1831                'actor_name', IExpression::LIKE ) );
1832        }
1833    }
1834
1835    /**
1836     * Return expression that is true when the user has reached the given experience level.
1837     * @param string $level 'learner' or 'experienced'
1838     * @param int $now Current time as UNIX timestamp (if 0, uses actual time)
1839     * @param IReadableDatabase $dbr
1840     * @param bool $asNotCondition
1841     * @return IExpression
1842     */
1843    private function getExperienceExpr( $level, $now, IReadableDatabase $dbr, $asNotCondition = false ): IExpression {
1844        $config = $this->getConfig();
1845
1846        $configSince = [
1847            'learner' => $config->get( MainConfigNames::LearnerMemberSince ),
1848            'experienced' => $config->get( MainConfigNames::ExperiencedUserMemberSince ),
1849        ][$level];
1850        if ( $now === 0 ) {
1851            $now = ConvertibleTimestamp::time();
1852        }
1853        $secondsPerDay = 86400;
1854        $timeCutoff = $now - $configSince * $secondsPerDay;
1855
1856        $editCutoff = [
1857            'learner' => $config->get( MainConfigNames::LearnerEdits ),
1858            'experienced' => $config->get( MainConfigNames::ExperiencedUserEdits ),
1859        ][$level];
1860
1861        if ( $asNotCondition ) {
1862            return $dbr->expr( 'user_editcount', '<', intval( $editCutoff ) )
1863                ->or( 'user_registration', '>', $dbr->timestamp( $timeCutoff ) );
1864        }
1865        return $dbr->expr( 'user_editcount', '>=', intval( $editCutoff ) )->andExpr(
1866            // Users who don't have user_registration set are very old, so we assume they're above any cutoff
1867            $dbr->expr( 'user_registration', '=', null )
1868                ->or( 'user_registration', '<=', $dbr->timestamp( $timeCutoff ) )
1869        );
1870    }
1871
1872    /**
1873     * Filter on users' experience levels; this will not be called if nothing is
1874     * selected.
1875     *
1876     * @param string $specialPageClassName Class name of current special page
1877     * @param IContextSource $context Context, for e.g. user
1878     * @param IReadableDatabase $dbr Database, for addQuotes, makeList, and similar
1879     * @param array &$tables Array of tables; see IReadableDatabase::select $table
1880     * @param array &$fields Array of fields; see IReadableDatabase::select $vars
1881     * @param array &$conds Array of conditions; see IReadableDatabase::select $conds
1882     * @param array &$query_options Array of query options; see IReadableDatabase::select $options
1883     * @param array &$join_conds Array of join conditions; see IReadableDatabase::select $join_conds
1884     * @param array $selectedExpLevels The allowed active values, sorted
1885     * @param int $now Current time as UNIX timestamp (if 0, uses actual time)
1886     */
1887    public function filterOnUserExperienceLevel( $specialPageClassName, $context, $dbr,
1888        &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedExpLevels, $now = 0
1889    ) {
1890        $selected = array_fill_keys( $selectedExpLevels, true );
1891
1892        $isUnregistered = $this->getRegisteredExpr( false, $dbr );
1893        $isRegistered = $this->getRegisteredExpr( true, $dbr );
1894        $aboveNewcomer = $this->getExperienceExpr( 'learner', $now, $dbr );
1895        $notAboveNewcomer = $this->getExperienceExpr( 'learner', $now, $dbr, true );
1896        $aboveLearner = $this->getExperienceExpr( 'experienced', $now, $dbr );
1897        $notAboveLearner = $this->getExperienceExpr( 'experienced', $now, $dbr, true );
1898
1899        // We need to select some range of user experience levels, from the following table:
1900        // | Unregistered |     --------- Registered ---------     |
1901        // |              |  Newcomers  |  Learners  | Experienced |
1902        // |<------------>|<----------->|<---------->|<----------->|
1903        // We just need to define a condition for each of the columns, figure out which are selected,
1904        // and then OR them together.
1905        $columnConds = [
1906            'unregistered' => $isUnregistered,
1907            'registered' => $isRegistered,
1908            'newcomer' => $dbr->andExpr( [ $isRegistered, $notAboveNewcomer ] ),
1909            'learner' => $dbr->andExpr( [ $isRegistered, $aboveNewcomer, $notAboveLearner ] ),
1910            'experienced' => $dbr->andExpr( [ $isRegistered, $aboveLearner ] ),
1911        ];
1912
1913        // There are some cases where we can easily optimize away some queries:
1914        // | Unregistered |     --------- Registered ---------     |
1915        // |              |  Newcomers  |  Learners  | Experienced |
1916        // |              |<-------------------------------------->| (1)
1917        // |<----------------------------------------------------->| (2)
1918
1919        // (1) Selecting all of "Newcomers", "Learners" and "Experienced users" is the same as "Registered".
1920        if (
1921            isset( $selected['registered'] ) ||
1922            ( isset( $selected['newcomer'] ) && isset( $selected['learner'] ) && isset( $selected['experienced'] ) )
1923        ) {
1924            unset( $selected['newcomer'], $selected['learner'], $selected['experienced'] );
1925            $selected['registered'] = true;
1926        }
1927        // (2) Selecting "Unregistered" and "Registered" covers all users.
1928        if ( isset( $selected['registered'] ) && isset( $selected['unregistered'] ) ) {
1929            unset( $selected['registered'], $selected['unregistered'] );
1930        }
1931
1932        // Combine the conditions for the selected columns.
1933        if ( !$selected ) {
1934            return;
1935        }
1936        $selectedColumnConds = array_values( array_intersect_key( $columnConds, $selected ) );
1937        $conds[] = $dbr->orExpr( $selectedColumnConds );
1938
1939        // Add necessary tables to the queries.
1940        $join_conds['recentchanges_actor'] = [ 'JOIN', 'actor_id=rc_actor' ];
1941        if ( isset( $selected['newcomer'] ) || isset( $selected['learner'] ) || isset( $selected['experienced'] ) ) {
1942            $tables[] = 'user';
1943            $join_conds['user'] = [ 'LEFT JOIN', 'actor_user=user_id' ];
1944        }
1945    }
1946
1947    /**
1948     * Check whether the structured filter UI is enabled
1949     *
1950     * @return bool
1951     */
1952    public function isStructuredFilterUiEnabled() {
1953        if ( $this->getRequest()->getBool( 'rcfilters' ) ) {
1954            return true;
1955        }
1956
1957        return static::checkStructuredFilterUiEnabled( $this->getUser() );
1958    }
1959
1960    /**
1961     * Static method to check whether StructuredFilter UI is enabled for the given user
1962     *
1963     * @since 1.31
1964     * @param UserIdentity $user
1965     * @return bool
1966     */
1967    public static function checkStructuredFilterUiEnabled( UserIdentity $user ) {
1968        return !MediaWikiServices::getInstance()
1969            ->getUserOptionsLookup()
1970            ->getOption( $user, 'rcenhancedfilters-disable' );
1971    }
1972
1973    /**
1974     * Get the default value of the number of changes to display when loading
1975     * the result set.
1976     *
1977     * @since 1.30
1978     * @return int
1979     */
1980    public function getDefaultLimit() {
1981        return MediaWikiServices::getInstance()
1982            ->getUserOptionsLookup()
1983            ->getIntOption( $this->getUser(), $this->getLimitPreferenceName() );
1984    }
1985
1986    /**
1987     * Get the default value of the number of days to display when loading
1988     * the result set.
1989     * Supports fractional values, and should be cast to a float.
1990     *
1991     * @since 1.30
1992     * @return float
1993     */
1994    public function getDefaultDays() {
1995        return floatval( MediaWikiServices::getInstance()
1996            ->getUserOptionsLookup()
1997            ->getOption( $this->getUser(), $this->getDefaultDaysPreferenceName() ) );
1998    }
1999
2000    /**
2001     * Getting the preference name for 'limit'.
2002     *
2003     * @since 1.37
2004     * @return string
2005     */
2006    abstract protected function getLimitPreferenceName(): string;
2007
2008    /**
2009     * Preference name for saved queries.
2010     *
2011     * @since 1.38
2012     * @return string
2013     */
2014    abstract protected function getSavedQueriesPreferenceName(): string;
2015
2016    /**
2017     * Preference name for 'days'.
2018     *
2019     * @since 1.38
2020     * @return string
2021     */
2022    abstract protected function getDefaultDaysPreferenceName(): string;
2023
2024    /**
2025     * Preference name for collapsing the active filter display.
2026     *
2027     * @since 1.38
2028     * @return string
2029     */
2030    abstract protected function getCollapsedPreferenceName(): string;
2031
2032    /**
2033     * @param array $namespaces
2034     * @return array
2035     */
2036    private function expandSymbolicNamespaceFilters( array $namespaces ) {
2037        $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
2038        $symbolicFilters = [
2039            'all-contents' => $nsInfo->getSubjectNamespaces(),
2040            'all-discussions' => $nsInfo->getTalkNamespaces(),
2041        ];
2042        $additionalNamespaces = [];
2043        foreach ( $symbolicFilters as $name => $values ) {
2044            if ( in_array( $name, $namespaces ) ) {
2045                $additionalNamespaces = array_merge( $additionalNamespaces, $values );
2046            }
2047        }
2048        $namespaces = array_diff( $namespaces, array_keys( $symbolicFilters ) );
2049        $namespaces = array_merge( $namespaces, $additionalNamespaces );
2050        return array_unique( $namespaces );
2051    }
2052}
2053
2054/** @deprecated class alias since 1.41 */
2055class_alias( ChangesListSpecialPage::class, 'ChangesListSpecialPage' );