90d45ce49c22c9444e6c930662432006911c4df8
[lhc/web/wiklou.git] / includes / WatchedItemStore.php
1 <?php
2
3 use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
4 use MediaWiki\Linker\LinkTarget;
5 use Wikimedia\Assert\Assert;
6
7 /**
8 * Storage layer class for WatchedItems.
9 * Database interaction.
10 *
11 * @author Addshore
12 *
13 * @since 1.27
14 */
15 class WatchedItemStore implements StatsdAwareInterface {
16
17 const SORT_DESC = 'DESC';
18 const SORT_ASC = 'ASC';
19
20 /**
21 * @var LoadBalancer
22 */
23 private $loadBalancer;
24
25 /**
26 * @var HashBagOStuff
27 */
28 private $cache;
29
30 /**
31 * @var array[] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
32 * The index is needed so that on mass changes all relevant items can be un-cached.
33 * For example: Clearing a users watchlist of all items or updating notification timestamps
34 * for all users watching a single target.
35 */
36 private $cacheIndex = [];
37
38 /**
39 * @var callable|null
40 */
41 private $deferredUpdatesAddCallableUpdateCallback;
42
43 /**
44 * @var callable|null
45 */
46 private $revisionGetTimestampFromIdCallback;
47
48 /**
49 * @var StatsdDataFactoryInterface
50 */
51 private $stats;
52
53 /**
54 * @param LoadBalancer $loadBalancer
55 * @param HashBagOStuff $cache
56 */
57 public function __construct(
58 LoadBalancer $loadBalancer,
59 HashBagOStuff $cache
60 ) {
61 $this->loadBalancer = $loadBalancer;
62 $this->cache = $cache;
63 $this->stats = new NullStatsdDataFactory();
64 $this->deferredUpdatesAddCallableUpdateCallback = [ 'DeferredUpdates', 'addCallableUpdate' ];
65 $this->revisionGetTimestampFromIdCallback = [ 'Revision', 'getTimestampFromId' ];
66 }
67
68 public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
69 $this->stats = $stats;
70 }
71
72 /**
73 * Overrides the DeferredUpdates::addCallableUpdate callback
74 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
75 *
76 * @param callable $callback
77 *
78 * @see DeferredUpdates::addCallableUpdate for callback signiture
79 *
80 * @return ScopedCallback to reset the overridden value
81 * @throws MWException
82 */
83 public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
84 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
85 throw new MWException(
86 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
87 );
88 }
89 $previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
90 $this->deferredUpdatesAddCallableUpdateCallback = $callback;
91 return new ScopedCallback( function() use ( $previousValue ) {
92 $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
93 } );
94 }
95
96 /**
97 * Overrides the Revision::getTimestampFromId callback
98 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
99 *
100 * @param callable $callback
101 * @see Revision::getTimestampFromId for callback signiture
102 *
103 * @return ScopedCallback to reset the overridden value
104 * @throws MWException
105 */
106 public function overrideRevisionGetTimestampFromIdCallback( callable $callback ) {
107 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
108 throw new MWException(
109 'Cannot override Revision::getTimestampFromId callback in operation.'
110 );
111 }
112 $previousValue = $this->revisionGetTimestampFromIdCallback;
113 $this->revisionGetTimestampFromIdCallback = $callback;
114 return new ScopedCallback( function() use ( $previousValue ) {
115 $this->revisionGetTimestampFromIdCallback = $previousValue;
116 } );
117 }
118
119 private function getCacheKey( User $user, LinkTarget $target ) {
120 return $this->cache->makeKey(
121 (string)$target->getNamespace(),
122 $target->getDBkey(),
123 (string)$user->getId()
124 );
125 }
126
127 private function cache( WatchedItem $item ) {
128 $user = $item->getUser();
129 $target = $item->getLinkTarget();
130 $key = $this->getCacheKey( $user, $target );
131 $this->cache->set( $key, $item );
132 $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
133 $this->stats->increment( 'WatchedItemStore.cache' );
134 }
135
136 private function uncache( User $user, LinkTarget $target ) {
137 $this->cache->delete( $this->getCacheKey( $user, $target ) );
138 unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
139 $this->stats->increment( 'WatchedItemStore.uncache' );
140 }
141
142 private function uncacheLinkTarget( LinkTarget $target ) {
143 $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
144 if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
145 return;
146 }
147 foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
148 $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
149 $this->cache->delete( $key );
150 }
151 }
152
153 private function uncacheUser( User $user ) {
154 $this->stats->increment( 'WatchedItemStore.uncacheUser' );
155 foreach ( $this->cacheIndex as $ns => $dbKeyArray ) {
156 foreach ( $dbKeyArray as $dbKey => $userArray ) {
157 if ( isset( $userArray[$user->getId()] ) ) {
158 $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
159 $this->cache->delete( $userArray[$user->getId()] );
160 }
161 }
162 }
163 }
164
165 /**
166 * @param User $user
167 * @param LinkTarget $target
168 *
169 * @return WatchedItem|null
170 */
171 private function getCached( User $user, LinkTarget $target ) {
172 return $this->cache->get( $this->getCacheKey( $user, $target ) );
173 }
174
175 /**
176 * Return an array of conditions to select or update the appropriate database
177 * row.
178 *
179 * @param User $user
180 * @param LinkTarget $target
181 *
182 * @return array
183 */
184 private function dbCond( User $user, LinkTarget $target ) {
185 return [
186 'wl_user' => $user->getId(),
187 'wl_namespace' => $target->getNamespace(),
188 'wl_title' => $target->getDBkey(),
189 ];
190 }
191
192 /**
193 * @param int $dbIndex DB_MASTER or DB_REPLICA
194 *
195 * @return IDatabase
196 * @throws MWException
197 */
198 private function getConnectionRef( $dbIndex ) {
199 return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
200 }
201
202 /**
203 * Count the number of individual items that are watched by the user.
204 * If a subject and corresponding talk page are watched this will return 2.
205 *
206 * @param User $user
207 *
208 * @return int
209 */
210 public function countWatchedItems( User $user ) {
211 $dbr = $this->getConnectionRef( DB_REPLICA );
212 $return = (int)$dbr->selectField(
213 'watchlist',
214 'COUNT(*)',
215 [
216 'wl_user' => $user->getId()
217 ],
218 __METHOD__
219 );
220
221 return $return;
222 }
223
224 /**
225 * @param LinkTarget $target
226 *
227 * @return int
228 */
229 public function countWatchers( LinkTarget $target ) {
230 $dbr = $this->getConnectionRef( DB_REPLICA );
231 $return = (int)$dbr->selectField(
232 'watchlist',
233 'COUNT(*)',
234 [
235 'wl_namespace' => $target->getNamespace(),
236 'wl_title' => $target->getDBkey(),
237 ],
238 __METHOD__
239 );
240
241 return $return;
242 }
243
244 /**
245 * Number of page watchers who also visited a "recent" edit
246 *
247 * @param LinkTarget $target
248 * @param mixed $threshold timestamp accepted by wfTimestamp
249 *
250 * @return int
251 * @throws DBUnexpectedError
252 * @throws MWException
253 */
254 public function countVisitingWatchers( LinkTarget $target, $threshold ) {
255 $dbr = $this->getConnectionRef( DB_REPLICA );
256 $visitingWatchers = (int)$dbr->selectField(
257 'watchlist',
258 'COUNT(*)',
259 [
260 'wl_namespace' => $target->getNamespace(),
261 'wl_title' => $target->getDBkey(),
262 'wl_notificationtimestamp >= ' .
263 $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
264 ' OR wl_notificationtimestamp IS NULL'
265 ],
266 __METHOD__
267 );
268
269 return $visitingWatchers;
270 }
271
272 /**
273 * @param LinkTarget[] $targets
274 * @param array $options Allowed keys:
275 * 'minimumWatchers' => int
276 *
277 * @return array multi dimensional like $return[$namespaceId][$titleString] = int $watchers
278 * All targets will be present in the result. 0 either means no watchers or the number
279 * of watchers was below the minimumWatchers option if passed.
280 */
281 public function countWatchersMultiple( array $targets, array $options = [] ) {
282 $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
283
284 $dbr = $this->getConnectionRef( DB_REPLICA );
285
286 if ( array_key_exists( 'minimumWatchers', $options ) ) {
287 $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
288 }
289
290 $lb = new LinkBatch( $targets );
291 $res = $dbr->select(
292 'watchlist',
293 [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
294 [ $lb->constructSet( 'wl', $dbr ) ],
295 __METHOD__,
296 $dbOptions
297 );
298
299 $watchCounts = [];
300 foreach ( $targets as $linkTarget ) {
301 $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
302 }
303
304 foreach ( $res as $row ) {
305 $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
306 }
307
308 return $watchCounts;
309 }
310
311 /**
312 * Number of watchers of each page who have visited recent edits to that page
313 *
314 * @param array $targetsWithVisitThresholds array of pairs (LinkTarget $target, mixed $threshold),
315 * $threshold is:
316 * - a timestamp of the recent edit if $target exists (format accepted by wfTimestamp)
317 * - null if $target doesn't exist
318 * @param int|null $minimumWatchers
319 * @return array multi-dimensional like $return[$namespaceId][$titleString] = $watchers,
320 * where $watchers is an int:
321 * - if the page exists, number of users watching who have visited the page recently
322 * - if the page doesn't exist, number of users that have the page on their watchlist
323 * - 0 means there are no visiting watchers or their number is below the minimumWatchers
324 * option (if passed).
325 */
326 public function countVisitingWatchersMultiple(
327 array $targetsWithVisitThresholds,
328 $minimumWatchers = null
329 ) {
330 $dbr = $this->getConnectionRef( DB_REPLICA );
331
332 $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
333
334 $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
335 if ( $minimumWatchers !== null ) {
336 $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
337 }
338 $res = $dbr->select(
339 'watchlist',
340 [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
341 $conds,
342 __METHOD__,
343 $dbOptions
344 );
345
346 $watcherCounts = [];
347 foreach ( $targetsWithVisitThresholds as list( $target ) ) {
348 /* @var LinkTarget $target */
349 $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
350 }
351
352 foreach ( $res as $row ) {
353 $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
354 }
355
356 return $watcherCounts;
357 }
358
359 /**
360 * Generates condition for the query used in a batch count visiting watchers.
361 *
362 * @param IDatabase $db
363 * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
364 * @return string
365 */
366 private function getVisitingWatchersCondition(
367 IDatabase $db,
368 array $targetsWithVisitThresholds
369 ) {
370 $missingTargets = [];
371 $namespaceConds = [];
372 foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
373 if ( $threshold === null ) {
374 $missingTargets[] = $target;
375 continue;
376 }
377 /* @var LinkTarget $target */
378 $namespaceConds[$target->getNamespace()][] = $db->makeList( [
379 'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
380 $db->makeList( [
381 'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
382 'wl_notificationtimestamp IS NULL'
383 ], LIST_OR )
384 ], LIST_AND );
385 }
386
387 $conds = [];
388 foreach ( $namespaceConds as $namespace => $pageConds ) {
389 $conds[] = $db->makeList( [
390 'wl_namespace = ' . $namespace,
391 '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
392 ], LIST_AND );
393 }
394
395 if ( $missingTargets ) {
396 $lb = new LinkBatch( $missingTargets );
397 $conds[] = $lb->constructSet( 'wl', $db );
398 }
399
400 return $db->makeList( $conds, LIST_OR );
401 }
402
403 /**
404 * Get an item (may be cached)
405 *
406 * @param User $user
407 * @param LinkTarget $target
408 *
409 * @return WatchedItem|false
410 */
411 public function getWatchedItem( User $user, LinkTarget $target ) {
412 if ( $user->isAnon() ) {
413 return false;
414 }
415
416 $cached = $this->getCached( $user, $target );
417 if ( $cached ) {
418 $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
419 return $cached;
420 }
421 $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
422 return $this->loadWatchedItem( $user, $target );
423 }
424
425 /**
426 * Loads an item from the db
427 *
428 * @param User $user
429 * @param LinkTarget $target
430 *
431 * @return WatchedItem|false
432 */
433 public function loadWatchedItem( User $user, LinkTarget $target ) {
434 // Only loggedin user can have a watchlist
435 if ( $user->isAnon() ) {
436 return false;
437 }
438
439 $dbr = $this->getConnectionRef( DB_REPLICA );
440 $row = $dbr->selectRow(
441 'watchlist',
442 'wl_notificationtimestamp',
443 $this->dbCond( $user, $target ),
444 __METHOD__
445 );
446
447 if ( !$row ) {
448 return false;
449 }
450
451 $item = new WatchedItem(
452 $user,
453 $target,
454 $row->wl_notificationtimestamp
455 );
456 $this->cache( $item );
457
458 return $item;
459 }
460
461 /**
462 * @param User $user
463 * @param array $options Allowed keys:
464 * 'forWrite' => bool defaults to false
465 * 'sort' => string optional sorting by namespace ID and title
466 * one of the self::SORT_* constants
467 *
468 * @return WatchedItem[]
469 */
470 public function getWatchedItemsForUser( User $user, array $options = [] ) {
471 $options += [ 'forWrite' => false ];
472
473 $dbOptions = [];
474 if ( array_key_exists( 'sort', $options ) ) {
475 Assert::parameter(
476 ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
477 '$options[\'sort\']',
478 'must be SORT_ASC or SORT_DESC'
479 );
480 $dbOptions['ORDER BY'] = [
481 "wl_namespace {$options['sort']}",
482 "wl_title {$options['sort']}"
483 ];
484 }
485 $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA );
486
487 $res = $db->select(
488 'watchlist',
489 [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
490 [ 'wl_user' => $user->getId() ],
491 __METHOD__,
492 $dbOptions
493 );
494
495 $watchedItems = [];
496 foreach ( $res as $row ) {
497 // todo these could all be cached at some point?
498 $watchedItems[] = new WatchedItem(
499 $user,
500 new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
501 $row->wl_notificationtimestamp
502 );
503 }
504
505 return $watchedItems;
506 }
507
508 /**
509 * Must be called separately for Subject & Talk namespaces
510 *
511 * @param User $user
512 * @param LinkTarget $target
513 *
514 * @return bool
515 */
516 public function isWatched( User $user, LinkTarget $target ) {
517 return (bool)$this->getWatchedItem( $user, $target );
518 }
519
520 /**
521 * @param User $user
522 * @param LinkTarget[] $targets
523 *
524 * @return array multi-dimensional like $return[$namespaceId][$titleString] = $timestamp,
525 * where $timestamp is:
526 * - string|null value of wl_notificationtimestamp,
527 * - false if $target is not watched by $user.
528 */
529 public function getNotificationTimestampsBatch( User $user, array $targets ) {
530 $timestamps = [];
531 foreach ( $targets as $target ) {
532 $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
533 }
534
535 if ( $user->isAnon() ) {
536 return $timestamps;
537 }
538
539 $targetsToLoad = [];
540 foreach ( $targets as $target ) {
541 $cachedItem = $this->getCached( $user, $target );
542 if ( $cachedItem ) {
543 $timestamps[$target->getNamespace()][$target->getDBkey()] =
544 $cachedItem->getNotificationTimestamp();
545 } else {
546 $targetsToLoad[] = $target;
547 }
548 }
549
550 if ( !$targetsToLoad ) {
551 return $timestamps;
552 }
553
554 $dbr = $this->getConnectionRef( DB_REPLICA );
555
556 $lb = new LinkBatch( $targetsToLoad );
557 $res = $dbr->select(
558 'watchlist',
559 [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
560 [
561 $lb->constructSet( 'wl', $dbr ),
562 'wl_user' => $user->getId(),
563 ],
564 __METHOD__
565 );
566
567 foreach ( $res as $row ) {
568 $timestamps[$row->wl_namespace][$row->wl_title] = $row->wl_notificationtimestamp;
569 }
570
571 return $timestamps;
572 }
573
574 /**
575 * Must be called separately for Subject & Talk namespaces
576 *
577 * @param User $user
578 * @param LinkTarget $target
579 */
580 public function addWatch( User $user, LinkTarget $target ) {
581 $this->addWatchBatchForUser( $user, [ $target ] );
582 }
583
584 /**
585 * @param User $user
586 * @param LinkTarget[] $targets
587 *
588 * @return bool success
589 */
590 public function addWatchBatchForUser( User $user, array $targets ) {
591 if ( $this->loadBalancer->getReadOnlyReason() !== false ) {
592 return false;
593 }
594 // Only loggedin user can have a watchlist
595 if ( $user->isAnon() ) {
596 return false;
597 }
598
599 if ( !$targets ) {
600 return true;
601 }
602
603 $rows = [];
604 foreach ( $targets as $target ) {
605 $rows[] = [
606 'wl_user' => $user->getId(),
607 'wl_namespace' => $target->getNamespace(),
608 'wl_title' => $target->getDBkey(),
609 'wl_notificationtimestamp' => null,
610 ];
611 $this->uncache( $user, $target );
612 }
613
614 $dbw = $this->getConnectionRef( DB_MASTER );
615 foreach ( array_chunk( $rows, 100 ) as $toInsert ) {
616 // Use INSERT IGNORE to avoid overwriting the notification timestamp
617 // if there's already an entry for this page
618 $dbw->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' );
619 }
620
621 return true;
622 }
623
624 /**
625 * Removes the an entry for the User watching the LinkTarget
626 * Must be called separately for Subject & Talk namespaces
627 *
628 * @param User $user
629 * @param LinkTarget $target
630 *
631 * @return bool success
632 * @throws DBUnexpectedError
633 * @throws MWException
634 */
635 public function removeWatch( User $user, LinkTarget $target ) {
636 // Only logged in user can have a watchlist
637 if ( $this->loadBalancer->getReadOnlyReason() !== false || $user->isAnon() ) {
638 return false;
639 }
640
641 $this->uncache( $user, $target );
642
643 $dbw = $this->getConnectionRef( DB_MASTER );
644 $dbw->delete( 'watchlist',
645 [
646 'wl_user' => $user->getId(),
647 'wl_namespace' => $target->getNamespace(),
648 'wl_title' => $target->getDBkey(),
649 ], __METHOD__
650 );
651 $success = (bool)$dbw->affectedRows();
652
653 return $success;
654 }
655
656 /**
657 * @param User $user The user to set the timestamp for
658 * @param string $timestamp Set the update timestamp to this value
659 * @param LinkTarget[] $targets List of targets to update. Default to all targets
660 *
661 * @return bool success
662 */
663 public function setNotificationTimestampsForUser( User $user, $timestamp, array $targets = [] ) {
664 // Only loggedin user can have a watchlist
665 if ( $user->isAnon() ) {
666 return false;
667 }
668
669 $dbw = $this->getConnectionRef( DB_MASTER );
670
671 $conds = [ 'wl_user' => $user->getId() ];
672 if ( $targets ) {
673 $batch = new LinkBatch( $targets );
674 $conds[] = $batch->constructSet( 'wl', $dbw );
675 }
676
677 $success = $dbw->update(
678 'watchlist',
679 [ 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp ) ],
680 $conds,
681 __METHOD__
682 );
683
684 $this->uncacheUser( $user );
685
686 return $success;
687 }
688
689 /**
690 * @param User $editor The editor that triggered the update. Their notification
691 * timestamp will not be updated(they have already seen it)
692 * @param LinkTarget $target The target to update timestamps for
693 * @param string $timestamp Set the update timestamp to this value
694 *
695 * @return int[] Array of user IDs the timestamp has been updated for
696 */
697 public function updateNotificationTimestamp( User $editor, LinkTarget $target, $timestamp ) {
698 $dbw = $this->getConnectionRef( DB_MASTER );
699 $uids = $dbw->selectFieldValues(
700 'watchlist',
701 'wl_user',
702 [
703 'wl_user != ' . intval( $editor->getId() ),
704 'wl_namespace' => $target->getNamespace(),
705 'wl_title' => $target->getDBkey(),
706 'wl_notificationtimestamp IS NULL',
707 ],
708 __METHOD__
709 );
710
711 $watchers = array_map( 'intval', $uids );
712 if ( $watchers ) {
713 // Update wl_notificationtimestamp for all watching users except the editor
714 $fname = __METHOD__;
715 DeferredUpdates::addCallableUpdate(
716 function () use ( $timestamp, $watchers, $target, $fname ) {
717 global $wgUpdateRowsPerQuery;
718
719 $dbw = $this->getConnectionRef( DB_MASTER );
720 $factory = wfGetLBFactory();
721 $ticket = $factory->getEmptyTransactionTicket( __METHOD__ );
722
723 $watchersChunks = array_chunk( $watchers, $wgUpdateRowsPerQuery );
724 foreach ( $watchersChunks as $watchersChunk ) {
725 $dbw->update( 'watchlist',
726 [ /* SET */
727 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
728 ], [ /* WHERE - TODO Use wl_id T130067 */
729 'wl_user' => $watchersChunk,
730 'wl_namespace' => $target->getNamespace(),
731 'wl_title' => $target->getDBkey(),
732 ], $fname
733 );
734 if ( count( $watchersChunks ) > 1 ) {
735 $factory->commitAndWaitForReplication(
736 __METHOD__, $ticket, [ 'wiki' => $dbw->getWikiID() ]
737 );
738 }
739 }
740 $this->uncacheLinkTarget( $target );
741 },
742 DeferredUpdates::POSTSEND,
743 $dbw
744 );
745 }
746
747 return $watchers;
748 }
749
750 /**
751 * Reset the notification timestamp of this entry
752 *
753 * @param User $user
754 * @param Title $title
755 * @param string $force Whether to force the write query to be executed even if the
756 * page is not watched or the notification timestamp is already NULL.
757 * 'force' in order to force
758 * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed.
759 *
760 * @return bool success
761 */
762 public function resetNotificationTimestamp( User $user, Title $title, $force = '', $oldid = 0 ) {
763 // Only loggedin user can have a watchlist
764 if ( $this->loadBalancer->getReadOnlyReason() !== false || $user->isAnon() ) {
765 return false;
766 }
767
768 $item = null;
769 if ( $force != 'force' ) {
770 $item = $this->loadWatchedItem( $user, $title );
771 if ( !$item || $item->getNotificationTimestamp() === null ) {
772 return false;
773 }
774 }
775
776 // If the page is watched by the user (or may be watched), update the timestamp
777 $job = new ActivityUpdateJob(
778 $title,
779 [
780 'type' => 'updateWatchlistNotification',
781 'userid' => $user->getId(),
782 'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
783 'curTime' => time()
784 ]
785 );
786
787 // Try to run this post-send
788 // Calls DeferredUpdates::addCallableUpdate in normal operation
789 call_user_func(
790 $this->deferredUpdatesAddCallableUpdateCallback,
791 function() use ( $job ) {
792 $job->run();
793 }
794 );
795
796 $this->uncache( $user, $title );
797
798 return true;
799 }
800
801 private function getNotificationTimestamp( User $user, Title $title, $item, $force, $oldid ) {
802 if ( !$oldid ) {
803 // No oldid given, assuming latest revision; clear the timestamp.
804 return null;
805 }
806
807 if ( !$title->getNextRevisionID( $oldid ) ) {
808 // Oldid given and is the latest revision for this title; clear the timestamp.
809 return null;
810 }
811
812 if ( $item === null ) {
813 $item = $this->loadWatchedItem( $user, $title );
814 }
815
816 if ( !$item ) {
817 // This can only happen if $force is enabled.
818 return null;
819 }
820
821 // Oldid given and isn't the latest; update the timestamp.
822 // This will result in no further notification emails being sent!
823 // Calls Revision::getTimestampFromId in normal operation
824 $notificationTimestamp = call_user_func(
825 $this->revisionGetTimestampFromIdCallback,
826 $title,
827 $oldid
828 );
829
830 // We need to go one second to the future because of various strict comparisons
831 // throughout the codebase
832 $ts = new MWTimestamp( $notificationTimestamp );
833 $ts->timestamp->add( new DateInterval( 'PT1S' ) );
834 $notificationTimestamp = $ts->getTimestamp( TS_MW );
835
836 if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
837 if ( $force != 'force' ) {
838 return false;
839 } else {
840 // This is a little silly…
841 return $item->getNotificationTimestamp();
842 }
843 }
844
845 return $notificationTimestamp;
846 }
847
848 /**
849 * @param User $user
850 * @param int $unreadLimit
851 *
852 * @return int|bool The number of unread notifications
853 * true if greater than or equal to $unreadLimit
854 */
855 public function countUnreadNotifications( User $user, $unreadLimit = null ) {
856 $queryOptions = [];
857 if ( $unreadLimit !== null ) {
858 $unreadLimit = (int)$unreadLimit;
859 $queryOptions['LIMIT'] = $unreadLimit;
860 }
861
862 $dbr = $this->getConnectionRef( DB_REPLICA );
863 $rowCount = $dbr->selectRowCount(
864 'watchlist',
865 '1',
866 [
867 'wl_user' => $user->getId(),
868 'wl_notificationtimestamp IS NOT NULL',
869 ],
870 __METHOD__,
871 $queryOptions
872 );
873
874 if ( !isset( $unreadLimit ) ) {
875 return $rowCount;
876 }
877
878 if ( $rowCount >= $unreadLimit ) {
879 return true;
880 }
881
882 return $rowCount;
883 }
884
885 /**
886 * Check if the given title already is watched by the user, and if so
887 * add a watch for the new title.
888 *
889 * To be used for page renames and such.
890 *
891 * @param LinkTarget $oldTarget
892 * @param LinkTarget $newTarget
893 */
894 public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
895 $oldTarget = Title::newFromLinkTarget( $oldTarget );
896 $newTarget = Title::newFromLinkTarget( $newTarget );
897
898 $this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
899 $this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
900 }
901
902 /**
903 * Check if the given title already is watched by the user, and if so
904 * add a watch for the new title.
905 *
906 * To be used for page renames and such.
907 * This must be called separately for Subject and Talk pages
908 *
909 * @param LinkTarget $oldTarget
910 * @param LinkTarget $newTarget
911 */
912 public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
913 $dbw = $this->getConnectionRef( DB_MASTER );
914
915 $result = $dbw->select(
916 'watchlist',
917 [ 'wl_user', 'wl_notificationtimestamp' ],
918 [
919 'wl_namespace' => $oldTarget->getNamespace(),
920 'wl_title' => $oldTarget->getDBkey(),
921 ],
922 __METHOD__,
923 [ 'FOR UPDATE' ]
924 );
925
926 $newNamespace = $newTarget->getNamespace();
927 $newDBkey = $newTarget->getDBkey();
928
929 # Construct array to replace into the watchlist
930 $values = [];
931 foreach ( $result as $row ) {
932 $values[] = [
933 'wl_user' => $row->wl_user,
934 'wl_namespace' => $newNamespace,
935 'wl_title' => $newDBkey,
936 'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
937 ];
938 }
939
940 if ( !empty( $values ) ) {
941 # Perform replace
942 # Note that multi-row replace is very efficient for MySQL but may be inefficient for
943 # some other DBMSes, mostly due to poor simulation by us
944 $dbw->replace(
945 'watchlist',
946 [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
947 $values,
948 __METHOD__
949 );
950 }
951 }
952
953 }