Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
74.26% |
427 / 575 |
|
44.00% |
11 / 25 |
CRAP | |
0.00% |
0 / 1 |
SpecialWatchlist | |
74.39% |
427 / 574 |
|
44.00% |
11 / 25 |
285.67 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
69.70% |
23 / 33 |
|
0.00% |
0 / 1 |
17.70 | |||
checkStructuredFilterUiEnabled | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getSubpagesForPrefixSearch | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
transformFilterDefinition | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
registerFilters | |
74.14% |
86 / 116 |
|
0.00% |
0 / 1 |
14.49 | |||
fetchOptionsFromRequest | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
5 | |||
doMainQuery | |
91.43% |
64 / 70 |
|
0.00% |
0 / 1 |
8.04 | |||
outputFeedLinks | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
outputChangesList | |
16.13% |
10 / 62 |
|
0.00% |
0 / 1 |
255.99 | |||
getAssociatedNavigationLinks | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getShortDescriptionHelper | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
42 | |||
getShortDescription | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
doHeader | |
98.59% |
140 / 142 |
|
0.00% |
0 / 1 |
7 | |||
cutoffselector | |
96.00% |
24 / 25 |
|
0.00% |
0 / 1 |
4 | |||
setTopText | |
40.54% |
15 / 37 |
|
0.00% |
0 / 1 |
26.03 | |||
showHideCheck | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
countItems | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
isChangeEffectivelySeen | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
getLatestNotificationTimestamp | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
getLimitPreferenceName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getSavedQueriesPreferenceName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getDefaultDaysPreferenceName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getCollapsedPreferenceName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | /** |
3 | * Implements Special:Watchlist |
4 | * |
5 | * This program is free software; you can redistribute it and/or modify |
6 | * it under the terms of the GNU General Public License as published by |
7 | * the Free Software Foundation; either version 2 of the License, or |
8 | * (at your option) any later version. |
9 | * |
10 | * This program is distributed in the hope that it will be useful, |
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
13 | * GNU General Public License for more details. |
14 | * |
15 | * You should have received a copy of the GNU General Public License along |
16 | * with this program; if not, write to the Free Software Foundation, Inc., |
17 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
18 | * http://www.gnu.org/copyleft/gpl.html |
19 | * |
20 | * @file |
21 | * @ingroup SpecialPage |
22 | */ |
23 | |
24 | namespace MediaWiki\Specials; |
25 | |
26 | use ChangesList; |
27 | use ChangesListBooleanFilterGroup; |
28 | use ChangesListStringOptionsFilterGroup; |
29 | use EnhancedChangesList; |
30 | use LogPage; |
31 | use MediaWiki\ChangeTags\ChangeTagsStore; |
32 | use MediaWiki\Context\IContextSource; |
33 | use MediaWiki\Html\FormOptions; |
34 | use MediaWiki\Html\Html; |
35 | use MediaWiki\MainConfigNames; |
36 | use MediaWiki\MediaWikiServices; |
37 | use MediaWiki\Request\DerivativeRequest; |
38 | use MediaWiki\SpecialPage\ChangesListSpecialPage; |
39 | use MediaWiki\SpecialPage\SpecialPage; |
40 | use MediaWiki\Title\TitleValue; |
41 | use MediaWiki\User\Options\UserOptionsLookup; |
42 | use MediaWiki\User\TempUser\TempUserConfig; |
43 | use MediaWiki\User\UserIdentity; |
44 | use MediaWiki\User\UserIdentityUtils; |
45 | use MediaWiki\Watchlist\WatchlistManager; |
46 | use RecentChange; |
47 | use UserNotLoggedIn; |
48 | use WatchedItem; |
49 | use WatchedItemStoreInterface; |
50 | use Wikimedia\Rdbms\IReadableDatabase; |
51 | use Wikimedia\Rdbms\IResultWrapper; |
52 | use Xml; |
53 | use XmlSelect; |
54 | |
55 | /** |
56 | * A special page that lists last changes made to the wiki, |
57 | * limited to user-defined list of titles. |
58 | * |
59 | * @ingroup SpecialPage |
60 | */ |
61 | class SpecialWatchlist extends ChangesListSpecialPage { |
62 | /** @var array */ |
63 | public const WATCHLIST_TAB_PATHS = [ |
64 | 'Special:Watchlist', |
65 | 'Special:EditWatchlist', |
66 | 'Special:EditWatchlist/raw', |
67 | 'Special:EditWatchlist/clear' |
68 | ]; |
69 | |
70 | private WatchedItemStoreInterface $watchedItemStore; |
71 | private WatchlistManager $watchlistManager; |
72 | private UserOptionsLookup $userOptionsLookup; |
73 | |
74 | /** |
75 | * @var int|false where the value is one of the SpecialEditWatchlist:EDIT_ prefixed |
76 | * constants (e.g. EDIT_NORMAL) |
77 | */ |
78 | private $currentMode; |
79 | private ChangeTagsStore $changeTagsStore; |
80 | |
81 | /** |
82 | * @param WatchedItemStoreInterface $watchedItemStore |
83 | * @param WatchlistManager $watchlistManager |
84 | * @param UserOptionsLookup $userOptionsLookup |
85 | */ |
86 | public function __construct( |
87 | WatchedItemStoreInterface $watchedItemStore, |
88 | WatchlistManager $watchlistManager, |
89 | UserOptionsLookup $userOptionsLookup, |
90 | ChangeTagsStore $changeTagsStore, |
91 | UserIdentityUtils $userIdentityUtils, |
92 | TempUserConfig $tempUserConfig |
93 | ) { |
94 | parent::__construct( |
95 | 'Watchlist', |
96 | 'viewmywatchlist', |
97 | $userIdentityUtils, |
98 | $tempUserConfig |
99 | ); |
100 | |
101 | $this->watchedItemStore = $watchedItemStore; |
102 | $this->watchlistManager = $watchlistManager; |
103 | $this->userOptionsLookup = $userOptionsLookup; |
104 | $this->changeTagsStore = $changeTagsStore; |
105 | } |
106 | |
107 | public function doesWrites() { |
108 | return true; |
109 | } |
110 | |
111 | /** |
112 | * Main execution point |
113 | * |
114 | * @param string|null $subpage |
115 | */ |
116 | public function execute( $subpage ) { |
117 | $user = $this->getUser(); |
118 | if ( |
119 | // Anons don't get a watchlist |
120 | !$user->isRegistered() |
121 | // Redirect temp users to login if they're not allowed |
122 | || ( $user->isTemp() && !$user->isAllowed( 'viewmywatchlist' ) ) |
123 | ) { |
124 | throw new UserNotLoggedIn( 'watchlistanontext' ); |
125 | } |
126 | |
127 | $output = $this->getOutput(); |
128 | $request = $this->getRequest(); |
129 | $this->addHelpLink( 'Help:Watching pages' ); |
130 | $output->addModuleStyles( [ 'mediawiki.special' ] ); |
131 | $output->addModules( [ 'mediawiki.special.watchlist' ] ); |
132 | |
133 | $mode = SpecialEditWatchlist::getMode( $request, $subpage ); |
134 | $this->currentMode = $mode; |
135 | |
136 | if ( $mode !== false ) { |
137 | if ( $mode === SpecialEditWatchlist::EDIT_RAW ) { |
138 | $title = SpecialPage::getTitleFor( 'EditWatchlist', 'raw' ); |
139 | } elseif ( $mode === SpecialEditWatchlist::EDIT_CLEAR ) { |
140 | $title = SpecialPage::getTitleFor( 'EditWatchlist', 'clear' ); |
141 | } else { |
142 | $title = SpecialPage::getTitleFor( 'EditWatchlist' ); |
143 | } |
144 | |
145 | $output->redirect( $title->getLocalURL() ); |
146 | |
147 | return; |
148 | } |
149 | |
150 | $this->checkPermissions(); |
151 | |
152 | $opts = $this->getOptions(); |
153 | |
154 | $config = $this->getConfig(); |
155 | if ( ( $config->get( MainConfigNames::EnotifWatchlist ) || |
156 | $config->get( MainConfigNames::ShowUpdatedMarker ) ) |
157 | && $request->getVal( 'reset' ) |
158 | && $request->wasPosted() |
159 | && $user->matchEditToken( $request->getVal( 'token' ) ) |
160 | ) { |
161 | $this->watchlistManager->clearAllUserNotifications( $user ); |
162 | $output->redirect( $this->getPageTitle()->getFullURL( $opts->getChangedValues() ) ); |
163 | |
164 | return; |
165 | } |
166 | |
167 | parent::execute( $subpage ); |
168 | |
169 | if ( $this->isStructuredFilterUiEnabled() ) { |
170 | $output->addModuleStyles( [ 'mediawiki.rcfilters.highlightCircles.seenunseen.styles' ] ); |
171 | } |
172 | } |
173 | |
174 | /** |
175 | * @inheritDoc |
176 | */ |
177 | public static function checkStructuredFilterUiEnabled( UserIdentity $user ) { |
178 | return !MediaWikiServices::getInstance() |
179 | ->getUserOptionsLookup() |
180 | ->getOption( $user, 'wlenhancedfilters-disable' ); |
181 | } |
182 | |
183 | /** |
184 | * Return an array of subpages that this special page will accept. |
185 | * |
186 | * @see also SpecialEditWatchlist::getSubpagesForPrefixSearch |
187 | * @return string[] subpages |
188 | */ |
189 | public function getSubpagesForPrefixSearch() { |
190 | return [ |
191 | 'edit', |
192 | 'raw', |
193 | 'clear', |
194 | ]; |
195 | } |
196 | |
197 | /** |
198 | * @inheritDoc |
199 | */ |
200 | protected function transformFilterDefinition( array $filterDefinition ) { |
201 | if ( isset( $filterDefinition['showHideSuffix'] ) ) { |
202 | $filterDefinition['showHide'] = 'wl' . $filterDefinition['showHideSuffix']; |
203 | } |
204 | |
205 | return $filterDefinition; |
206 | } |
207 | |
208 | /** |
209 | * @inheritDoc |
210 | * @suppress PhanUndeclaredMethod |
211 | */ |
212 | protected function registerFilters() { |
213 | parent::registerFilters(); |
214 | |
215 | // legacy 'extended' filter |
216 | $this->registerFilterGroup( new ChangesListBooleanFilterGroup( [ |
217 | 'name' => 'extended-group', |
218 | 'filters' => [ |
219 | [ |
220 | 'name' => 'extended', |
221 | 'isReplacedInStructuredUi' => true, |
222 | 'activeValue' => false, |
223 | 'default' => $this->userOptionsLookup->getBoolOption( $this->getUser(), 'extendwatchlist' ), |
224 | 'queryCallable' => function ( string $specialClassName, IContextSource $ctx, |
225 | IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds |
226 | ) { |
227 | $nonRevisionTypes = [ RC_LOG ]; |
228 | $this->getHookRunner()->onSpecialWatchlistGetNonRevisionTypes( $nonRevisionTypes ); |
229 | if ( $nonRevisionTypes ) { |
230 | $conds[] = $dbr->makeList( |
231 | [ |
232 | 'rc_this_oldid=page_latest', |
233 | 'rc_type' => $nonRevisionTypes, |
234 | ], |
235 | LIST_OR |
236 | ); |
237 | } |
238 | }, |
239 | ] |
240 | ], |
241 | |
242 | ] ) ); |
243 | |
244 | if ( $this->isStructuredFilterUiEnabled() ) { |
245 | $this->getFilterGroup( 'lastRevision' ) |
246 | ->getFilter( 'hidepreviousrevisions' ) |
247 | ->setDefault( !$this->userOptionsLookup->getBoolOption( $this->getUser(), 'extendwatchlist' ) ); |
248 | } |
249 | |
250 | $this->registerFilterGroup( new ChangesListStringOptionsFilterGroup( [ |
251 | 'name' => 'watchlistactivity', |
252 | 'title' => 'rcfilters-filtergroup-watchlistactivity', |
253 | 'class' => ChangesListStringOptionsFilterGroup::class, |
254 | 'priority' => 3, |
255 | 'isFullCoverage' => true, |
256 | 'filters' => [ |
257 | [ |
258 | 'name' => 'unseen', |
259 | 'label' => 'rcfilters-filter-watchlistactivity-unseen-label', |
260 | 'description' => 'rcfilters-filter-watchlistactivity-unseen-description', |
261 | 'cssClassSuffix' => 'watchedunseen', |
262 | 'isRowApplicableCallable' => function ( IContextSource $ctx, RecentChange $rc ) { |
263 | return !$this->isChangeEffectivelySeen( $rc ); |
264 | }, |
265 | ], |
266 | [ |
267 | 'name' => 'seen', |
268 | 'label' => 'rcfilters-filter-watchlistactivity-seen-label', |
269 | 'description' => 'rcfilters-filter-watchlistactivity-seen-description', |
270 | 'cssClassSuffix' => 'watchedseen', |
271 | 'isRowApplicableCallable' => function ( IContextSource $ctx, RecentChange $rc ) { |
272 | return $this->isChangeEffectivelySeen( $rc ); |
273 | } |
274 | ], |
275 | ], |
276 | 'default' => ChangesListStringOptionsFilterGroup::NONE, |
277 | 'queryCallable' => static function ( |
278 | string $specialPageClassName, |
279 | IContextSource $context, |
280 | IReadableDatabase $dbr, |
281 | &$tables, |
282 | &$fields, |
283 | &$conds, |
284 | &$query_options, |
285 | &$join_conds, |
286 | $selectedValues |
287 | ) { |
288 | if ( $selectedValues === [ 'seen' ] ) { |
289 | $conds[] = $dbr->makeList( [ |
290 | 'wl_notificationtimestamp' => null, |
291 | 'rc_timestamp < wl_notificationtimestamp' |
292 | ], LIST_OR ); |
293 | } elseif ( $selectedValues === [ 'unseen' ] ) { |
294 | $conds[] = $dbr->makeList( [ |
295 | 'wl_notificationtimestamp IS NOT NULL', |
296 | 'rc_timestamp >= wl_notificationtimestamp' |
297 | ], LIST_AND ); |
298 | } |
299 | } |
300 | ] ) ); |
301 | |
302 | $user = $this->getUser(); |
303 | |
304 | $significance = $this->getFilterGroup( 'significance' ); |
305 | $hideMinor = $significance->getFilter( 'hideminor' ); |
306 | $hideMinor->setDefault( $this->userOptionsLookup->getBoolOption( $user, 'watchlisthideminor' ) ); |
307 | |
308 | $automated = $this->getFilterGroup( 'automated' ); |
309 | $hideBots = $automated->getFilter( 'hidebots' ); |
310 | $hideBots->setDefault( $this->userOptionsLookup->getBoolOption( $user, 'watchlisthidebots' ) ); |
311 | |
312 | $registration = $this->getFilterGroup( 'registration' ); |
313 | $hideAnons = $registration->getFilter( 'hideanons' ); |
314 | $hideAnons->setDefault( $this->userOptionsLookup->getBoolOption( $user, 'watchlisthideanons' ) ); |
315 | $hideLiu = $registration->getFilter( 'hideliu' ); |
316 | $hideLiu->setDefault( $this->userOptionsLookup->getBoolOption( $user, 'watchlisthideliu' ) ); |
317 | |
318 | // Selecting both hideanons and hideliu on watchlist preferences |
319 | // gives mutually exclusive filters, so those are ignored |
320 | if ( $this->userOptionsLookup->getBoolOption( $user, 'watchlisthideanons' ) && |
321 | !$this->userOptionsLookup->getBoolOption( $user, 'watchlisthideliu' ) |
322 | ) { |
323 | $this->getFilterGroup( 'userExpLevel' ) |
324 | ->setDefault( 'registered' ); |
325 | } |
326 | |
327 | if ( $this->userOptionsLookup->getBoolOption( $user, 'watchlisthideliu' ) && |
328 | !$this->userOptionsLookup->getBoolOption( $user, 'watchlisthideanons' ) |
329 | ) { |
330 | $this->getFilterGroup( 'userExpLevel' ) |
331 | ->setDefault( 'unregistered' ); |
332 | } |
333 | |
334 | $reviewStatus = $this->getFilterGroup( 'reviewStatus' ); |
335 | if ( $reviewStatus !== null ) { |
336 | // Conditional on feature being available and rights |
337 | if ( $this->userOptionsLookup->getBoolOption( $user, 'watchlisthidepatrolled' ) ) { |
338 | $reviewStatus->setDefault( 'unpatrolled' ); |
339 | $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' ); |
340 | $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' ); |
341 | $legacyHidePatrolled->setDefault( true ); |
342 | } |
343 | } |
344 | |
345 | $authorship = $this->getFilterGroup( 'authorship' ); |
346 | $hideMyself = $authorship->getFilter( 'hidemyself' ); |
347 | $hideMyself->setDefault( $this->userOptionsLookup->getBoolOption( $user, 'watchlisthideown' ) ); |
348 | |
349 | $changeType = $this->getFilterGroup( 'changeType' ); |
350 | $hideCategorization = $changeType->getFilter( 'hidecategorization' ); |
351 | if ( $hideCategorization !== null ) { |
352 | // Conditional on feature being available |
353 | $hideCategorization->setDefault( |
354 | $this->userOptionsLookup->getBoolOption( $user, 'watchlisthidecategorization' ) |
355 | ); |
356 | } |
357 | } |
358 | |
359 | /** |
360 | * Fetch values for a FormOptions object from the WebRequest associated with this instance. |
361 | * |
362 | * Maps old pre-1.23 request parameters Watchlist used to use (different from Recentchanges' ones) |
363 | * to the current ones. |
364 | * |
365 | * @param FormOptions $opts |
366 | * @return FormOptions |
367 | */ |
368 | protected function fetchOptionsFromRequest( $opts ) { |
369 | static $compatibilityMap = [ |
370 | 'hideMinor' => 'hideminor', |
371 | 'hideBots' => 'hidebots', |
372 | 'hideAnons' => 'hideanons', |
373 | 'hideLiu' => 'hideliu', |
374 | 'hidePatrolled' => 'hidepatrolled', |
375 | 'hideOwn' => 'hidemyself', |
376 | ]; |
377 | |
378 | $params = $this->getRequest()->getValues(); |
379 | foreach ( $compatibilityMap as $from => $to ) { |
380 | if ( isset( $params[$from] ) ) { |
381 | $params[$to] = $params[$from]; |
382 | unset( $params[$from] ); |
383 | } |
384 | } |
385 | |
386 | if ( $this->getRequest()->getRawVal( 'action' ) == 'submit' ) { |
387 | $allBooleansFalse = []; |
388 | |
389 | // If the user submitted the form, start with a baseline of "all |
390 | // booleans are false", then change the ones they checked. This |
391 | // means we ignore the defaults. |
392 | |
393 | // This is how we handle the fact that HTML forms don't submit |
394 | // unchecked boxes. |
395 | foreach ( $this->getLegacyShowHideFilters() as $filter ) { |
396 | $allBooleansFalse[ $filter->getName() ] = false; |
397 | } |
398 | |
399 | $params += $allBooleansFalse; |
400 | } |
401 | |
402 | // Not the prettiest way to achieve this… FormOptions internally depends on data sanitization |
403 | // methods defined on WebRequest and removing this dependency would cause some code duplication. |
404 | $request = new DerivativeRequest( $this->getRequest(), $params ); |
405 | $opts->fetchValuesFromRequest( $request ); |
406 | |
407 | return $opts; |
408 | } |
409 | |
410 | /** |
411 | * @inheritDoc |
412 | */ |
413 | protected function doMainQuery( $tables, $fields, $conds, $query_options, |
414 | $join_conds, FormOptions $opts |
415 | ) { |
416 | $dbr = $this->getDB(); |
417 | $user = $this->getUser(); |
418 | |
419 | $rcQuery = RecentChange::getQueryInfo(); |
420 | $tables = array_merge( $rcQuery['tables'], $tables, [ 'watchlist' ] ); |
421 | $fields = array_merge( $rcQuery['fields'], $fields ); |
422 | |
423 | $join_conds = array_merge( |
424 | [ |
425 | 'watchlist' => [ |
426 | 'JOIN', |
427 | [ |
428 | 'wl_user' => $user->getId(), |
429 | 'wl_namespace=rc_namespace', |
430 | 'wl_title=rc_title' |
431 | ], |
432 | ], |
433 | ], |
434 | $rcQuery['joins'], |
435 | $join_conds |
436 | ); |
437 | |
438 | if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) ) { |
439 | $tables[] = 'watchlist_expiry'; |
440 | $fields[] = 'we_expiry'; |
441 | $join_conds['watchlist_expiry'] = [ 'LEFT JOIN', 'wl_id = we_item' ]; |
442 | $conds[] = $dbr->expr( 'we_expiry', '=', null )->or( 'we_expiry', '>', $dbr->timestamp() ); |
443 | } |
444 | |
445 | $tables[] = 'page'; |
446 | $fields[] = 'page_latest'; |
447 | $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ]; |
448 | |
449 | $fields[] = 'wl_notificationtimestamp'; |
450 | |
451 | // Log entries with DELETED_ACTION must not show up unless the user has |
452 | // the necessary rights. |
453 | $authority = $this->getAuthority(); |
454 | if ( !$authority->isAllowed( 'deletedhistory' ) ) { |
455 | $bitmask = LogPage::DELETED_ACTION; |
456 | } elseif ( !$authority->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { |
457 | $bitmask = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED; |
458 | } else { |
459 | $bitmask = 0; |
460 | } |
461 | if ( $bitmask ) { |
462 | $conds[] = $dbr->makeList( [ |
463 | 'rc_type != ' . RC_LOG, |
464 | $dbr->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask", |
465 | ], LIST_OR ); |
466 | } |
467 | |
468 | $tagFilter = $opts['tagfilter'] !== '' ? explode( '|', $opts['tagfilter'] ) : []; |
469 | $this->changeTagsStore->modifyDisplayQuery( |
470 | $tables, |
471 | $fields, |
472 | $conds, |
473 | $join_conds, |
474 | $query_options, |
475 | $tagFilter, |
476 | $opts['inverttags'] |
477 | ); |
478 | |
479 | $this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds, $opts ); |
480 | |
481 | if ( $this->areFiltersInConflict() ) { |
482 | return false; |
483 | } |
484 | |
485 | $orderByAndLimit = [ |
486 | 'ORDER BY' => 'rc_timestamp DESC', |
487 | 'LIMIT' => $opts['limit'] |
488 | ]; |
489 | if ( in_array( 'DISTINCT', $query_options ) ) { |
490 | // ChangeTagsStore::modifyDisplayQuery() adds DISTINCT when filtering on multiple tags. |
491 | // In order to prevent DISTINCT from causing query performance problems, |
492 | // we have to GROUP BY the primary key. This in turn requires us to add |
493 | // the primary key to the end of the ORDER BY, and the old ORDER BY to the |
494 | // start of the GROUP BY |
495 | $orderByAndLimit['ORDER BY'] = 'rc_timestamp DESC, rc_id DESC'; |
496 | $orderByAndLimit['GROUP BY'] = 'rc_timestamp, rc_id'; |
497 | } |
498 | // array_merge() is used intentionally here so that hooks can, should |
499 | // they so desire, override the ORDER BY / LIMIT condition(s) |
500 | $query_options = array_merge( $orderByAndLimit, $query_options ); |
501 | $query_options['MAX_EXECUTION_TIME'] = |
502 | $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries ); |
503 | $queryBuilder = $dbr->newSelectQueryBuilder() |
504 | ->tables( $tables ) |
505 | ->conds( $conds ) |
506 | ->fields( $fields ) |
507 | ->options( $query_options ) |
508 | ->joinConds( $join_conds ) |
509 | ->caller( __METHOD__ ); |
510 | |
511 | return $queryBuilder->fetchResultSet(); |
512 | } |
513 | |
514 | public function outputFeedLinks() { |
515 | $user = $this->getUser(); |
516 | $wlToken = $user->getTokenFromOption( 'watchlisttoken' ); |
517 | if ( $wlToken ) { |
518 | $this->addFeedLinks( [ |
519 | 'action' => 'feedwatchlist', |
520 | 'allrev' => 1, |
521 | 'wlowner' => $user->getName(), |
522 | 'wltoken' => $wlToken, |
523 | ] ); |
524 | } |
525 | } |
526 | |
527 | /** |
528 | * Build and output the actual changes list. |
529 | * |
530 | * @param IResultWrapper $rows Database rows |
531 | * @param FormOptions $opts |
532 | */ |
533 | public function outputChangesList( $rows, $opts ) { |
534 | $dbr = $this->getDB(); |
535 | $user = $this->getUser(); |
536 | $output = $this->getOutput(); |
537 | |
538 | // Show a message about replica DB lag, if applicable |
539 | $lag = $dbr->getSessionLagStatus()['lag']; |
540 | if ( $lag > 0 ) { |
541 | $output->showLagWarning( $lag ); |
542 | } |
543 | |
544 | // If there are no rows to display, show message before trying to render the list |
545 | if ( $rows->numRows() == 0 ) { |
546 | $output->wrapWikiMsg( |
547 | "<div class='mw-changeslist-empty'>\n$1\n</div>", 'recentchanges-noresult' |
548 | ); |
549 | return; |
550 | } |
551 | |
552 | $rows->seek( 0 ); |
553 | |
554 | $list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups ); |
555 | $list->setWatchlistDivs(); |
556 | $list->initChangesListRows( $rows ); |
557 | |
558 | if ( $this->userOptionsLookup->getBoolOption( $user, 'watchlistunwatchlinks' ) ) { |
559 | $list->setChangeLinePrefixer( function ( RecentChange $rc, ChangesList $cl, $grouped ) { |
560 | $unwatch = $this->msg( 'watchlist-unwatch' )->text(); |
561 | // Don't show unwatch link if the line is a grouped log entry using EnhancedChangesList, |
562 | // since EnhancedChangesList groups log entries by performer rather than by target article |
563 | if ( $rc->mAttribs['rc_type'] == RC_LOG && $cl instanceof EnhancedChangesList && |
564 | $grouped ) { |
565 | return "<span style='visibility:hidden'>$unwatch</span>\u{00A0}"; |
566 | } else { |
567 | $unwatchTooltipMessage = 'tooltip-ca-unwatch'; |
568 | $diffInDays = null; |
569 | // Check if the watchlist expiry flag is enabled to show new tooltip message |
570 | if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) ) { |
571 | $watchedItem = $this->watchedItemStore->getWatchedItem( $this->getUser(), $rc->getTitle() ); |
572 | if ( $watchedItem instanceof WatchedItem && $watchedItem->getExpiry() !== null ) { |
573 | $diffInDays = $watchedItem->getExpiryInDays(); |
574 | |
575 | if ( $diffInDays > 0 ) { |
576 | $unwatchTooltipMessage = 'tooltip-ca-unwatch-expiring'; |
577 | } else { |
578 | $unwatchTooltipMessage = 'tooltip-ca-unwatch-expiring-hours'; |
579 | } |
580 | } |
581 | } |
582 | |
583 | return $this->getLinkRenderer() |
584 | ->makeKnownLink( $rc->getTitle(), |
585 | $unwatch, [ |
586 | 'class' => 'mw-unwatch-link', |
587 | 'title' => $this->msg( $unwatchTooltipMessage, [ $diffInDays ] )->text() |
588 | ], [ 'action' => 'unwatch' ] ) . "\u{00A0}"; |
589 | } |
590 | } ); |
591 | } |
592 | $rows->seek( 0 ); |
593 | |
594 | $s = $list->beginRecentChangesList(); |
595 | |
596 | if ( $this->isStructuredFilterUiEnabled() ) { |
597 | $s .= $this->makeLegend(); |
598 | } |
599 | |
600 | $userShowHiddenCats = $this->userOptionsLookup->getBoolOption( $user, 'showhiddencats' ); |
601 | $counter = 1; |
602 | foreach ( $rows as $obj ) { |
603 | // Make RC entry |
604 | $rc = RecentChange::newFromRow( $obj ); |
605 | |
606 | // Skip CatWatch entries for hidden cats based on user preference |
607 | if ( |
608 | $rc->getAttribute( 'rc_type' ) == RC_CATEGORIZE && |
609 | !$userShowHiddenCats && |
610 | $rc->getParam( 'hidden-cat' ) |
611 | ) { |
612 | continue; |
613 | } |
614 | |
615 | $rc->counter = $counter++; |
616 | |
617 | if ( $this->getConfig()->get( MainConfigNames::ShowUpdatedMarker ) ) { |
618 | $unseen = !$this->isChangeEffectivelySeen( $rc ); |
619 | } else { |
620 | $unseen = false; |
621 | } |
622 | |
623 | if ( $this->getConfig()->get( MainConfigNames::RCShowWatchingUsers ) |
624 | && $this->userOptionsLookup->getBoolOption( $user, 'shownumberswatching' ) |
625 | ) { |
626 | $rcTitleValue = new TitleValue( (int)$obj->rc_namespace, $obj->rc_title ); |
627 | $rc->numberofWatchingusers = $this->watchedItemStore->countWatchers( $rcTitleValue ); |
628 | } else { |
629 | $rc->numberofWatchingusers = 0; |
630 | } |
631 | |
632 | // XXX: this treats pages with no unseen changes as "not on the watchlist" since |
633 | // everything is on the watchlist and it is an easy way to make pages with unseen |
634 | // changes appear bold. @TODO: clean this up. |
635 | $changeLine = $list->recentChangesLine( $rc, $unseen, $counter ); |
636 | if ( $changeLine !== false ) { |
637 | $s .= $changeLine; |
638 | } |
639 | } |
640 | $s .= $list->endRecentChangesList(); |
641 | |
642 | $output->addHTML( $s ); |
643 | } |
644 | |
645 | /** |
646 | * @inheritDoc |
647 | */ |
648 | public function getAssociatedNavigationLinks() { |
649 | return self::WATCHLIST_TAB_PATHS; |
650 | } |
651 | |
652 | /** |
653 | * @param SpecialPage $specialPage |
654 | * @param string $path |
655 | * @return string |
656 | */ |
657 | public static function getShortDescriptionHelper( SpecialPage $specialPage, string $path = '' ): string { |
658 | switch ( $path ) { |
659 | case 'Watchlist': |
660 | return $specialPage->msg( 'watchlisttools-view' )->text(); |
661 | case 'EditWatchlist': |
662 | return $specialPage->msg( 'watchlisttools-edit' )->text(); |
663 | case 'EditWatchlist/raw': |
664 | return $specialPage->msg( 'watchlisttools-raw' )->text(); |
665 | case 'EditWatchlist/clear': |
666 | return $specialPage->msg( 'watchlisttools-clear' )->text(); |
667 | default: |
668 | return $path; |
669 | } |
670 | } |
671 | |
672 | /** |
673 | * @inheritDoc |
674 | */ |
675 | public function getShortDescription( string $path = '' ): string { |
676 | return self::getShortDescriptionHelper( $this, $path ); |
677 | } |
678 | |
679 | /** |
680 | * Set the text to be displayed above the changes |
681 | * |
682 | * @param FormOptions $opts |
683 | * @param int $numRows Number of rows in the result to show after this header |
684 | */ |
685 | public function doHeader( $opts, $numRows ) { |
686 | $user = $this->getUser(); |
687 | $out = $this->getOutput(); |
688 | $skin = $this->getSkin(); |
689 | // For legacy skins render the tabs in the subtitle |
690 | $subpageSubtitle = $skin->supportsMenu( 'associated-pages' ) ? '' : |
691 | ' ' . |
692 | SpecialEditWatchlist::buildTools( |
693 | null, |
694 | $this->getLinkRenderer(), |
695 | $this->currentMode |
696 | ); |
697 | |
698 | $out->addSubtitle( |
699 | Html::element( |
700 | 'span', |
701 | [ |
702 | 'class' => 'mw-watchlist-owner' |
703 | ], |
704 | // Previously the watchlistfor2 message took 2 parameters. |
705 | // It now only takes 1 so empty string is passed. |
706 | // Empty string parameter can be removed when all messages |
707 | // are updated to not use $2 |
708 | $this->msg( 'watchlistfor2', $this->getUser()->getName(), '' )->text() |
709 | ) . $subpageSubtitle |
710 | ); |
711 | |
712 | $this->setTopText( $opts ); |
713 | |
714 | $form = ''; |
715 | |
716 | $form .= Xml::openElement( 'form', [ |
717 | 'method' => 'get', |
718 | 'action' => wfScript(), |
719 | 'id' => 'mw-watchlist-form' |
720 | ] ); |
721 | $form .= Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ); |
722 | $form .= Xml::openElement( |
723 | 'fieldset', |
724 | [ 'id' => 'mw-watchlist-options', 'class' => 'cloptions' ] |
725 | ); |
726 | $form .= Xml::element( |
727 | 'legend', null, $this->msg( 'watchlist-options' )->text() |
728 | ); |
729 | |
730 | if ( !$this->isStructuredFilterUiEnabled() ) { |
731 | $form .= $this->makeLegend(); |
732 | } |
733 | |
734 | $lang = $this->getLanguage(); |
735 | $timestamp = wfTimestampNow(); |
736 | $now = $lang->userTimeAndDate( $timestamp, $user ); |
737 | $wlInfo = Html::rawElement( |
738 | 'span', |
739 | [ |
740 | 'class' => 'wlinfo', |
741 | 'data-params' => json_encode( [ 'from' => $timestamp, 'fromFormatted' => $now ] ), |
742 | ], |
743 | $this->msg( 'wlnote' )->numParams( $numRows, round( $opts['days'] * 24 ) )->params( |
744 | $lang->userDate( $timestamp, $user ), $lang->userTime( $timestamp, $user ) |
745 | )->parse() |
746 | ) . "<br />\n"; |
747 | |
748 | $nondefaults = $opts->getChangedValues(); |
749 | $cutofflinks = Html::rawElement( |
750 | 'span', |
751 | [ 'class' => 'cldays cloption' ], |
752 | $this->msg( 'wlshowtime' ) . ' ' . $this->cutoffselector( $opts ) |
753 | ); |
754 | |
755 | // Spit out some control panel links |
756 | $links = []; |
757 | $namesOfDisplayedFilters = []; |
758 | foreach ( $this->getLegacyShowHideFilters() as $filterName => $filter ) { |
759 | $namesOfDisplayedFilters[] = $filterName; |
760 | $links[] = $this->showHideCheck( |
761 | $nondefaults, |
762 | $filter->getShowHide(), |
763 | $filterName, |
764 | $opts[ $filterName ], |
765 | $filter->isFeatureAvailableOnStructuredUi() |
766 | ); |
767 | } |
768 | |
769 | $hiddenFields = $nondefaults; |
770 | $hiddenFields['action'] = 'submit'; |
771 | unset( $hiddenFields['namespace'] ); |
772 | unset( $hiddenFields['invert'] ); |
773 | unset( $hiddenFields['associated'] ); |
774 | unset( $hiddenFields['days'] ); |
775 | foreach ( $namesOfDisplayedFilters as $filterName ) { |
776 | unset( $hiddenFields[$filterName] ); |
777 | } |
778 | |
779 | // Namespace filter and put the whole form together. |
780 | $form .= $wlInfo; |
781 | $form .= $cutofflinks; |
782 | $form .= Html::rawElement( |
783 | 'span', |
784 | [ 'class' => 'clshowhide' ], |
785 | $this->msg( 'watchlist-hide' ) . |
786 | $this->msg( 'colon-separator' )->escaped() . |
787 | implode( ' ', $links ) |
788 | ); |
789 | $form .= "\n<br />\n"; |
790 | |
791 | $namespaceForm = Html::namespaceSelector( |
792 | [ |
793 | 'selected' => $opts['namespace'], |
794 | 'all' => '', |
795 | 'label' => $this->msg( 'namespace' )->text(), |
796 | 'in-user-lang' => true, |
797 | ], [ |
798 | 'name' => 'namespace', |
799 | 'id' => 'namespace', |
800 | 'class' => 'namespaceselector', |
801 | ] |
802 | ) . "\n"; |
803 | $namespaceForm .= Html::rawElement( 'label', [ |
804 | 'class' => 'mw-input-with-label', 'title' => $this->msg( 'tooltip-invert' )->text(), |
805 | ], Html::element( 'input', [ |
806 | 'type' => 'checkbox', 'name' => 'invert', 'value' => '1', 'checked' => $opts['invert'], |
807 | ] ) . ' ' . $this->msg( 'invert' )->escaped() ) . "\n"; |
808 | $namespaceForm .= Html::rawElement( 'label', [ |
809 | 'class' => 'mw-input-with-label', 'title' => $this->msg( 'tooltip-namespace_association' )->text(), |
810 | ], Html::element( 'input', [ |
811 | 'type' => 'checkbox', 'name' => 'associated', 'value' => '1', 'checked' => $opts['associated'], |
812 | ] ) . ' ' . $this->msg( 'namespace_association' )->escaped() ) . "\n"; |
813 | $form .= Html::rawElement( |
814 | 'span', |
815 | [ 'class' => 'namespaceForm cloption' ], |
816 | $namespaceForm |
817 | ); |
818 | |
819 | $form .= Xml::submitButton( |
820 | $this->msg( 'watchlist-submit' )->text(), |
821 | [ 'class' => 'cloption-submit' ] |
822 | ) . "\n"; |
823 | foreach ( $hiddenFields as $key => $value ) { |
824 | $form .= Html::hidden( $key, $value ) . "\n"; |
825 | } |
826 | $form .= Xml::closeElement( 'fieldset' ) . "\n"; |
827 | $form .= Xml::closeElement( 'form' ) . "\n"; |
828 | |
829 | // Insert a placeholder for RCFilters |
830 | if ( $this->isStructuredFilterUiEnabled() ) { |
831 | $rcfilterContainer = Html::element( |
832 | 'div', |
833 | [ 'class' => 'mw-rcfilters-container' ] |
834 | ); |
835 | |
836 | $loadingContainer = Html::rawElement( |
837 | 'div', |
838 | [ 'class' => 'mw-rcfilters-spinner' ], |
839 | Html::element( |
840 | 'div', |
841 | [ 'class' => 'mw-rcfilters-spinner-bounce' ] |
842 | ) |
843 | ); |
844 | |
845 | // Wrap both with mw-rcfilters-head |
846 | $this->getOutput()->addHTML( |
847 | Html::rawElement( |
848 | 'div', |
849 | [ 'class' => 'mw-rcfilters-head' ], |
850 | $rcfilterContainer . $form |
851 | ) |
852 | ); |
853 | |
854 | // Add spinner |
855 | $this->getOutput()->addHTML( $loadingContainer ); |
856 | } else { |
857 | $this->getOutput()->addHTML( $form ); |
858 | } |
859 | |
860 | $this->setBottomText( $opts ); |
861 | } |
862 | |
863 | private function cutoffselector( $options ) { |
864 | $selected = (float)$options['days']; |
865 | $maxDays = $this->getConfig()->get( MainConfigNames::RCMaxAge ) / ( 3600 * 24 ); |
866 | if ( $selected <= 0 ) { |
867 | $selected = $maxDays; |
868 | } |
869 | |
870 | $selectedHours = round( $selected * 24 ); |
871 | |
872 | $hours = array_unique( array_filter( [ |
873 | 1, |
874 | 2, |
875 | 6, |
876 | 12, |
877 | 24, |
878 | 72, |
879 | 168, |
880 | 24 * (float)$this->userOptionsLookup->getOption( $this->getUser(), 'watchlistdays', 0 ), |
881 | 24 * $maxDays, |
882 | $selectedHours |
883 | ] ) ); |
884 | asort( $hours ); |
885 | |
886 | $select = new XmlSelect( 'days', 'days', $selectedHours / 24 ); |
887 | |
888 | foreach ( $hours as $value ) { |
889 | if ( $value < 24 ) { |
890 | $name = $this->msg( 'hours' )->numParams( $value )->text(); |
891 | } else { |
892 | $name = $this->msg( 'days' )->numParams( $value / 24 )->text(); |
893 | } |
894 | $select->addOption( $name, (float)( $value / 24 ) ); |
895 | } |
896 | |
897 | return $select->getHTML() . "\n<br />\n"; |
898 | } |
899 | |
900 | public function setTopText( FormOptions $opts ) { |
901 | $nondefaults = $opts->getChangedValues(); |
902 | $form = ''; |
903 | $user = $this->getUser(); |
904 | |
905 | $numItems = $this->countItems(); |
906 | $showUpdatedMarker = $this->getConfig()->get( MainConfigNames::ShowUpdatedMarker ); |
907 | |
908 | // Show watchlist header |
909 | $watchlistHeader = ''; |
910 | if ( $numItems == 0 ) { |
911 | $watchlistHeader = $this->msg( 'nowatchlist' )->parse(); |
912 | } else { |
913 | $watchlistHeader .= $this->msg( 'watchlist-details' )->numParams( $numItems )->parse() |
914 | . $this->msg( 'word-separator' )->escaped(); |
915 | if ( $this->getConfig()->get( MainConfigNames::EnotifWatchlist ) |
916 | && $this->userOptionsLookup->getBoolOption( $user, 'enotifwatchlistpages' ) |
917 | ) { |
918 | $watchlistHeader .= $this->msg( 'wlheader-enotif' )->parse() |
919 | . $this->msg( 'word-separator' )->escaped(); |
920 | } |
921 | if ( $showUpdatedMarker ) { |
922 | $watchlistHeader .= $this->msg( |
923 | $this->isStructuredFilterUiEnabled() ? |
924 | 'rcfilters-watchlist-showupdated' : |
925 | 'wlheader-showupdated' |
926 | )->parse() . $this->msg( 'word-separator' )->escaped(); |
927 | } |
928 | } |
929 | $form .= Html::rawElement( |
930 | 'div', |
931 | [ 'class' => 'watchlistDetails' ], |
932 | $watchlistHeader |
933 | ); |
934 | |
935 | if ( $numItems > 0 && $showUpdatedMarker ) { |
936 | $form .= Xml::openElement( 'form', [ 'method' => 'post', |
937 | 'action' => $this->getPageTitle()->getLocalURL(), |
938 | 'id' => 'mw-watchlist-resetbutton' ] ) . "\n" . |
939 | Xml::submitButton( $this->msg( 'enotif_reset' )->text(), |
940 | [ 'name' => 'mw-watchlist-reset-submit' ] ) . "\n" . |
941 | Html::hidden( 'token', $user->getEditToken() ) . "\n" . |
942 | Html::hidden( 'reset', 'all' ) . "\n"; |
943 | foreach ( $nondefaults as $key => $value ) { |
944 | $form .= Html::hidden( $key, $value ) . "\n"; |
945 | } |
946 | $form .= Xml::closeElement( 'form' ) . "\n"; |
947 | } |
948 | |
949 | $this->getOutput()->addHTML( $form ); |
950 | } |
951 | |
952 | protected function showHideCheck( $options, $message, $name, $value, $inStructuredUi ) { |
953 | $options[$name] = 1 - (int)$value; |
954 | |
955 | $attribs = [ 'class' => 'mw-input-with-label clshowhideoption cloption' ]; |
956 | if ( $inStructuredUi ) { |
957 | $attribs[ 'data-feature-in-structured-ui' ] = true; |
958 | } |
959 | |
960 | return Html::rawElement( |
961 | 'span', |
962 | $attribs, |
963 | // not using Html::label because that would escape the contents |
964 | Html::check( $name, (bool)$value, [ 'id' => $name ] ) . "\n" . Html::rawElement( |
965 | 'label', |
966 | $attribs + [ 'for' => $name ], |
967 | // <nowiki/> at beginning to avoid messages with "$1 ..." being parsed as pre tags |
968 | $this->msg( $message, '<nowiki/>' )->parse() |
969 | ) |
970 | ); |
971 | } |
972 | |
973 | /** |
974 | * Count the number of paired items on a user's watchlist. |
975 | * The assumption made here is that when a subject page is watched a talk page is also watched. |
976 | * Hence the number of individual items is halved. |
977 | * |
978 | * @return int |
979 | */ |
980 | protected function countItems() { |
981 | $count = $this->watchedItemStore->countWatchedItems( $this->getUser() ); |
982 | return (int)floor( $count / 2 ); |
983 | } |
984 | |
985 | /** |
986 | * @param RecentChange $rc |
987 | * @return bool User viewed the revision or a newer one |
988 | */ |
989 | protected function isChangeEffectivelySeen( RecentChange $rc ) { |
990 | $firstUnseen = $this->getLatestNotificationTimestamp( $rc ); |
991 | |
992 | return ( $firstUnseen === null || $firstUnseen > $rc->getAttribute( 'rc_timestamp' ) ); |
993 | } |
994 | |
995 | /** |
996 | * @param RecentChange $rc |
997 | * @return string|null TS_MW timestamp of first unseen revision or null if there isn't one |
998 | */ |
999 | private function getLatestNotificationTimestamp( RecentChange $rc ) { |
1000 | return $this->watchedItemStore->getLatestNotificationTimestamp( |
1001 | $rc->getAttribute( 'wl_notificationtimestamp' ), |
1002 | $this->getUser(), |
1003 | $rc->getTitle() |
1004 | ); |
1005 | } |
1006 | |
1007 | /** |
1008 | * @return string |
1009 | */ |
1010 | protected function getLimitPreferenceName(): string { |
1011 | return 'wllimit'; |
1012 | } |
1013 | |
1014 | /** |
1015 | * @return string |
1016 | */ |
1017 | protected function getSavedQueriesPreferenceName(): string { |
1018 | return 'rcfilters-wl-saved-queries'; |
1019 | } |
1020 | |
1021 | /** |
1022 | * @return string |
1023 | */ |
1024 | protected function getDefaultDaysPreferenceName(): string { |
1025 | return 'watchlistdays'; |
1026 | } |
1027 | |
1028 | /** |
1029 | * @return string |
1030 | */ |
1031 | protected function getCollapsedPreferenceName(): string { |
1032 | return 'rcfilters-wl-collapsed'; |
1033 | } |
1034 | |
1035 | } |
1036 | |
1037 | /** |
1038 | * Retain the old class name for backwards compatibility. |
1039 | * @deprecated since 1.41 |
1040 | */ |
1041 | class_alias( SpecialWatchlist::class, 'SpecialWatchlist' ); |