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