mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/DiscussionTools
synced 2024-11-24 16:34:21 +00:00
25272e7a4a
These changes ensure that DiscussionTools is independent of DOM library choice, and will not break if/when Parsoid switches to an alternate (more standards-compliant) DOM library. We run `phan` against the Dodo standards-compliant DOM library, so this ends up flagging uses of non-standard PHP extensions to the DOM. These will be suppressed for now with a "Nonstandard DOM" comment that can be grepped for, since they will eventually will need to be rewritten or worked around. Most frequent issues: * Node::nodeValue and Node::textContent and Element::getAttribute() can return null in a spec-compliant implementation. Add `?? ''` to make spec-compliant results consistent w/ what PHP returns. * DOMXPath doesn't accept anything except DOMDocument. These uses should be replaced with DOMCompat::querySelectorAll() or similar (which end up using DOMXPath under the covers for DOMDocument any way, but are implemented more efficiently in a spec-compliant implementation). * A couple of times we have code like: `while ($node->firstChild!==null) { $node = $node->firstChild; }` and phan's analysis isn't strong enough to determine that $node is still non-null after the while. This same issue should appear with DOMDocument but phan doesn't complain for some reason. One apparently legit issue: * Node::insertBefore() is once called in a funny way which leans on the fact that the second option is optional in PHP. This seems to be a workaround for an ancient PHP bug, and can probably be safely removed. Bug: T287611 Bug: T217867 Change-Id: I3c4f41c3819770f85d68157c9f690d650b7266a3
209 lines
6.3 KiB
PHP
209 lines
6.3 KiB
PHP
<?php
|
|
/**
|
|
* DiscussionTools event dispatcher
|
|
*
|
|
* @file
|
|
* @ingroup Extensions
|
|
* @license MIT
|
|
*/
|
|
|
|
namespace MediaWiki\Extension\DiscussionTools\Notifications;
|
|
|
|
use EchoEvent;
|
|
use Error;
|
|
use IDBAccessObject;
|
|
use Iterator;
|
|
use MediaWiki\Extension\DiscussionTools\CommentParser;
|
|
use MediaWiki\Extension\DiscussionTools\HeadingItem;
|
|
use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
|
|
use MediaWiki\Extension\DiscussionTools\SubscriptionItem;
|
|
use MediaWiki\MediaWikiServices;
|
|
use MediaWiki\Page\PageIdentity;
|
|
use MediaWiki\Revision\RevisionRecord;
|
|
use MediaWiki\User\UserIdentity;
|
|
use ParserOptions;
|
|
use Title;
|
|
use Wikimedia\Assert\Assert;
|
|
use Wikimedia\Parsoid\DOM\Element;
|
|
use Wikimedia\Parsoid\Utils\DOMUtils;
|
|
|
|
class EventDispatcher {
|
|
/**
|
|
* @param RevisionRecord $revRecord
|
|
* @return CommentParser
|
|
*/
|
|
private static function getParsedRevision( RevisionRecord $revRecord ): CommentParser {
|
|
$services = MediaWikiServices::getInstance();
|
|
|
|
$pageRecord = $services->getPageStore()->getPageByReference( $revRecord->getPage() );
|
|
Assert::postcondition( $pageRecord !== null, 'Revision had no page' );
|
|
|
|
// If the $revRecord was fetched from the primary database, this will also fetch the content
|
|
// from the primary database (using the same query flags)
|
|
$status = $services->getParserOutputAccess()->getParserOutput(
|
|
$pageRecord,
|
|
ParserOptions::newCanonical( 'canonical' ),
|
|
$revRecord
|
|
);
|
|
if ( !$status->isOK() ) {
|
|
throw new Error( 'Could not load revision for notifications' );
|
|
}
|
|
|
|
$parserOutput = $status->getValue();
|
|
$html = $parserOutput->getText();
|
|
|
|
$doc = DOMUtils::parseHTML( $html );
|
|
$container = $doc->getElementsByTagName( 'body' )->item( 0 );
|
|
if ( !( $container instanceof Element ) ) {
|
|
throw new Error( 'Could not load revision for notifications' );
|
|
}
|
|
return CommentParser::newFromGlobalState( $container );
|
|
}
|
|
|
|
/**
|
|
* @param array &$events
|
|
* @param RevisionRecord $newRevRecord
|
|
*/
|
|
public static function generateEventsForRevision( array &$events, RevisionRecord $newRevRecord ) {
|
|
$services = MediaWikiServices::getInstance();
|
|
$dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' );
|
|
|
|
if ( !$dtConfig->get( 'DiscussionToolsEnableTopicSubscriptionBackend' ) ) {
|
|
// Feature disabled for all users
|
|
return;
|
|
}
|
|
|
|
$revisionStore = $services->getRevisionStore();
|
|
$userFactory = $services->getUserFactory();
|
|
$oldRevRecord = $revisionStore->getPreviousRevision( $newRevRecord, IDBAccessObject::READ_LATEST );
|
|
|
|
if ( $oldRevRecord === null ) {
|
|
// TODO: Handle page creation (oldRevRecord = null?)
|
|
return;
|
|
}
|
|
|
|
$title = Title::newFromLinkTarget(
|
|
$newRevRecord->getPageAsLinkTarget()
|
|
);
|
|
if ( !HookUtils::isAvailableForTitle( $title ) ) {
|
|
// Not a talk page
|
|
return;
|
|
}
|
|
|
|
$user = $newRevRecord->getUser();
|
|
if ( !$user ) {
|
|
// User can be null if the user is deleted, but this is unlikely
|
|
// to be the case if the user just made an edit
|
|
return;
|
|
}
|
|
|
|
$oldParser = self::getParsedRevision( $oldRevRecord );
|
|
$newParser = self::getParsedRevision( $newRevRecord );
|
|
|
|
self::generateEventsFromParsers( $events, $oldParser, $newParser, $newRevRecord, $title, $user );
|
|
}
|
|
|
|
/**
|
|
* Helper for generateEventsForRevision(), separated out for easier testing.
|
|
*
|
|
* @param array &$events
|
|
* @param CommentParser $oldParser
|
|
* @param CommentParser $newParser
|
|
* @param RevisionRecord $newRevRecord
|
|
* @param PageIdentity $title
|
|
* @param UserIdentity $user
|
|
*/
|
|
protected static function generateEventsFromParsers(
|
|
array &$events,
|
|
CommentParser $oldParser,
|
|
CommentParser $newParser,
|
|
RevisionRecord $newRevRecord,
|
|
PageIdentity $title,
|
|
UserIdentity $user
|
|
) {
|
|
$newComments = [];
|
|
foreach ( $newParser->getCommentItems() as $newComment ) {
|
|
if (
|
|
$newComment->getAuthor() === $user->getName() &&
|
|
// Compare comments by name, as ID could be changed by a parent comment
|
|
// being moved/deleted. The downside is that multiple replies within the
|
|
// same minute will only fire one notification.
|
|
count( $oldParser->findCommentsByName( $newComment->getName() ) ) === 0
|
|
) {
|
|
$newComments[] = $newComment;
|
|
}
|
|
}
|
|
|
|
$mentionedUsers = [];
|
|
foreach ( $events as $event ) {
|
|
if ( $event['type'] === 'mention' || $event['type'] === 'mention-summary' ) {
|
|
// Array is keyed by user id so we can do a simple array merge
|
|
$mentionedUsers += $event['extra']['mentioned-users'];
|
|
}
|
|
}
|
|
|
|
foreach ( $newComments as $newComment ) {
|
|
$heading = $newComment->getHeading();
|
|
// Find a level 2 heading, because the interface doesn't allow subscribing to other headings.
|
|
// (T286736)
|
|
while ( $heading instanceof HeadingItem && $heading->getHeadingLevel() !== 2 ) {
|
|
$heading = $heading->getParent();
|
|
}
|
|
if ( !( $heading instanceof HeadingItem ) ) {
|
|
continue;
|
|
}
|
|
// Check if the name corresponds to a section that contain no comments (only sub-sections).
|
|
// The interface doesn't allow subscribing to them either, because they can't be distinguished
|
|
// from each other. (T285796)
|
|
if ( $heading->getName() === 'h-' ) {
|
|
continue;
|
|
}
|
|
$events[] = [
|
|
'type' => 'dt-subscribed-new-comment',
|
|
'title' => $title,
|
|
'extra' => [
|
|
'subscribed-comment-name' => $heading->getName(),
|
|
'comment-id' => $newComment->getId(),
|
|
'comment-name' => $newComment->getName(),
|
|
'content' => $newComment->getBodyText( true ),
|
|
'section-title' => $heading->getText(),
|
|
'revid' => $newRevRecord->getId(),
|
|
'mentioned-users' => $mentionedUsers,
|
|
],
|
|
'agent' => $user,
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return all users subscribed to a comment
|
|
*
|
|
* @param EchoEvent $event
|
|
* @param int $batchSize
|
|
* @return UserIdentity[]|Iterator<UserIdentity>
|
|
*/
|
|
public static function locateSubscribedUsers( EchoEvent $event, $batchSize = 500 ) {
|
|
$commentName = $event->getExtraParam( 'subscribed-comment-name' );
|
|
|
|
$subscriptionStore = MediaWikiServices::getInstance()->getService( 'DiscussionTools.SubscriptionStore' );
|
|
$subscriptionItems = $subscriptionStore->getSubscriptionItemsForTopic(
|
|
$commentName,
|
|
1
|
|
);
|
|
|
|
// Update notified timestamps
|
|
$subscriptionStore->updateSubscriptionNotifiedTimestamp(
|
|
null,
|
|
$commentName
|
|
);
|
|
|
|
// TODD: Have this return an Iterator instead?
|
|
$users = array_map( static function ( SubscriptionItem $item ) {
|
|
return $item->getUserIdentity();
|
|
}, $subscriptionItems );
|
|
|
|
return $users;
|
|
}
|
|
|
|
}
|