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