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