Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
47.86% |
112 / 234 |
|
16.67% |
2 / 12 |
CRAP | |
0.00% |
0 / 1 |
| EventDispatcher | |
47.86% |
112 / 234 |
|
16.67% |
2 / 12 |
850.06 | |
0.00% |
0 / 1 |
| setAutosubscribe | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getParsedRevision | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| generateEventsForRevision | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
20 | |||
| groupCommentsByThreadAndName | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
9 | |||
| groupSubscribableHeadings | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| findAddedItems | |
84.62% |
11 / 13 |
|
0.00% |
0 / 1 |
8.23 | |||
| generateEventsFromItemSets | |
84.54% |
82 / 97 |
|
0.00% |
0 / 1 |
21.48 | |||
| addCommentChangeTag | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
| addAutoSubscription | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
30 | |||
| locateSubscribedUsers | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
2 | |||
| logAddedComments | |
7.41% |
4 / 54 |
|
0.00% |
0 / 1 |
246.42 | |||
| inEventSample | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * DiscussionTools event dispatcher |
| 4 | * |
| 5 | * @file |
| 6 | * @ingroup Extensions |
| 7 | * @license MIT |
| 8 | */ |
| 9 | |
| 10 | namespace MediaWiki\Extension\DiscussionTools\Notifications; |
| 11 | |
| 12 | use DateInterval; |
| 13 | use DateTimeImmutable; |
| 14 | use Iterator; |
| 15 | use MediaWiki\Context\RequestContext; |
| 16 | use MediaWiki\Deferred\DeferredUpdates; |
| 17 | use MediaWiki\Extension\DiscussionTools\CommentUtils; |
| 18 | use MediaWiki\Extension\DiscussionTools\ContentThreadItemSet; |
| 19 | use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils; |
| 20 | use MediaWiki\Extension\DiscussionTools\SubscriptionItem; |
| 21 | use MediaWiki\Extension\DiscussionTools\SubscriptionStore; |
| 22 | use MediaWiki\Extension\DiscussionTools\ThreadItem\CommentItem; |
| 23 | use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem; |
| 24 | use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentHeadingItem; |
| 25 | use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentThreadItem; |
| 26 | use MediaWiki\Extension\DiscussionTools\ThreadItem\HeadingItem; |
| 27 | use MediaWiki\Extension\EventLogging\EventLogging; |
| 28 | use MediaWiki\Extension\EventLogging\Libs\UserBucketProvider\UserBucketProvider; |
| 29 | use MediaWiki\Extension\Notifications\Model\Event; |
| 30 | use MediaWiki\MediaWikiServices; |
| 31 | use MediaWiki\Page\PageIdentity; |
| 32 | use MediaWiki\Registration\ExtensionRegistry; |
| 33 | use MediaWiki\Revision\RevisionRecord; |
| 34 | use MediaWiki\Title\Title; |
| 35 | use MediaWiki\User\UserIdentity; |
| 36 | use Wikimedia\Assert\Assert; |
| 37 | use Wikimedia\Parsoid\Core\DOMCompat; |
| 38 | use Wikimedia\Parsoid\Ext\DOMUtils; |
| 39 | use Wikimedia\Rdbms\IDBAccessObject; |
| 40 | |
| 41 | class EventDispatcher { |
| 42 | protected static string $autosubscribe = 'preferences'; |
| 43 | |
| 44 | /** |
| 45 | * Set the autosubscribe preference from an API call |
| 46 | */ |
| 47 | public static function setAutosubscribe( string $autosubscribe ): void { |
| 48 | self::$autosubscribe = $autosubscribe; |
| 49 | } |
| 50 | |
| 51 | private static function getParsedRevision( RevisionRecord $revRecord ): ContentThreadItemSet { |
| 52 | return HookUtils::parseRevisionParsoidHtml( $revRecord, __METHOD__ )->getValueOrThrow(); |
| 53 | } |
| 54 | |
| 55 | public static function generateEventsForRevision( array &$events, RevisionRecord $newRevRecord ): void { |
| 56 | $services = MediaWikiServices::getInstance(); |
| 57 | |
| 58 | $title = Title::newFromPageIdentity( $newRevRecord->getPage() ); |
| 59 | if ( !HookUtils::isAvailableForTitle( $title ) ) { |
| 60 | // Not a talk page |
| 61 | return; |
| 62 | } |
| 63 | |
| 64 | $user = $newRevRecord->getUser(); |
| 65 | if ( !$user ) { |
| 66 | // User can be null if the user is deleted, but this is unlikely |
| 67 | // to be the case if the user just made an edit |
| 68 | return; |
| 69 | } |
| 70 | |
| 71 | $revisionStore = $services->getRevisionStore(); |
| 72 | $oldRevRecord = $revisionStore->getPreviousRevision( $newRevRecord, IDBAccessObject::READ_LATEST ); |
| 73 | |
| 74 | if ( $oldRevRecord !== null ) { |
| 75 | $oldItemSet = static::getParsedRevision( $oldRevRecord ); |
| 76 | } else { |
| 77 | // Page creation |
| 78 | $doc = DOMUtils::parseHTML( '' ); |
| 79 | $container = DOMCompat::getBody( $doc ); |
| 80 | $oldItemSet = $services->getService( 'DiscussionTools.CommentParser' ) |
| 81 | ->parse( $container, $title->getTitleValue() ); |
| 82 | } |
| 83 | $newItemSet = static::getParsedRevision( $newRevRecord ); |
| 84 | |
| 85 | static::generateEventsFromItemSets( $events, $oldItemSet, $newItemSet, $newRevRecord, $title, $user ); |
| 86 | } |
| 87 | |
| 88 | /** |
| 89 | * For each level 2 heading, get a list of comments in the thread grouped by names, then IDs. |
| 90 | * (Compare by name first, as ID could be changed by a parent comment being moved/deleted.) |
| 91 | * Comments in level 3+ sub-threads are grouped together with the parent thread. |
| 92 | * |
| 93 | * For any other headings (including level 3+ before the first level 2 heading, level 1, and |
| 94 | * section zero placeholder headings), ignore comments in those threads. |
| 95 | * |
| 96 | * @param ContentThreadItem[] $items |
| 97 | * @return ContentCommentItem[][][] |
| 98 | */ |
| 99 | private static function groupCommentsByThreadAndName( array $items ): array { |
| 100 | $comments = []; |
| 101 | $threadName = null; |
| 102 | foreach ( $items as $item ) { |
| 103 | if ( $item instanceof HeadingItem && ( $item->getHeadingLevel() < 2 || $item->isPlaceholderHeading() ) ) { |
| 104 | $threadName = null; |
| 105 | } elseif ( $item instanceof HeadingItem && $item->getHeadingLevel() === 2 ) { |
| 106 | $threadName = $item->getName(); |
| 107 | } elseif ( $item instanceof CommentItem && $threadName !== null ) { |
| 108 | $comments[ $threadName ][ $item->getName() ][ $item->getId() ] = $item; |
| 109 | } |
| 110 | } |
| 111 | return $comments; |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * Get a list of all subscribable headings, grouped by name in case there are duplicates. |
| 116 | * |
| 117 | * @param ContentHeadingItem[] $items |
| 118 | * @return ContentHeadingItem[][] |
| 119 | */ |
| 120 | private static function groupSubscribableHeadings( array $items ): array { |
| 121 | $headings = []; |
| 122 | foreach ( $items as $item ) { |
| 123 | if ( $item->isSubscribable() ) { |
| 124 | $headings[ $item->getName() ][ $item->getId() ] = $item; |
| 125 | } |
| 126 | } |
| 127 | return $headings; |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * Compare two lists of thread items, return those in $new but not $old. |
| 132 | * |
| 133 | * @param ContentThreadItem[][] $old |
| 134 | * @param ContentThreadItem[][] $new |
| 135 | * @return iterable<ContentThreadItem> |
| 136 | */ |
| 137 | private static function findAddedItems( array $old, array $new ) { |
| 138 | foreach ( $new as $itemName => $nameNewItems ) { |
| 139 | // Usually, there will be 0 or 1 $nameNewItems, and 0 $nameOldItems, |
| 140 | // and $addedCount will be 0 or 1. |
| 141 | // |
| 142 | // But when multiple replies are added in one edit, or in multiple edits within the same |
| 143 | // minute, there may be more, and the complex logic below tries to make the best guess |
| 144 | // as to which items are actually new. See the 'multiple' and 'sametime' test cases. |
| 145 | // |
| 146 | $nameOldItems = $old[ $itemName ] ?? []; |
| 147 | $addedCount = count( $nameNewItems ) - count( $nameOldItems ); |
| 148 | |
| 149 | if ( $addedCount > 0 ) { |
| 150 | // For any name that occurs more times in new than old, report that many new items, |
| 151 | // preferring IDs that did not occur in old, then preferring items lower on the page. |
| 152 | foreach ( array_reverse( $nameNewItems ) as $itemId => $newItem ) { |
| 153 | if ( $addedCount > 0 && !isset( $nameOldItems[ $itemId ] ) ) { |
| 154 | yield $newItem; |
| 155 | $addedCount--; |
| 156 | } |
| 157 | } |
| 158 | foreach ( array_reverse( $nameNewItems ) as $itemId => $newItem ) { |
| 159 | if ( $addedCount > 0 ) { |
| 160 | yield $newItem; |
| 161 | $addedCount--; |
| 162 | } |
| 163 | } |
| 164 | Assert::postcondition( $addedCount === 0, 'Reported expected number of items' ); |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | /** |
| 170 | * Helper for generateEventsForRevision(), separated out for easier testing. |
| 171 | */ |
| 172 | protected static function generateEventsFromItemSets( |
| 173 | array &$events, |
| 174 | ContentThreadItemSet $oldItemSet, |
| 175 | ContentThreadItemSet $newItemSet, |
| 176 | RevisionRecord $newRevRecord, |
| 177 | PageIdentity $title, |
| 178 | UserIdentity $user |
| 179 | ): void { |
| 180 | $newComments = static::groupCommentsByThreadAndName( $newItemSet->getThreadItems() ); |
| 181 | $oldComments = static::groupCommentsByThreadAndName( $oldItemSet->getThreadItems() ); |
| 182 | $addedComments = []; |
| 183 | foreach ( $newComments as $threadName => $threadNewComments ) { |
| 184 | $threadOldComments = $oldComments[ $threadName ] ?? []; |
| 185 | foreach ( static::findAddedItems( $threadOldComments, $threadNewComments ) as $newComment ) { |
| 186 | Assert::precondition( $newComment instanceof ContentCommentItem, 'Must be ContentCommentItem' ); |
| 187 | $addedComments[] = $newComment; |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | $newHeadings = static::groupSubscribableHeadings( $newItemSet->getThreads() ); |
| 192 | $oldHeadings = static::groupSubscribableHeadings( $oldItemSet->getThreads() ); |
| 193 | |
| 194 | $addedHeadings = []; |
| 195 | foreach ( static::findAddedItems( $oldHeadings, $newHeadings ) as $newHeading ) { |
| 196 | Assert::precondition( $newHeading instanceof ContentHeadingItem, 'Must be ContentHeadingItem' ); |
| 197 | $addedHeadings[] = $newHeading; |
| 198 | } |
| 199 | |
| 200 | $removedHeadings = []; |
| 201 | // Pass swapped parameters to findAddedItems() to find *removed* items |
| 202 | foreach ( static::findAddedItems( $newHeadings, $oldHeadings ) as $oldHeading ) { |
| 203 | Assert::precondition( $oldHeading instanceof ContentHeadingItem, 'Must be ContentHeadingItem' ); |
| 204 | $removedHeadings[] = $oldHeading; |
| 205 | } |
| 206 | |
| 207 | $mentionedUsers = []; |
| 208 | foreach ( $events as &$event ) { |
| 209 | if ( $event['type'] === 'mention' || $event['type'] === 'mention-summary' ) { |
| 210 | // Save mentioned users in our events, so that we can exclude them from our notification, |
| 211 | // to avoid duplicate notifications for a single comment. |
| 212 | // Array is keyed by user id so we can do a simple array merge. |
| 213 | $mentionedUsers += $event['extra']['mentioned-users']; |
| 214 | } |
| 215 | |
| 216 | if ( count( $addedComments ) === 1 ) { |
| 217 | // If this edit was a new user talk message according to Echo, |
| 218 | // and we also found exactly one new comment, |
| 219 | // add some extra information to the edit-user-talk event. |
| 220 | if ( $event['type'] === 'edit-user-talk' ) { |
| 221 | $event['extra'] += [ |
| 222 | 'comment-id' => $addedComments[0]->getId(), |
| 223 | 'comment-name' => $addedComments[0]->getName(), |
| 224 | 'content' => $addedComments[0]->getBodyText( true ), |
| 225 | ]; |
| 226 | } |
| 227 | |
| 228 | // Similarly for mentions. |
| 229 | // We don't handle 'content' in this case, as Echo makes its own snippets. |
| 230 | if ( $event['type'] === 'mention' ) { |
| 231 | $event['extra'] += [ |
| 232 | 'comment-id' => $addedComments[0]->getId(), |
| 233 | 'comment-name' => $addedComments[0]->getName(), |
| 234 | ]; |
| 235 | } |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | if ( $addedComments ) { |
| 240 | // It's a bit weird to do this here, in the middle of the hook handler for Echo. However: |
| 241 | // * Echo calls this from a PageSaveComplete hook handler as a DeferredUpdate, |
| 242 | // which is exactly how we would do this otherwise |
| 243 | // * It allows us to reuse the generated comment trees without any annoying caching |
| 244 | static::addCommentChangeTag( $newRevRecord ); |
| 245 | // For very similar reasons, we do logging here |
| 246 | static::logAddedComments( $addedComments, $newRevRecord, $title, $user ); |
| 247 | } |
| 248 | |
| 249 | foreach ( $addedComments as $newComment ) { |
| 250 | // Ignore comments by other users, e.g. in case of reverts or a discussion being moved. |
| 251 | // TODO: But what about someone signing another's comment? |
| 252 | if ( $newComment->getAuthor() !== $user->getName() ) { |
| 253 | continue; |
| 254 | } |
| 255 | // Ignore comments which are more than 10 minutes old, as this may be a user archiving |
| 256 | // their own comment. (T290803) |
| 257 | $revTimestamp = new DateTimeImmutable( $newRevRecord->getTimestamp() ); |
| 258 | $threshold = $revTimestamp->sub( new DateInterval( 'PT10M' ) ); |
| 259 | if ( $newComment->getTimestamp() <= $threshold ) { |
| 260 | continue; |
| 261 | } |
| 262 | $heading = $newComment->getSubscribableHeading(); |
| 263 | if ( !$heading ) { |
| 264 | continue; |
| 265 | } |
| 266 | $events[] = [ |
| 267 | // This probably should've been called "dt-new-comment": this code is |
| 268 | // unaware if there are any subscriptions to the containing topic and |
| 269 | // an event is generated for every comment posted. |
| 270 | // However, changing this would require a complex migration. |
| 271 | 'type' => 'dt-subscribed-new-comment', |
| 272 | 'title' => $title, |
| 273 | 'extra' => [ |
| 274 | 'subscribed-comment-name' => $heading->getName(), |
| 275 | 'comment-id' => $newComment->getId(), |
| 276 | 'comment-name' => $newComment->getName(), |
| 277 | 'content' => $newComment->getBodyText( true ), |
| 278 | 'section-title' => $heading->getLinkableTitle(), |
| 279 | 'revid' => $newRevRecord->getId(), |
| 280 | 'mentioned-users' => $mentionedUsers, |
| 281 | ], |
| 282 | 'agent' => $user, |
| 283 | ]; |
| 284 | |
| 285 | $titleForSubscriptions = Title::castFromPageIdentity( $title )->createFragmentTarget( $heading->getText() ); |
| 286 | static::addAutoSubscription( $user, $titleForSubscriptions, $heading->getName() ); |
| 287 | } |
| 288 | |
| 289 | foreach ( $removedHeadings as $oldHeading ) { |
| 290 | $events[] = [ |
| 291 | 'type' => 'dt-removed-topic', |
| 292 | 'title' => $title, |
| 293 | 'extra' => [ |
| 294 | 'subscribed-comment-name' => $oldHeading->getName(), |
| 295 | 'heading-id' => $oldHeading->getId(), |
| 296 | 'heading-name' => $oldHeading->getName(), |
| 297 | 'section-title' => $oldHeading->getLinkableTitle(), |
| 298 | 'revid' => $newRevRecord->getId(), |
| 299 | ], |
| 300 | 'agent' => $user, |
| 301 | ]; |
| 302 | } |
| 303 | |
| 304 | $titleObj = Title::castFromPageIdentity( $title ); |
| 305 | if ( $titleObj ) { |
| 306 | foreach ( $addedHeadings as $newHeading ) { |
| 307 | // Don't use $event here as that already exists as a reference from above |
| 308 | $addTopicEvent = [ |
| 309 | 'type' => 'dt-added-topic', |
| 310 | 'title' => $title, |
| 311 | 'extra' => [ |
| 312 | // As no one can be subscribed to a topic before it has been created, |
| 313 | // we will notify users who have subscribed to the whole page. |
| 314 | 'subscribed-comment-name' => CommentUtils::getNewTopicsSubscriptionId( $titleObj ), |
| 315 | 'heading-id' => $newHeading->getId(), |
| 316 | 'heading-name' => $newHeading->getName(), |
| 317 | 'section-title' => $newHeading->getLinkableTitle(), |
| 318 | 'revid' => $newRevRecord->getId(), |
| 319 | ], |
| 320 | 'agent' => $user, |
| 321 | ]; |
| 322 | // Add metadata about the accompanying comment |
| 323 | $firstComment = $newHeading->getOldestReply(); |
| 324 | if ( $firstComment ) { |
| 325 | $addTopicEvent['extra']['comment-id'] = $firstComment->getId(); |
| 326 | $addTopicEvent['extra']['comment-name'] = $firstComment->getName(); |
| 327 | $addTopicEvent['extra']['content'] = $firstComment->getBodyText( true ); |
| 328 | } |
| 329 | $events[] = $addTopicEvent; |
| 330 | } |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | /** |
| 335 | * Add our change tag for a revision that adds new comments. |
| 336 | */ |
| 337 | protected static function addCommentChangeTag( RevisionRecord $newRevRecord ): void { |
| 338 | // Unclear if DeferredUpdates::addCallableUpdate() is needed, |
| 339 | // but every extension does it that way. |
| 340 | DeferredUpdates::addCallableUpdate( static function () use ( $newRevRecord ) { |
| 341 | MediaWikiServices::getInstance()->getChangeTagsStore() |
| 342 | ->addTags( [ 'discussiontools-added-comment' ], null, $newRevRecord->getId() ); |
| 343 | } ); |
| 344 | } |
| 345 | |
| 346 | /** |
| 347 | * Add an automatic subscription to the given item, assuming the user has automatic subscriptions |
| 348 | * enabled. |
| 349 | */ |
| 350 | protected static function addAutoSubscription( UserIdentity $user, Title $title, string $itemName ): void { |
| 351 | $dtConfig = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'discussiontools' ); |
| 352 | if ( $dtConfig->get( 'DiscussionToolsAutoTopicSubEditor' ) !== 'any' ) { |
| 353 | return; |
| 354 | } |
| 355 | |
| 356 | if ( |
| 357 | ( self::$autosubscribe === 'preferences' && HookUtils::shouldAddAutoSubscription( $user, $title ) ) || |
| 358 | self::$autosubscribe === 'yes' |
| 359 | ) { |
| 360 | /** @var SubscriptionStore $subscriptionStore */ |
| 361 | $subscriptionStore = MediaWikiServices::getInstance()->getService( 'DiscussionTools.SubscriptionStore' ); |
| 362 | $subscriptionStore->addAutoSubscriptionForUser( $user, $title, $itemName ); |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | /** |
| 367 | * Return all users subscribed to a comment |
| 368 | * |
| 369 | * @param Event $event |
| 370 | * @param int $batchSize |
| 371 | * @return UserIdentity[]|Iterator<UserIdentity> |
| 372 | */ |
| 373 | public static function locateSubscribedUsers( Event $event, $batchSize = 500 ) { |
| 374 | $commentName = $event->getExtraParam( 'subscribed-comment-name' ); |
| 375 | |
| 376 | /** @var SubscriptionStore $subscriptionStore */ |
| 377 | $subscriptionStore = MediaWikiServices::getInstance()->getService( 'DiscussionTools.SubscriptionStore' ); |
| 378 | $subscriptionItems = $subscriptionStore->getSubscriptionItemsForTopic( |
| 379 | $commentName, |
| 380 | [ SubscriptionStore::STATE_SUBSCRIBED, SubscriptionStore::STATE_AUTOSUBSCRIBED ] |
| 381 | ); |
| 382 | |
| 383 | // Update notified timestamps |
| 384 | $subscriptionStore->updateSubscriptionNotifiedTimestamp( |
| 385 | null, |
| 386 | $commentName |
| 387 | ); |
| 388 | |
| 389 | // TODD: Have this return an Iterator instead? |
| 390 | $users = array_map( static function ( SubscriptionItem $item ) { |
| 391 | return $item->getUserIdentity(); |
| 392 | }, $subscriptionItems ); |
| 393 | |
| 394 | return $users; |
| 395 | } |
| 396 | |
| 397 | /** |
| 398 | * Log stuff to EventLogging's Schema:TalkPageEvent |
| 399 | * If you don't have EventLogging installed, does nothing. |
| 400 | * |
| 401 | * @return bool Whether events were logged |
| 402 | */ |
| 403 | protected static function logAddedComments( |
| 404 | array $addedComments, |
| 405 | RevisionRecord $newRevRecord, |
| 406 | PageIdentity $title, |
| 407 | UserIdentity $identity |
| 408 | ): bool { |
| 409 | global $wgDTSchemaEditAttemptStepOversample, $wgDBname; |
| 410 | $context = RequestContext::getMain(); |
| 411 | $request = $context->getRequest(); |
| 412 | // We've reached here through Echo's post-save deferredupdate, which |
| 413 | // might either be after an API request from DiscussionTools or a |
| 414 | // regular POST from WikiEditor. Both should have this value snuck |
| 415 | // into their request if their session is being logged. |
| 416 | if ( !$request->getCheck( 'editingStatsId' ) ) { |
| 417 | return false; |
| 418 | } |
| 419 | $editingStatsId = $request->getVal( 'editingStatsId' ); |
| 420 | $isDiscussionTools = $request->getCheck( 'dttags' ); |
| 421 | |
| 422 | $extensionRegistry = ExtensionRegistry::getInstance(); |
| 423 | if ( !$extensionRegistry->isLoaded( 'EventLogging' ) ) { |
| 424 | return false; |
| 425 | } |
| 426 | if ( !$extensionRegistry->isLoaded( 'WikimediaEvents' ) ) { |
| 427 | return false; |
| 428 | } |
| 429 | $inSample = static::inEventSample( $editingStatsId ); |
| 430 | $shouldOversample = ( $isDiscussionTools && $wgDTSchemaEditAttemptStepOversample ) || ( |
| 431 | // @phan-suppress-next-line PhanUndeclaredClassMethod |
| 432 | \WikimediaEvents\WikimediaEventsHooks::shouldSchemaEditAttemptStepOversample( $context ) |
| 433 | ); |
| 434 | if ( !$inSample && !$shouldOversample ) { |
| 435 | return false; |
| 436 | } |
| 437 | |
| 438 | $services = MediaWikiServices::getInstance(); |
| 439 | $editTracker = $services->getUserEditTracker(); |
| 440 | $userIdentityUtils = $services->getUserIdentityUtils(); |
| 441 | |
| 442 | $commonData = [ |
| 443 | '$schema' => '/analytics/mediawiki/talk_page_edit/1.2.0', |
| 444 | 'action' => 'publish', |
| 445 | 'session_id' => $editingStatsId, |
| 446 | 'page_id' => $newRevRecord->getPageId(), |
| 447 | 'page_namespace' => $title->getNamespace(), |
| 448 | 'revision_id' => $newRevRecord->getId() ?: 0, |
| 449 | 'performer' => [ |
| 450 | // Note: we're logging the user who made the edit, not the user who's signed on the comment |
| 451 | 'user_id' => $identity->getId(), |
| 452 | 'user_edit_count' => $editTracker->getUserEditCount( $identity ) ?: 0, |
| 453 | // Retention-safe values: |
| 454 | 'user_is_anonymous' => !$identity->isRegistered(), |
| 455 | 'user_is_temp' => $userIdentityUtils->isTemp( $identity ), |
| 456 | 'user_edit_count_bucket' => UserBucketProvider::getUserEditCountBucket( $identity ) ?: 'N/A', |
| 457 | ], |
| 458 | 'database' => $wgDBname, |
| 459 | // This is unreliable, but sufficient for our purposes; we |
| 460 | // mostly just want to see the difference between DT and |
| 461 | // everything-else: |
| 462 | 'integration' => $isDiscussionTools ? 'discussiontools' : 'page', |
| 463 | ]; |
| 464 | |
| 465 | foreach ( $addedComments as $comment ) { |
| 466 | $heading = $comment->getSubscribableHeading(); |
| 467 | $parent = $comment->getParent(); |
| 468 | if ( !$heading || !$parent ) { |
| 469 | continue; |
| 470 | } |
| 471 | if ( $parent->getType() === 'heading' ) { |
| 472 | if ( count( $heading->getReplies() ) === 1 ) { |
| 473 | // A new heading was added when this comment was created |
| 474 | $component_type = 'topic'; |
| 475 | } else { |
| 476 | $component_type = 'comment'; |
| 477 | } |
| 478 | } else { |
| 479 | $component_type = 'response'; |
| 480 | } |
| 481 | EventLogging::submit( 'mediawiki.talk_page_edit', array_merge( $commonData, [ |
| 482 | 'component_type' => $component_type, |
| 483 | 'topic_id' => $heading->getId(), |
| 484 | 'comment_id' => $comment->getId(), |
| 485 | 'comment_parent_id' => $parent->getId(), |
| 486 | ] ) ); |
| 487 | } |
| 488 | |
| 489 | return true; |
| 490 | } |
| 491 | |
| 492 | /** |
| 493 | * Should the current session be sampled for EventLogging? |
| 494 | * |
| 495 | * @return bool Whether to sample the session |
| 496 | */ |
| 497 | protected static function inEventSample( string $sessionId ): bool { |
| 498 | global $wgDTSchemaEditAttemptStepSamplingRate, $wgWMESchemaEditAttemptStepSamplingRate; |
| 499 | // Sample 6.25% |
| 500 | $samplingRate = 0.0625; |
| 501 | if ( $wgDTSchemaEditAttemptStepSamplingRate !== null ) { |
| 502 | $samplingRate = $wgDTSchemaEditAttemptStepSamplingRate; |
| 503 | } |
| 504 | if ( $wgWMESchemaEditAttemptStepSamplingRate !== null ) { |
| 505 | $samplingRate = $wgWMESchemaEditAttemptStepSamplingRate; |
| 506 | } |
| 507 | if ( $samplingRate === 0 ) { |
| 508 | return false; |
| 509 | } |
| 510 | $inSample = EventLogging::sessionInSample( |
| 511 | (int)( 1 / $samplingRate ), $sessionId |
| 512 | ); |
| 513 | return $inSample; |
| 514 | } |
| 515 | |
| 516 | } |