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