Merge "Split off ThreadItemSet from CommentParser"

This commit is contained in:
jenkins-bot 2022-02-21 16:33:58 +00:00 committed by Gerrit Code Review
commit aeabff63c9
25 changed files with 634 additions and 553 deletions

View file

@ -66,6 +66,7 @@
"modifier.js",
"utils.js",
"Parser.js",
"ThreadItemSet.js",
"ThreadItem.js",
"CommentItem.js",
"HeadingItem.js",

View file

@ -62,19 +62,19 @@ class ApiDiscussionToolsCompare extends ApiBase {
return;
}
$fromParser = $this->parseRevision( $fromRev );
$toParser = $this->parseRevision( $toRev );
$fromItemSet = $this->parseRevision( $fromRev );
$toItemSet = $this->parseRevision( $toRev );
$removedComments = [];
foreach ( $fromParser->getCommentItems() as $fromComment ) {
if ( !$toParser->findCommentById( $fromComment->getId() ) ) {
foreach ( $fromItemSet->getCommentItems() as $fromComment ) {
if ( !$toItemSet->findCommentById( $fromComment->getId() ) ) {
$removedComments[] = $fromComment->jsonSerializeForDiff();
}
}
$addedComments = [];
foreach ( $toParser->getCommentItems() as $toComment ) {
if ( !$fromParser->findCommentById( $toComment->getId() ) ) {
foreach ( $toItemSet->getCommentItems() as $toComment ) {
if ( !$fromItemSet->findCommentById( $toComment->getId() ) ) {
$addedComments[] = $toComment->jsonSerializeForDiff();
}
}

View file

@ -190,18 +190,18 @@ class ApiDiscussionToolsEdit extends ApiBase {
$container = DOMCompat::getBody( $doc );
$parser = MediaWikiServices::getInstance()->getService( 'DiscussionTools.CommentParser' )
$threadItemSet = MediaWikiServices::getInstance()->getService( 'DiscussionTools.CommentParser' )
->parse( $container, $title );
if ( $commentId ) {
$comment = $parser->findCommentById( $commentId );
$comment = $threadItemSet->findCommentById( $commentId );
if ( !$comment || !( $comment instanceof CommentItem ) ) {
$this->dieWithError( [ 'apierror-discussiontools-commentid-notfound', $commentId ] );
}
} else {
$comments = $parser->findCommentsByName( $commentName );
$comments = $threadItemSet->findCommentsByName( $commentName );
$comment = $comments[ 0 ] ?? null;
if ( count( $comments ) > 1 ) {

View file

@ -32,8 +32,8 @@ class ApiDiscussionToolsPageInfo extends ApiBase {
}
$revision = $this->getValidRevision( $title, $params['oldid'] ?? null );
$parser = $this->parseRevision( $revision );
$threadItems = $parser->getThreadItems();
$threadItemSet = $this->parseRevision( $revision );
$threadItems = $threadItemSet->getThreadItems();
$transcludedFrom = [];
foreach ( $threadItems as $threadItem ) {

View file

@ -11,9 +11,9 @@ use Wikimedia\Parsoid\Utils\DOMUtils;
trait ApiDiscussionToolsTrait {
/**
* @param RevisionRecord $revision
* @return CommentParser
* @return ThreadItemSet
*/
protected function parseRevision( RevisionRecord $revision ): CommentParser {
protected function parseRevision( RevisionRecord $revision ): ThreadItemSet {
$response = $this->requestRestbasePageHtml( $revision );
$doc = DOMUtils::parseHTML( $response['body'] );

View file

@ -87,8 +87,8 @@ class CommentFormatter {
$doc = DOMUtils::parseHTML( $html );
$container = DOMCompat::getBody( $doc );
$parser = static::getParser()->parse( $container, $title );
$threadItems = $parser->getThreadItems();
$threadItemSet = static::getParser()->parse( $container, $title );
$threadItems = $threadItemSet->getThreadItems();
// Iterate in reverse order, because adding the range markers for a thread item
// can invalidate the ranges of subsequent thread items (T298096)

View file

@ -27,17 +27,6 @@ class CommentParser {
/** @var Title */
private $title;
/** @var ThreadItem[] */
private $threadItems;
/** @var CommentItem[] */
private $commentItems;
/** @var ThreadItem[][] */
private $threadItemsByName;
/** @var ThreadItem[] */
private $threadItemsById;
/** @var HeadingItem[] */
private $threads;
/** @var Config */
private $config;
@ -75,14 +64,17 @@ class CommentParser {
*
* @param Element $rootNode Root node of content to parse
* @param Title $title Title of the page being parsed
* @return $this
* @return ThreadItemSet
*/
public function parse( Element $rootNode, Title $title ) {
public function parse( Element $rootNode, Title $title ): ThreadItemSet {
$this->rootNode = $rootNode;
$this->title = $title;
// TODO Return a data object
// (This line is a big fat hack)
return clone $this;
$result = $this->buildThreadItems();
$this->buildThreads( $result );
$this->computeIdsAndNames( $result );
return $result;
}
/**
@ -729,89 +721,6 @@ class CommentParser {
return null;
}
/**
* Get all discussion comments (and headings) within a DOM subtree.
*
* This returns a flat list, use getThreads() to get a tree structure starting at section headings.
*
* For example, for a MediaWiki discussion like this (we're dealing with HTML DOM here,
* the wikitext syntax is just for illustration):
*
* == A ==
* B. ~~~~
* : C.
* : C. ~~~~
* :: D. ~~~~
* ::: E. ~~~~
* ::: F. ~~~~
* : G. ~~~~
* H. ~~~~
* : I. ~~~~
*
* This function would return a structure like:
*
* [
* HeadingItem( { level: 0, range: (h2: A) } ),
* CommentItem( { level: 1, range: (p: B) } ),
* CommentItem( { level: 2, range: (li: C, li: C) } ),
* CommentItem( { level: 3, range: (li: D) } ),
* CommentItem( { level: 4, range: (li: E) } ),
* CommentItem( { level: 4, range: (li: F) } ),
* CommentItem( { level: 2, range: (li: G) } ),
* CommentItem( { level: 1, range: (p: H) } ),
* CommentItem( { level: 2, range: (li: I) } )
* ]
*
* @return ThreadItem[] Thread items
*/
public function getThreadItems(): array {
if ( !$this->threadItems ) {
$this->buildThreads();
}
return $this->threadItems;
}
/**
* Same as getFlatThreadItems, but only returns the CommentItems
*
* @return CommentItem[] Comment items
*/
public function getCommentItems(): array {
if ( !$this->commentItems ) {
$this->buildThreads();
}
return $this->commentItems;
}
/**
* Find ThreadItems by their name
*
* This will usually return a single-element array, but it may return multiple comments if they're
* indistinguishable by name. In that case, use their IDs to disambiguate.
*
* @param string $name Name
* @return ThreadItem[] Thread items, empty array if not found
*/
public function findCommentsByName( string $name ): array {
if ( !$this->threadItemsByName ) {
$this->buildThreads();
}
return $this->threadItemsByName[$name] ?? [];
}
/**
* Find a ThreadItem by its ID
*
* @param string $id ID
* @return ThreadItem|null Thread item, null if not found
*/
public function findCommentById( string $id ): ?ThreadItem {
if ( !$this->threadItemsById ) {
$this->buildThreads();
}
return $this->threadItemsById[$id] ?? null;
}
/**
* @param Node[] $sigNodes
* @param array $match
@ -838,19 +747,13 @@ class CommentParser {
return $sigRange;
}
private function buildThreadItems(): void {
private function buildThreadItems(): ThreadItemSet {
$result = new ThreadItemSet();
$timestampRegexps = $this->getLocalTimestampRegexps();
$commentItems = [];
$threadItems = [];
$dfParsers = $this->getLocalTimestampParsers();
// Placeholder heading in case there are comments in the 0th section
$range = new ImmutableRange( $this->rootNode, 0, $this->rootNode, 0 );
$fakeHeading = new HeadingItem( $range, 99, true );
$fakeHeading->setRootNode( $this->rootNode );
$curComment = $fakeHeading;
$curCommentEnd = $range->endContainer;
$curCommentEnd = $this->rootNode;
$treeWalker = new TreeWalker(
$this->rootNode,
@ -868,7 +771,7 @@ class CommentParser {
);
$curComment = new HeadingItem( $range, (int)( $match[ 1 ] ) );
$curComment->setRootNode( $this->rootNode );
$threadItems[] = $curComment;
$result->addThreadItem( $curComment );
$curCommentEnd = $node;
} elseif ( $node instanceof Text && ( $match = $this->findTimestamp( $node, $timestampRegexps ) ) ) {
$warnings = [];
@ -965,70 +868,20 @@ class CommentParser {
if ( $warnings ) {
$curComment->addWarnings( $warnings );
}
$commentItems[] = $curComment;
$threadItems[] = $curComment;
if ( $result->isEmpty() ) {
// Add a fake placeholder heading if there are any comments in the 0th section
// (before the first real heading)
$range = new ImmutableRange( $this->rootNode, 0, $this->rootNode, 0 );
$fakeHeading = new HeadingItem( $range, 99, true );
$fakeHeading->setRootNode( $this->rootNode );
$result->addThreadItem( $fakeHeading );
}
$result->addThreadItem( $curComment );
$curCommentEnd = $curComment->getRange()->endContainer;
}
}
// Insert the fake placeholder heading if there are any comments in the 0th section
// (before the first real heading)
if ( count( $threadItems ) && !( $threadItems[ 0 ] instanceof HeadingItem ) ) {
array_unshift( $threadItems, $fakeHeading );
}
$this->commentItems = $commentItems;
$this->threadItems = $threadItems;
}
/**
* Group discussion comments into threads and associate replies to original messages.
*
* Each thread must begin with a heading. Original messages in the thread are treated as replies to
* its heading. Other replies are associated based on the order and indentation level.
*
* Note that the objects in `comments` are extended in-place with the additional data.
*
* For example, for a MediaWiki discussion like this (we're dealing with HTML DOM here,
* the wikitext syntax is just for illustration):
*
* == A ==
* B. ~~~~
* : C.
* : C. ~~~~
* :: D. ~~~~
* ::: E. ~~~~
* ::: F. ~~~~
* : G. ~~~~
* H. ~~~~
* : I. ~~~~
*
* This function would return a structure like:
*
* [
* HeadingItem( { level: 0, range: (h2: A), replies: [
* CommentItem( { level: 1, range: (p: B), replies: [
* CommentItem( { level: 2, range: (li: C, li: C), replies: [
* CommentItem( { level: 3, range: (li: D), replies: [
* CommentItem( { level: 4, range: (li: E), replies: [] } ),
* CommentItem( { level: 4, range: (li: F), replies: [] } ),
* ] } ),
* ] } ),
* CommentItem( { level: 2, range: (li: G), replies: [] } ),
* ] } ),
* CommentItem( { level: 1, range: (p: H), replies: [
* CommentItem( { level: 2, range: (li: I), replies: [] } ),
* ] } ),
* ] } )
* ]
*
* @return HeadingItem[] Tree structure of comments, top-level items are the headings.
*/
public function getThreads(): array {
if ( !$this->threads ) {
$this->buildThreads();
}
return $this->threads;
return $result;
}
/**
@ -1045,9 +898,10 @@ class CommentParser {
* Given a thread item, return an identifier for it that is unique within the page.
*
* @param ThreadItem $threadItem
* @param ThreadItemSet $previousItems
* @return string
*/
private function computeId( ThreadItem $threadItem ): string {
private function computeId( ThreadItem $threadItem, ThreadItemSet $previousItems ): string {
// When changing the algorithm below, copy the old version into computeLegacyId()
// for compatibility with cached data.
@ -1088,12 +942,12 @@ class CommentParser {
}
}
if ( isset( $this->threadItemsById[$id] ) ) {
if ( $previousItems->findCommentById( $id ) ) {
// Well, that's tough
$threadItem->addWarning( 'Duplicate comment ID' );
// Finally, disambiguate by adding sequential numbers, to allow replying to both comments
$number = 1;
while ( isset( $this->threadItemsById["$id-$number"] ) ) {
while ( $previousItems->findCommentById( "$id-$number" ) ) {
$number++;
}
$id = "$id-$number";
@ -1107,9 +961,10 @@ class CommentParser {
* older algorithm, so that we can still match IDs from cached data.
*
* @param ThreadItem $threadItem
* @param ThreadItemSet $previousItems
* @return string|null
*/
private function computeLegacyId( ThreadItem $threadItem ): ?string {
private function computeLegacyId( ThreadItem $threadItem, ThreadItemSet $previousItems ): ?string {
// When we change the algorithm in computeId(), the old version should be copied below
// for compatibility with cached data.
@ -1147,17 +1002,14 @@ class CommentParser {
return $name;
}
private function buildThreads(): void {
if ( !$this->threadItems ) {
$this->buildThreadItems();
}
$threads = [];
/**
* @param ThreadItemSet $result
*/
private function buildThreads( ThreadItemSet $result ): void {
$lastHeading = null;
$replies = [];
$this->threadItemsById = [];
$this->threadItemsByName = [];
foreach ( $this->threadItems as $threadItem ) {
foreach ( $result->getThreadItems() as $threadItem ) {
if ( count( $replies ) < $threadItem->getLevel() ) {
// Someone skipped an indentation level (or several). Pretend that the previous reply
// covers multiple indentation levels, so that following comments get connected to it.
@ -1169,10 +1021,9 @@ class CommentParser {
if ( $threadItem instanceof HeadingItem ) {
// New root (thread)
$threads[] = $threadItem;
// Attach as a sub-thread to preceding higher-level heading.
// Any replies will appear in the tree twice, under the main-thread and the sub-thread.
$maybeParent = count( $threads ) > 1 ? $threads[ count( $threads ) - 2 ] : null;
$maybeParent = $lastHeading;
while ( $maybeParent && $maybeParent->getHeadingLevel() >= $threadItem->getHeadingLevel() ) {
$maybeParent = $maybeParent->getParent();
}
@ -1180,6 +1031,7 @@ class CommentParser {
$threadItem->setParent( $maybeParent );
$maybeParent->addReply( $threadItem );
}
$lastHeading = $threadItem;
} elseif ( isset( $replies[ $threadItem->getLevel() - 1 ] ) ) {
// Add as a reply to the closest less-nested comment
$threadItem->setParent( $replies[ $threadItem->getLevel() - 1 ] );
@ -1192,25 +1044,26 @@ class CommentParser {
// Cut off more deeply nested replies
array_splice( $replies, $threadItem->getLevel() + 1 );
}
}
$this->threads = $threads;
foreach ( $this->threadItems as $threadItem ) {
/**
* Set the IDs and names used to refer to comments and headings.
* This has to be a separate pass because we don't have the list of replies before
* this point.
*
* @param ThreadItemSet $result
*/
private function computeIdsAndNames( ThreadItemSet $result ): void {
foreach ( $result->getThreadItems() as $threadItem ) {
$name = $this->computeName( $threadItem );
$threadItem->setName( $name );
$this->threadItemsByName[$name][] = $threadItem;
// Set the IDs used to refer to comments and headings.
// This has to be a separate pass because we don't have the list of replies before
// this point.
$id = $this->computeId( $threadItem );
$id = $this->computeId( $threadItem, $result );
$threadItem->setId( $id );
$this->threadItemsById[$id] = $threadItem;
$legacyId = $this->computeLegacyId( $threadItem );
$legacyId = $this->computeLegacyId( $threadItem, $result );
$threadItem->setLegacyId( $legacyId );
if ( $legacyId ) {
$this->threadItemsById[$legacyId] = $threadItem;
}
$result->updateIdAndNameMaps( $threadItem );
}
}

View file

@ -19,12 +19,12 @@ use ExtensionRegistry;
use IDBAccessObject;
use Iterator;
use MediaWiki\Extension\DiscussionTools\CommentItem;
use MediaWiki\Extension\DiscussionTools\CommentParser;
use MediaWiki\Extension\DiscussionTools\HeadingItem;
use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
use MediaWiki\Extension\DiscussionTools\SubscriptionItem;
use MediaWiki\Extension\DiscussionTools\SubscriptionStore;
use MediaWiki\Extension\DiscussionTools\ThreadItem;
use MediaWiki\Extension\DiscussionTools\ThreadItemSet;
use MediaWiki\MediaWikiServices;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Revision\RevisionRecord;
@ -39,9 +39,9 @@ use Wikimedia\Parsoid\Utils\DOMUtils;
class EventDispatcher {
/**
* @param RevisionRecord $revRecord
* @return CommentParser
* @return ThreadItemSet
*/
private static function getParsedRevision( RevisionRecord $revRecord ): CommentParser {
private static function getParsedRevision( RevisionRecord $revRecord ): ThreadItemSet {
$services = MediaWikiServices::getInstance();
$pageRecord = $services->getPageStore()->getPageById( $revRecord->getPageId() ) ?:
@ -100,17 +100,17 @@ class EventDispatcher {
}
if ( $oldRevRecord !== null ) {
$oldParser = self::getParsedRevision( $oldRevRecord );
$oldItemSet = self::getParsedRevision( $oldRevRecord );
} else {
// Page creation
$doc = DOMUtils::parseHTML( '' );
$container = DOMCompat::getBody( $doc );
$oldParser = $services->getService( 'DiscussionTools.CommentParser' )
$oldItemSet = $services->getService( 'DiscussionTools.CommentParser' )
->parse( $container, $title );
}
$newParser = self::getParsedRevision( $newRevRecord );
$newItemSet = self::getParsedRevision( $newRevRecord );
self::generateEventsFromParsers( $events, $oldParser, $newParser, $newRevRecord, $title, $user );
self::generateEventsFromItemSets( $events, $oldItemSet, $newItemSet, $newRevRecord, $title, $user );
}
/**
@ -143,22 +143,22 @@ class EventDispatcher {
* Helper for generateEventsForRevision(), separated out for easier testing.
*
* @param array &$events
* @param CommentParser $oldParser
* @param CommentParser $newParser
* @param ThreadItemSet $oldItemSet
* @param ThreadItemSet $newItemSet
* @param RevisionRecord $newRevRecord
* @param PageIdentity $title
* @param UserIdentity $user
*/
protected static function generateEventsFromParsers(
protected static function generateEventsFromItemSets(
array &$events,
CommentParser $oldParser,
CommentParser $newParser,
ThreadItemSet $oldItemSet,
ThreadItemSet $newItemSet,
RevisionRecord $newRevRecord,
PageIdentity $title,
UserIdentity $user
): void {
$newComments = self::groupCommentsByThreadAndName( $newParser->getThreadItems() );
$oldComments = self::groupCommentsByThreadAndName( $oldParser->getThreadItems() );
$newComments = self::groupCommentsByThreadAndName( $newItemSet->getThreadItems() );
$oldComments = self::groupCommentsByThreadAndName( $oldItemSet->getThreadItems() );
$addedComments = [];
foreach ( $newComments as $threadName => $threadNewComments ) {

176
includes/ThreadItemSet.php Normal file
View file

@ -0,0 +1,176 @@
<?php
namespace MediaWiki\Extension\DiscussionTools;
/**
* Groups thread items (headings and comments) generated by parsing a discussion page.
*/
class ThreadItemSet {
/** @var ThreadItem[] */
private $threadItems = [];
/** @var CommentItem[] */
private $commentItems = [];
/** @var ThreadItem[][] */
private $threadItemsByName = [];
/** @var ThreadItem[] */
private $threadItemsById = [];
/** @var HeadingItem[] */
private $threads = [];
/**
* @internal Only used by CommentParser
* @param ThreadItem $item
*/
public function addThreadItem( ThreadItem $item ) {
$this->threadItems[] = $item;
if ( $item instanceof CommentItem ) {
$this->commentItems[] = $item;
}
if ( $item instanceof HeadingItem ) {
$this->threads[] = $item;
}
}
/**
* @internal Only used by CommentParser
* @return bool
*/
public function isEmpty(): bool {
return !$this->threadItems;
}
/**
* @internal Only used by CommentParser
* @param ThreadItem $item
*/
public function updateIdAndNameMaps( ThreadItem $item ) {
$this->threadItemsByName[ $item->getName() ][] = $item;
$this->threadItemsById[ $item->getId() ] = $item;
$legacyId = $item->getLegacyId();
if ( $legacyId ) {
$this->threadItemsById[ $legacyId ] = $item;
}
}
/**
* Get all discussion comments (and headings) within a DOM subtree.
*
* This returns a flat list, use getThreads() to get a tree structure starting at section headings.
*
* For example, for a MediaWiki discussion like this (we're dealing with HTML DOM here,
* the wikitext syntax is just for illustration):
*
* == A ==
* B. ~~~~
* : C.
* : C. ~~~~
* :: D. ~~~~
* ::: E. ~~~~
* ::: F. ~~~~
* : G. ~~~~
* H. ~~~~
* : I. ~~~~
*
* This function would return a structure like:
*
* [
* HeadingItem( { level: 0, range: (h2: A) } ),
* CommentItem( { level: 1, range: (p: B) } ),
* CommentItem( { level: 2, range: (li: C, li: C) } ),
* CommentItem( { level: 3, range: (li: D) } ),
* CommentItem( { level: 4, range: (li: E) } ),
* CommentItem( { level: 4, range: (li: F) } ),
* CommentItem( { level: 2, range: (li: G) } ),
* CommentItem( { level: 1, range: (p: H) } ),
* CommentItem( { level: 2, range: (li: I) } )
* ]
*
* @return ThreadItem[] Thread items
*/
public function getThreadItems(): array {
return $this->threadItems;
}
/**
* Same as getFlatThreadItems, but only returns the CommentItems
*
* @return CommentItem[] Comment items
*/
public function getCommentItems(): array {
return $this->commentItems;
}
/**
* Find ThreadItems by their name
*
* This will usually return a single-element array, but it may return multiple comments if they're
* indistinguishable by name. In that case, use their IDs to disambiguate.
*
* @param string $name Name
* @return ThreadItem[] Thread items, empty array if not found
*/
public function findCommentsByName( string $name ): array {
return $this->threadItemsByName[$name] ?? [];
}
/**
* Find a ThreadItem by its ID
*
* @param string $id ID
* @return ThreadItem|null Thread item, null if not found
*/
public function findCommentById( string $id ): ?ThreadItem {
return $this->threadItemsById[$id] ?? null;
}
/**
* Group discussion comments into threads and associate replies to original messages.
*
* Each thread must begin with a heading. Original messages in the thread are treated as replies to
* its heading. Other replies are associated based on the order and indentation level.
*
* Note that the objects in `comments` are extended in-place with the additional data.
*
* For example, for a MediaWiki discussion like this (we're dealing with HTML DOM here,
* the wikitext syntax is just for illustration):
*
* == A ==
* B. ~~~~
* : C.
* : C. ~~~~
* :: D. ~~~~
* ::: E. ~~~~
* ::: F. ~~~~
* : G. ~~~~
* H. ~~~~
* : I. ~~~~
*
* This function would return a structure like:
*
* [
* HeadingItem( { level: 0, range: (h2: A), replies: [
* CommentItem( { level: 1, range: (p: B), replies: [
* CommentItem( { level: 2, range: (li: C, li: C), replies: [
* CommentItem( { level: 3, range: (li: D), replies: [
* CommentItem( { level: 4, range: (li: E), replies: [] } ),
* CommentItem( { level: 4, range: (li: F), replies: [] } ),
* ] } ),
* ] } ),
* CommentItem( { level: 2, range: (li: G), replies: [] } ),
* ] } ),
* CommentItem( { level: 1, range: (p: H), replies: [
* CommentItem( { level: 2, range: (li: I), replies: [] } ),
* ] } ),
* ] } )
* ]
*
* @return HeadingItem[] Tree structure of comments, top-level items are the headings.
*/
public function getThreads(): array {
return $this->threads;
}
}

View file

@ -22,6 +22,7 @@
"DmMWPingNode": "DmMWPingNode",
"HeadingItem": "HeadingItem",
"moment": "moment",
"ThreadItemSet": "ThreadItemSet",
"ThreadItem": "ThreadItem"
}
}

View file

@ -38,15 +38,15 @@ if ( defaultVisual || enable2017Wikitext ) {
*
* @param {jQuery} $pageContainer Page container
* @param {ThreadItem} threadItem Thread item to attach new comment to
* @param {mw.dt.Parser} parser Comment parser
* @param {ThreadItemSet} threadItemSet
*/
function CommentController( $pageContainer, threadItem, parser ) {
function CommentController( $pageContainer, threadItem, threadItemSet ) {
// Mixin constructors
OO.EventEmitter.call( this );
this.$pageContainer = $pageContainer;
this.threadItem = threadItem;
this.parser = parser;
this.threadItemSet = threadItemSet;
this.newListItem = null;
this.replyWidgetPromise = null;
}
@ -275,7 +275,7 @@ CommentController.prototype.teardown = function ( mode ) {
CommentController.prototype.getApiQuery = function ( pageName, checkboxes ) {
var threadItem = this.getThreadItem();
var replyWidget = this.replyWidget;
var sameNameComments = this.parser.findCommentsByName( threadItem.name );
var sameNameComments = this.threadItemSet.findCommentsByName( threadItem.name );
var mode = replyWidget.getMode();
var tags = [

View file

@ -9,9 +9,9 @@ var
* Handles setup, save and teardown of new topic widget
*
* @param {jQuery} $pageContainer Page container
* @param {mw.dt.Parser} parser Comment parser
* @param {ThreadItemSet} threadItemSet
*/
function NewTopicController( $pageContainer, parser ) {
function NewTopicController( $pageContainer, threadItemSet ) {
this.container = new OO.ui.PanelLayout( {
classes: [ 'ext-discussiontools-ui-newTopic' ],
expanded: false,
@ -45,7 +45,7 @@ function NewTopicController( $pageContainer, parser ) {
threadItem.id = utils.NEW_TOPIC_COMMENT_ID;
threadItem.isNewTopic = true;
NewTopicController.super.call( this, $pageContainer, threadItem, parser );
NewTopicController.super.call( this, $pageContainer, threadItem, threadItemSet );
}
OO.inheritClass( NewTopicController, CommentController );

View file

@ -8,6 +8,7 @@ var
CommentItem = require( './CommentItem.js' ),
HeadingItem = require( './HeadingItem.js' ),
ThreadItem = require( './ThreadItem.js' ),
ThreadItemSet = require( './ThreadItemSet.js' ),
moment = require( './lib/moment-timezone/moment-timezone-with-data-1970-2030.js' );
/**
@ -33,12 +34,12 @@ function Parser( data ) {
Parser.prototype.parse = function ( rootNode, title ) {
this.rootNode = rootNode;
this.title = title;
this.threadItems = null;
this.commentItems = null;
this.threadItemsByName = null;
this.threadItemsById = null;
this.threads = null;
return this;
var result = this.buildThreadItems();
this.buildThreads( result );
this.computeIdsAndNames( result );
return result;
};
OO.initClass( Parser );
@ -702,89 +703,6 @@ Parser.prototype.nextInterestingLeafNode = function ( node ) {
return treeWalker.currentNode;
};
/**
* Get all discussion comments (and headings) within a DOM subtree.
*
* This returns a flat list, use #getThreads to get a tree structure starting at section headings.
*
* For example, for a MediaWiki discussion like this (we're dealing with HTML DOM here, the wikitext
* syntax is just for illustration):
*
* == A ==
* B. ~~~~
* : C.
* : C. ~~~~
* :: D. ~~~~
* ::: E. ~~~~
* ::: F. ~~~~
* : G. ~~~~
* H. ~~~~
* : I. ~~~~
*
* This function would return a structure like:
*
* [
* HeadingItem( { level: 0, range: (h2: A) } ),
* CommentItem( { level: 1, range: (p: B) } ),
* CommentItem( { level: 2, range: (li: C, li: C) } ),
* CommentItem( { level: 3, range: (li: D) } ),
* CommentItem( { level: 4, range: (li: E) } ),
* CommentItem( { level: 4, range: (li: F) } ),
* CommentItem( { level: 2, range: (li: G) } ),
* CommentItem( { level: 1, range: (p: H) } ),
* CommentItem( { level: 2, range: (li: I) } )
* ]
*
* @return {ThreadItem[]} Thread items
*/
Parser.prototype.getThreadItems = function () {
if ( !this.threadItems ) {
this.buildThreads();
}
return this.threadItems;
};
/**
* Same as getFlatThreadItems, but only returns the CommentItems
*
* @return {CommentItem[]} Comment items
*/
Parser.prototype.getCommentItems = function () {
if ( !this.commentItems ) {
this.buildThreads();
}
return this.commentItems;
};
/**
* Find ThreadItems by their name
*
* This will usually return a single-element array, but it may return multiple comments if they're
* indistinguishable by name. In that case, use their IDs to disambiguate.
*
* @param {string} name Name
* @return {ThreadItem[]} Thread items, empty array if not found
*/
Parser.prototype.findCommentsByName = function ( name ) {
if ( !this.threadItemsByName ) {
this.buildThreads();
}
return this.threadItemsByName[ name ] || [];
};
/**
* Find a ThreadItem by its ID
*
* @param {string} id ID
* @return {ThreadItem|null} Thread item, null if not found
*/
Parser.prototype.findCommentById = function ( id ) {
if ( !this.threadItemsById ) {
this.buildThreads();
}
return this.threadItemsById[ id ] || null;
};
/**
* @param {Node[]} sigNodes
* @param {Object} match
@ -808,12 +726,15 @@ function adjustSigRange( sigNodes, match, node ) {
return sigRange;
}
/**
* @return {ThreadItemSet}
*/
Parser.prototype.buildThreadItems = function () {
var result = new ThreadItemSet();
var
dfParsers = this.getLocalTimestampParsers(),
timestampRegexps = this.getLocalTimestampRegexps(),
commentItems = [],
threadItems = [];
timestampRegexps = this.getLocalTimestampRegexps();
var treeWalker = this.rootNode.ownerDocument.createTreeWalker(
this.rootNode,
@ -823,18 +744,8 @@ Parser.prototype.buildThreadItems = function () {
false
);
// Placeholder heading in case there are comments in the 0th section
var range = {
startContainer: this.rootNode,
startOffset: 0,
endContainer: this.rootNode,
endOffset: 0
};
var fakeHeading = new HeadingItem( range, 99, true );
fakeHeading.rootNode = this.rootNode;
var curComment = fakeHeading;
var curCommentEnd = range.endContainer;
var curComment, range;
var curCommentEnd = this.rootNode;
var node, lastSigNode;
while ( ( node = treeWalker.nextNode() ) ) {
@ -851,7 +762,7 @@ Parser.prototype.buildThreadItems = function () {
};
curComment = new HeadingItem( range, +match[ 1 ] );
curComment.rootNode = this.rootNode;
threadItems.push( curComment );
result.addThreadItem( curComment );
curCommentEnd = node;
} else if ( node.nodeType === Node.TEXT_NODE && ( match = this.findTimestamp( node, timestampRegexps ) ) ) {
var warnings = [];
@ -945,70 +856,25 @@ Parser.prototype.buildThreadItems = function () {
if ( warnings.length ) {
curComment.warnings = warnings;
}
commentItems.push( curComment );
threadItems.push( curComment );
if ( result.isEmpty() ) {
// Add a fake placeholder heading if there are any comments in the 0th section
// (before the first real heading)
range = {
startContainer: this.rootNode,
startOffset: 0,
endContainer: this.rootNode,
endOffset: 0
};
var fakeHeading = new HeadingItem( range, 99, true );
fakeHeading.rootNode = this.rootNode;
result.addThreadItem( fakeHeading );
}
result.addThreadItem( curComment );
curCommentEnd = curComment.range.endContainer;
}
}
// Insert the fake placeholder heading if there are any comments in the 0th section
// (before the first real heading)
if ( threadItems.length && !( threadItems[ 0 ] instanceof HeadingItem ) ) {
threadItems.unshift( fakeHeading );
}
this.commentItems = commentItems;
this.threadItems = threadItems;
};
/**
* Group discussion comments into threads and associate replies to original messages.
*
* Each thread must begin with a heading. Original messages in the thread are treated as replies to
* its heading. Other replies are associated based on the order and indentation level.
*
* Note that the objects in `comments` are extended in-place with the additional data.
*
* For example, for a MediaWiki discussion like this (we're dealing with HTML DOM here, the wikitext
* syntax is just for illustration):
*
* == A ==
* B. ~~~~
* : C.
* : C. ~~~~
* :: D. ~~~~
* ::: E. ~~~~
* ::: F. ~~~~
* : G. ~~~~
* H. ~~~~
* : I. ~~~~
*
* This function would return a structure like:
*
* [
* HeadingItem( { level: 0, range: (h2: A), replies: [
* CommentItem( { level: 1, range: (p: B), replies: [
* CommentItem( { level: 2, range: (li: C, li: C), replies: [
* CommentItem( { level: 3, range: (li: D), replies: [
* CommentItem( { level: 4, range: (li: E), replies: [] } ),
* CommentItem( { level: 4, range: (li: F), replies: [] } ),
* ] } ),
* ] } ),
* CommentItem( { level: 2, range: (li: G), replies: [] } ),
* ] } ),
* CommentItem( { level: 1, range: (p: H), replies: [
* CommentItem( { level: 2, range: (li: I), replies: [] } ),
* ] } ),
* ] } )
* ]
*
* @return {HeadingItem[]} Tree structure of comments, top-level items are the headings.
*/
Parser.prototype.getThreads = function () {
if ( !this.threads ) {
this.buildThreads();
}
return this.threads;
return result;
};
/**
@ -1025,9 +891,10 @@ Parser.prototype.truncateForId = function ( text ) {
* Given a thread item, return an identifier for it that is unique within the page.
*
* @param {ThreadItem} threadItem
* @param {ThreadItemSet} previousItems
* @return {string}
*/
Parser.prototype.computeId = function ( threadItem ) {
Parser.prototype.computeId = function ( threadItem, previousItems ) {
var id, headline;
if ( threadItem instanceof HeadingItem && threadItem.placeholderHeading ) {
@ -1063,12 +930,12 @@ Parser.prototype.computeId = function ( threadItem ) {
}
}
if ( this.threadItemsById[ id ] ) {
if ( previousItems.findCommentById( id ) ) {
// Well, that's tough
threadItem.warnings.push( 'Duplicate comment ID' );
// Finally, disambiguate by adding sequential numbers, to allow replying to both comments
var number = 1;
while ( this.threadItemsById[ id + '-' + number ] ) {
while ( previousItems.findCommentById( id + '-' + number ) ) {
number++;
}
id = id + '-' + number;
@ -1107,22 +974,16 @@ Parser.prototype.computeName = function ( threadItem ) {
return name;
};
Parser.prototype.buildThreads = function () {
var
threads = [],
replies = [];
if ( !this.threadItems ) {
this.buildThreadItems();
}
this.threadItemsById = {};
this.threadItemsByName = {};
/**
* @param {ThreadItemSet} result
*/
Parser.prototype.buildThreads = function ( result ) {
var lastHeading = null;
var replies = [];
var i, threadItem;
for ( i = 0; i < this.threadItems.length; i++ ) {
threadItem = this.threadItems[ i ];
for ( i = 0; i < result.threadItems.length; i++ ) {
threadItem = result.threadItems[ i ];
if ( replies.length < threadItem.level ) {
// Someone skipped an indentation level (or several). Pretend that the previous reply
@ -1135,10 +996,9 @@ Parser.prototype.buildThreads = function () {
if ( threadItem instanceof HeadingItem ) {
// New root (thread)
threads.push( threadItem );
// Attach as a sub-thread to preceding higher-level heading.
// Any replies will appear in the tree twice, under the main-thread and the sub-thread.
var maybeParent = threads.length > 1 ? threads[ threads.length - 2 ] : null;
var maybeParent = lastHeading;
while ( maybeParent && maybeParent.headingLevel >= threadItem.headingLevel ) {
maybeParent = maybeParent.parent;
}
@ -1146,6 +1006,7 @@ Parser.prototype.buildThreads = function () {
threadItem.parent = maybeParent;
maybeParent.replies.push( threadItem );
}
lastHeading = threadItem;
} else if ( replies[ threadItem.level - 1 ] ) {
// Add as a reply to the closest less-nested comment
threadItem.parent = replies[ threadItem.level - 1 ];
@ -1158,25 +1019,27 @@ Parser.prototype.buildThreads = function () {
// Cut off more deeply nested replies
replies.length = threadItem.level + 1;
}
};
this.threads = threads;
for ( i = 0; i < this.threadItems.length; i++ ) {
threadItem = this.threadItems[ i ];
/**
* Set the IDs and names used to refer to comments and headings.
* This has to be a separate pass because we don't have the list of replies before
* this point.
*
* @param {ThreadItemSet} result
*/
Parser.prototype.computeIdsAndNames = function ( result ) {
var i, threadItem;
for ( i = 0; i < result.threadItems.length; i++ ) {
threadItem = result.threadItems[ i ];
var name = this.computeName( threadItem );
threadItem.name = name;
if ( !this.threadItemsByName[ name ] ) {
this.threadItemsByName[ name ] = [];
}
this.threadItemsByName[ name ].push( threadItem );
// Set the IDs used to refer to comments and headings.
// This has to be a separate pass because we don't have the list of replies before
// this point.
var id = this.computeId( threadItem );
var id = this.computeId( threadItem, result );
threadItem.id = id;
this.threadItemsById[ id ] = threadItem;
result.updateIdAndNameMaps( threadItem );
}
};

View file

@ -28,6 +28,10 @@ function ThreadItem( type, level, range ) {
* @member {string} Unique ID (within the page) for this comment
*/
this.id = null;
/**
* @member {string|null} Unique ID (within the page) for this comment, according to an older algorithm
*/
this.legacyId = null;
/**
* @member {ThreadItem[]} Replies to this thread item
*/
@ -47,11 +51,10 @@ OO.initClass( ThreadItem );
* Create a new ThreadItem from a JSON serialization
*
* @param {string|Object} json JSON serialization or hash object
* @param {Object.<string,ThreadItem>} commentsById Collection of comments by ID for building replies/parent pointers
* @return {ThreadItem}
* @throws {Error} Unknown ThreadItem type
*/
ThreadItem.static.newFromJSON = function ( json, commentsById ) {
ThreadItem.static.newFromJSON = function ( json ) {
// The page can be served from the HTTP cache (Varnish), and the JSON may be generated
// by an older version of our PHP code. Code below must be able to handle that.
// See ThreadItem::jsonSerialize() in PHP.
@ -98,12 +101,6 @@ ThreadItem.static.newFromJSON = function ( json, commentsById ) {
endOffset: 0
};
// Setup replies/parent pointers
item.replies = hash.replies.map( function ( id ) {
commentsById[ id ].parent = item;
return commentsById[ id ];
} );
return item;
};

231
modules/ThreadItemSet.js Normal file
View file

@ -0,0 +1,231 @@
var CommentItem = require( './CommentItem.js' );
var HeadingItem = require( './HeadingItem.js' );
var ThreadItem = require( './ThreadItem.js' );
/**
* Groups thread items (headings and comments) generated by parsing a discussion page.
*
* @class ThreadItemSet
*/
function ThreadItemSet() {
this.threadItems = [];
this.commentItems = [];
this.threadItemsByName = {};
this.threadItemsById = {};
this.threads = [];
}
OO.initClass( ThreadItemSet );
/**
* Created a ThreadItemSet from DOM nodes that have been annotated by the PHP CommentFormatter with
* metadata about the thread structure.
*
* @param {HTMLElement} nodes
* @param {mw.dt.Parser} parser
* @return {ThreadItemSet}
*/
ThreadItemSet.static.newFromAnnotatedNodes = function ( nodes, parser ) {
var result = new ThreadItemSet();
// The page can be served from the HTTP cache (Varnish), containing data-mw-comment generated
// by an older version of our PHP code. Code below must be able to handle that.
// See CommentFormatter::addDiscussionTools() in PHP.
var i, item;
var items = [];
var replyIds = [];
var itemsById = {};
// Create ThreadItem objects with basic data
for ( i = 0; i < nodes.length; i++ ) {
var hash = JSON.parse( nodes[ i ].getAttribute( 'data-mw-comment' ) );
item = ThreadItem.static.newFromJSON( hash );
result.addThreadItem( item );
// Store info for second pass
items[ i ] = item;
replyIds[ i ] = hash.replies;
itemsById[ item.id ] = item;
}
// Now that we have all objects, we can set up replies/parent pointers
for ( i = 0; i < nodes.length; i++ ) {
item = items[ i ];
// eslint-disable-next-line no-loop-func
item.replies = replyIds[ i ].map( function ( id ) {
itemsById[ id ].parent = item;
return itemsById[ id ];
} );
// Recalculate legacy IDs (and calculate names, currently not stored in the metadata)
var newId = parser.computeId( item, result );
if ( newId !== item.id ) {
item.legacyId = item.id;
item.id = newId;
}
item.name = parser.computeName( item );
result.updateIdAndNameMaps( item );
}
return result;
};
/**
* @param {ThreadItem} item
*/
ThreadItemSet.prototype.addThreadItem = function ( item ) {
this.threadItems.push( item );
if ( item instanceof CommentItem ) {
this.commentItems.push( item );
}
if ( item instanceof HeadingItem ) {
this.threads.push( item );
}
};
/**
* @return {boolean}
*/
ThreadItemSet.prototype.isEmpty = function () {
return this.threadItems.length === 0;
};
/**
* @param {ThreadItem} item
*/
ThreadItemSet.prototype.updateIdAndNameMaps = function ( item ) {
if ( !this.threadItemsByName[ item.name ] ) {
this.threadItemsByName[ item.name ] = [];
}
this.threadItemsByName[ item.name ].push( item );
this.threadItemsById[ item.id ] = item;
if ( item.legacyId ) {
this.threadItemsById[ item.legacyId ] = item;
}
};
/**
* Get all discussion comments (and headings) within a DOM subtree.
*
* This returns a flat list, use #getThreads to get a tree structure starting at section headings.
*
* For example, for a MediaWiki discussion like this (we're dealing with HTML DOM here, the wikitext
* syntax is just for illustration):
*
* == A ==
* B. ~~~~
* : C.
* : C. ~~~~
* :: D. ~~~~
* ::: E. ~~~~
* ::: F. ~~~~
* : G. ~~~~
* H. ~~~~
* : I. ~~~~
*
* This function would return a structure like:
*
* [
* HeadingItem( { level: 0, range: (h2: A) } ),
* CommentItem( { level: 1, range: (p: B) } ),
* CommentItem( { level: 2, range: (li: C, li: C) } ),
* CommentItem( { level: 3, range: (li: D) } ),
* CommentItem( { level: 4, range: (li: E) } ),
* CommentItem( { level: 4, range: (li: F) } ),
* CommentItem( { level: 2, range: (li: G) } ),
* CommentItem( { level: 1, range: (p: H) } ),
* CommentItem( { level: 2, range: (li: I) } )
* ]
*
* @return {ThreadItem[]} Thread items
*/
ThreadItemSet.prototype.getThreadItems = function () {
return this.threadItems;
};
/**
* Same as getFlatThreadItems, but only returns the CommentItems
*
* @return {CommentItem[]} Comment items
*/
ThreadItemSet.prototype.getCommentItems = function () {
return this.commentItems;
};
/**
* Find ThreadItems by their name
*
* This will usually return a single-element array, but it may return multiple comments if they're
* indistinguishable by name. In that case, use their IDs to disambiguate.
*
* @param {string} name Name
* @return {ThreadItem[]} Thread items, empty array if not found
*/
ThreadItemSet.prototype.findCommentsByName = function ( name ) {
return this.threadItemsByName[ name ] || [];
};
/**
* Find a ThreadItem by its ID
*
* @param {string} id ID
* @return {ThreadItem|null} Thread item, null if not found
*/
ThreadItemSet.prototype.findCommentById = function ( id ) {
return this.threadItemsById[ id ] || null;
};
/**
* Group discussion comments into threads and associate replies to original messages.
*
* Each thread must begin with a heading. Original messages in the thread are treated as replies to
* its heading. Other replies are associated based on the order and indentation level.
*
* Note that the objects in `comments` are extended in-place with the additional data.
*
* For example, for a MediaWiki discussion like this (we're dealing with HTML DOM here, the wikitext
* syntax is just for illustration):
*
* == A ==
* B. ~~~~
* : C.
* : C. ~~~~
* :: D. ~~~~
* ::: E. ~~~~
* ::: F. ~~~~
* : G. ~~~~
* H. ~~~~
* : I. ~~~~
*
* This function would return a structure like:
*
* [
* HeadingItem( { level: 0, range: (h2: A), replies: [
* CommentItem( { level: 1, range: (p: B), replies: [
* CommentItem( { level: 2, range: (li: C, li: C), replies: [
* CommentItem( { level: 3, range: (li: D), replies: [
* CommentItem( { level: 4, range: (li: E), replies: [] } ),
* CommentItem( { level: 4, range: (li: F), replies: [] } ),
* ] } ),
* ] } ),
* CommentItem( { level: 2, range: (li: G), replies: [] } ),
* ] } ),
* CommentItem( { level: 1, range: (p: H), replies: [
* CommentItem( { level: 2, range: (li: I), replies: [] } ),
* ] } ),
* ] } )
* ]
*
* @return {HeadingItem[]} Tree structure of comments, top-level items are the headings.
*/
ThreadItemSet.prototype.getThreads = function () {
return this.threads;
};
module.exports = ThreadItemSet;

View file

@ -8,7 +8,7 @@ var
MemoryStorage = require( './MemoryStorage.js' ),
storage = new MemoryStorage( mw.storage.session.store ),
Parser = require( './Parser.js' ),
ThreadItem = require( './ThreadItem.js' ),
ThreadItemSet = require( './ThreadItemSet.js' ),
CommentItem = require( './CommentItem.js' ),
CommentDetails = require( './CommentDetails.js' ),
ReplyLinksController = require( './ReplyLinksController.js' ),
@ -516,10 +516,10 @@ var $highlightedTarget = null;
/**
* Highlight the comment on the page associated with the URL hash
*
* @param {mw.dt.Parser} parser Comment parser
* @param {ThreadItemSet} threadItemSet
* @param {boolean} [noScroll] Don't scroll to the topmost highlighted comment, e.g. on popstate
*/
function highlightTargetComment( parser, noScroll ) {
function highlightTargetComment( threadItemSet, noScroll ) {
// Delay with setTimeout() because "the Document's target element" (corresponding to the :target
// selector in CSS) is not yet updated to match the URL when handling a 'popstate' event.
setTimeout( function () {
@ -539,13 +539,13 @@ function highlightTargetComment( parser, noScroll ) {
}
var targetIds = uri && uri.query.dtnewcomments && uri.query.dtnewcomments.split( '|' );
if ( targetElement && targetElement.hasAttribute( 'data-mw-comment-start' ) ) {
var comment = parser.findCommentById( targetElement.getAttribute( 'id' ) );
var comment = threadItemSet.findCommentById( targetElement.getAttribute( 'id' ) );
$highlightedTarget = highlight( comment );
$highlightedTarget.addClass( 'ext-discussiontools-init-targetcomment' );
$highlightedTarget.addClass( 'ext-discussiontools-init-highlight-fadein' );
} else if ( targetIds ) {
var comments = targetIds.map( function ( id ) {
return parser.findCommentById( id );
return threadItemSet.findCommentById( id );
} ).filter( function ( cmt ) {
return !!cmt;
} );
@ -576,9 +576,9 @@ function highlightTargetComment( parser, noScroll ) {
/**
* Clear the highlighting of the comment in the URL hash
*
* @param {mw.dt.Parser} parser Comment parser
* @param {ThreadItemSet} threadItemSet
*/
function clearHighlightTargetComment( parser ) {
function clearHighlightTargetComment( threadItemSet ) {
// eslint-disable-next-line no-jquery/no-global-selector
var targetElement = $( ':target' )[ 0 ];
if ( targetElement && targetElement.hasAttribute( 'data-mw-comment-start' ) ) {
@ -597,7 +597,7 @@ function clearHighlightTargetComment( parser ) {
} else if ( window.location.search.match( /(^\?|&)dtnewcomments=/ ) ) {
history.pushState( null, document.title,
window.location.search.replace( /(^\?|&)dtnewcomments=[^&]+/, '' ) + window.location.hash );
highlightTargetComment( parser );
highlightTargetComment( threadItemSet );
}
}
@ -614,65 +614,17 @@ function init( $container, state ) {
activeController = null,
// Loads later to avoid circular dependency
CommentController = require( './CommentController.js' ),
NewTopicController = require( './NewTopicController.js' ),
threadItemsById = {},
threadItems = [];
NewTopicController = require( './NewTopicController.js' );
// Lazy-load postEdit module, may be required later (on desktop)
mw.loader.using( 'mediawiki.action.view.postEdit' );
$pageContainer = $container;
linksController = new ReplyLinksController( $pageContainer );
var parser = new Parser( require( './parser/data.json' ) ).parse(
$pageContainer[ 0 ],
mw.Title.newFromText( mw.config.get( 'wgRelevantPageName' ) )
);
var parser = new Parser( require( './parser/data.json' ) );
var pageThreads = [];
var commentNodes = $pageContainer[ 0 ].querySelectorAll( '[data-mw-comment]' );
threadItems.length = commentNodes.length;
// The page can be served from the HTTP cache (Varnish), containing data-mw-comment generated
// by an older version of our PHP code. Code below must be able to handle that.
// See CommentFormatter::addDiscussionTools() in PHP.
// Iterate over commentNodes backwards so replies are always deserialized before their parents.
var i, comment;
for ( i = commentNodes.length - 1; i >= 0; i-- ) {
var hash = JSON.parse( commentNodes[ i ].getAttribute( 'data-mw-comment' ) );
comment = ThreadItem.static.newFromJSON( hash, threadItemsById );
if ( !comment.name ) {
comment.name = parser.computeName( comment );
}
threadItemsById[ comment.id ] = comment;
if ( comment.type === 'heading' ) {
// Use unshift as we are in a backwards loop
pageThreads.unshift( comment );
}
threadItems[ i ] = comment;
}
// Recalculate legacy IDs
parser.threadItemsByName = {};
parser.threadItemsById = {};
// In the forward order this time, as the IDs for indistinguishable comments depend on it
for ( i = 0; i < threadItems.length; i++ ) {
comment = threadItems[ i ];
if ( !parser.threadItemsByName[ comment.name ] ) {
parser.threadItemsByName[ comment.name ] = [];
}
parser.threadItemsByName[ comment.name ].push( comment );
var newId = parser.computeId( comment );
parser.threadItemsById[ newId ] = comment;
if ( newId !== comment.id ) {
comment.id = newId;
threadItemsById[ newId ] = comment;
}
}
var result = ThreadItemSet.static.newFromAnnotatedNodes( commentNodes, parser );
if ( featuresEnabled.topicsubscription ) {
initTopicSubscriptions( $container );
@ -693,9 +645,9 @@ function init( $container, state ) {
$addSectionLink = $( '#ca-addsection a' );
// When opening new topic tool using any link, always activate the link in page tabs too
$link = $link.add( $addSectionLink );
commentController = new NewTopicController( $pageContainer, parser );
commentController = new NewTopicController( $pageContainer, result );
} else {
commentController = new CommentController( $pageContainer, parser.findCommentById( commentId ), parser );
commentController = new CommentController( $pageContainer, result.findCommentById( commentId ), result );
}
activeCommentId = commentId;
@ -749,8 +701,8 @@ function init( $container, state ) {
// Restore autosave
( function () {
var mode, $link;
for ( i = 0; i < threadItems.length; i++ ) {
comment = threadItems[ i ];
for ( var i = 0; i < result.threadItems.length; i++ ) {
var comment = result.threadItems[ i ];
if ( storage.get( 'reply/' + comment.id + '/saveable' ) ) {
mode = storage.get( 'reply/' + comment.id + '/mode' );
$link = $( commentNodes[ i ] );
@ -767,7 +719,7 @@ function init( $container, state ) {
}() );
// For debugging (now unused in the code)
mw.dt.pageThreads = pageThreads;
mw.dt.pageThreads = result;
var promise = OO.ui.isMobile() && mw.loader.getState( 'mobile.init' ) ?
mw.loader.using( 'mobile.init' ) :
@ -777,7 +729,7 @@ function init( $container, state ) {
var $highlight;
if ( state.repliedTo === utils.NEW_TOPIC_COMMENT_ID ) {
// Highlight the last comment on the page
var lastComment = threadItems[ threadItems.length - 1 ];
var lastComment = result.threadItems[ result.threadItems.length - 1 ];
$highlight = highlight( lastComment );
lastHighlightComment = lastComment;
@ -798,7 +750,7 @@ function init( $container, state ) {
} else if ( state.repliedTo ) {
// Find the comment we replied to, then highlight the last reply
var repliedToComment = threadItemsById[ state.repliedTo ];
var repliedToComment = result.threadItemsById[ state.repliedTo ];
$highlight = highlight( repliedToComment.replies[ repliedToComment.replies.length - 1 ] );
lastHighlightComment = repliedToComment.replies[ repliedToComment.replies.length - 1 ];
@ -850,19 +802,19 @@ function init( $container, state ) {
if ( state.repliedTo ) {
// Edited by using the reply tool or new topic tool. Only check the edited topic.
if ( state.repliedTo === utils.NEW_TOPIC_COMMENT_ID ) {
recentComments.push( threadItems[ threadItems.length - 1 ] );
recentComments.push( result.threadItems[ result.threadItems.length - 1 ] );
} else {
recentComments.push( threadItemsById[ state.repliedTo ] );
recentComments.push( result.threadItemsById[ state.repliedTo ] );
}
} else if ( mw.config.get( 'wgPostEdit' ) ) {
// Edited by using wikitext editor. Check topics with their own comments within last minute.
for ( i = 0; i < threadItems.length; i++ ) {
for ( var i = 0; i < result.threadItems.length; i++ ) {
if (
threadItems[ i ] instanceof CommentItem &&
threadItems[ i ].author === mw.user.getName() &&
threadItems[ i ].timestamp.isSameOrAfter( moment().subtract( 1, 'minute' ), 'minute' )
result.threadItems[ i ] instanceof CommentItem &&
result.threadItems[ i ].author === mw.user.getName() &&
result.threadItems[ i ].timestamp.isSameOrAfter( moment().subtract( 1, 'minute' ), 'minute' )
) {
recentComments.push( threadItems[ i ] );
recentComments.push( result.threadItems[ i ] );
}
}
}
@ -885,15 +837,15 @@ function init( $container, state ) {
);
$( window ).on( 'popstate', function () {
highlightTargetComment( parser, true );
highlightTargetComment( result, true );
} );
// eslint-disable-next-line no-jquery/no-global-selector
$( 'body' ).on( 'click', function ( e ) {
if ( utils.isUnmodifiedLeftClick( e ) ) {
clearHighlightTargetComment( parser );
clearHighlightTargetComment( result );
}
} );
highlightTargetComment( parser );
highlightTargetComment( result );
}
/**

View file

@ -3,12 +3,13 @@ var
modifier = require( 'ext.discussionTools.init' ).modifier,
utils = require( 'ext.discussionTools.init' ).utils,
highlighter = require( './highlighter.js' ),
parser = new Parser( require( 'ext.discussionTools.init' ).parserData ).parse(
parser = new Parser( require( 'ext.discussionTools.init' ).parserData ),
result = parser.parse(
document.getElementById( 'mw-content-text' ),
mw.Title.newFromText( mw.config.get( 'wgRelevantPageName' ) )
),
comments = parser.getCommentItems(),
threads = parser.getThreads(),
comments = result.getCommentItems(),
threads = result.getThreads(),
timestampRegexps = parser.getLocalTimestampRegexps(),
debug = +( new mw.Uri().query.dtdebug ),
DEBUG_HIGHLIGHT = 1,

View file

@ -36,8 +36,8 @@ class CommentModifierTest extends IntegrationTestCase {
$doc = self::createDocument( $dom );
$container = DOMCompat::getBody( $doc );
$parser = self::createParser( $data )->parse( $container, $title );
$comments = $parser->getCommentItems();
$threadItemSet = self::createParser( $data )->parse( $container, $title );
$comments = $threadItemSet->getCommentItems();
foreach ( $comments as $comment ) {
$node = CommentModifier::addListItem( $comment, $replyIndentation );
@ -81,8 +81,8 @@ class CommentModifierTest extends IntegrationTestCase {
$doc = self::createDocument( $dom );
$container = DOMCompat::getBody( $doc );
$parser = self::createParser( $data )->parse( $container, $title );
$comments = $parser->getCommentItems();
$threadItemSet = self::createParser( $data )->parse( $container, $title );
$comments = $threadItemSet->getCommentItems();
foreach ( $comments as $comment ) {
$linkNode = $doc->createElement( 'a' );

View file

@ -182,7 +182,10 @@ class CommentParserTest extends IntegrationTestCase {
/**
* @dataProvider provideComments
* @covers ::getThreads
* @covers ::parse
* @covers ::buildThreadItems
* @covers ::buildThreads
* @covers ::computeIdsAndNames
*/
public function testGetThreads(
string $name, string $title, string $dom, string $expected, string $config, string $data
@ -198,8 +201,8 @@ class CommentParserTest extends IntegrationTestCase {
$body = DOMCompat::getBody( $doc );
$this->setupEnv( $config, $data );
$parser = self::createParser( $data )->parse( $body, $title );
$threads = $parser->getThreads();
$threadItemSet = self::createParser( $data )->parse( $body, $title );
$threads = $threadItemSet->getThreads();
$processedThreads = [];

View file

@ -19,7 +19,7 @@ class EventDispatcherTest extends IntegrationTestCase {
/**
* @dataProvider provideGenerateCases
* @covers ::generateEventsFromParsers
* @covers ::generateEventsFromItemSets
*/
public function testGenerateEventsFromParsers(
string $rev1, string $rev2, string $authorUsername, string $other, string $expected
@ -40,8 +40,9 @@ class EventDispatcherTest extends IntegrationTestCase {
$dummyTitle = Title::newFromText( 'Dummy' );
$this->setupEnv( $config, $data );
$parser1 = self::createParser( $data )->parse( $body1, $dummyTitle );
$parser2 = self::createParser( $data )->parse( $body2, $dummyTitle );
$parser = self::createParser( $data );
$itemSet1 = $parser->parse( $body1, $dummyTitle );
$itemSet2 = $parser->parse( $body2, $dummyTitle );
$events = self::getJson( $other, true );
@ -50,8 +51,8 @@ class EventDispatcherTest extends IntegrationTestCase {
$fakeRevRecord = new MutableRevisionRecord( $fakeTitle );
// All mock comments are posted between 00:00 and 00:10 on 2020-01-01
$fakeRevRecord->setTimestamp( ( new DateTimeImmutable( '2020-01-01T00:10' ) )->format( 'c' ) );
MockEventDispatcher::generateEventsFromParsers(
$events, $parser1, $parser2, $fakeRevRecord, $fakeTitle, $fakeUser
MockEventDispatcher::generateEventsFromItemSets(
$events, $itemSet1, $itemSet2, $fakeRevRecord, $fakeTitle, $fakeUser
);
foreach ( $events as &$event ) {
@ -68,8 +69,8 @@ class EventDispatcherTest extends IntegrationTestCase {
// Assert that no events are generated for comments saved >10 minutes after their timestamps
$events = self::getJson( $other, true );
$fakeRevRecord->setTimestamp( ( new DateTimeImmutable( '2020-01-01T00:20' ) )->format( 'c' ) );
MockEventDispatcher::generateEventsFromParsers(
$events, $parser1, $parser2, $fakeRevRecord, $fakeTitle, $fakeUser
MockEventDispatcher::generateEventsFromItemSets(
$events, $itemSet1, $itemSet2, $fakeRevRecord, $fakeTitle, $fakeUser
);
foreach ( $events as &$event ) {

View file

@ -2,8 +2,8 @@
namespace MediaWiki\Extension\DiscussionTools\Tests;
use MediaWiki\Extension\DiscussionTools\CommentParser;
use MediaWiki\Extension\DiscussionTools\Notifications\EventDispatcher;
use MediaWiki\Extension\DiscussionTools\ThreadItemSet;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\User\UserIdentity;
@ -19,24 +19,24 @@ class MockEventDispatcher extends EventDispatcher {
* ... expected to be a reference, value given").
*
* @param array &$events
* @param CommentParser $oldParser
* @param CommentParser $newParser
* @param ThreadItemSet $oldItemSet
* @param ThreadItemSet $newItemSet
* @param RevisionRecord $newRevRecord
* @param PageIdentity $title
* @param UserIdentity $user
*/
public static function generateEventsFromParsers(
public static function generateEventsFromItemSets(
array &$events,
CommentParser $oldParser,
CommentParser $newParser,
ThreadItemSet $oldItemSet,
ThreadItemSet $newItemSet,
RevisionRecord $newRevRecord,
PageIdentity $title,
UserIdentity $user
): void {
parent::generateEventsFromParsers(
parent::generateEventsFromItemSets(
$events,
$oldParser,
$newParser,
$oldItemSet,
$newItemSet,
$newRevRecord,
$title,
$user

View file

@ -69,8 +69,8 @@ class ThreadItemTest extends IntegrationTestCase {
CommentUtils::unwrapParsoidSections( $container );
$parser = self::createParser( $data )->parse( $container, $title );
$comments = $parser->getCommentItems();
$threadItemSet = self::createParser( $data )->parse( $container, $title );
$comments = $threadItemSet->getCommentItems();
$transcludedFrom = [];
foreach ( $comments as $comment ) {
@ -113,8 +113,8 @@ class ThreadItemTest extends IntegrationTestCase {
$body = DOMCompat::getBody( $doc );
$this->setupEnv( $config, $data );
$parser = self::createParser( $data )->parse( $body, $title );
$items = $parser->getThreadItems();
$threadItemSet = self::createParser( $data )->parse( $body, $title );
$items = $threadItemSet->getThreadItems();
$output = [];
foreach ( $items as $item ) {
@ -159,8 +159,8 @@ class ThreadItemTest extends IntegrationTestCase {
$body = DOMCompat::getBody( $doc );
$this->setupEnv( $config, $data );
$parser = self::createParser( $data )->parse( $body, $title );
$items = $parser->getThreadItems();
$threadItemSet = self::createParser( $data )->parse( $body, $title );
$items = $threadItemSet->getThreadItems();
$output = [];
foreach ( $items as $item ) {

View file

@ -25,8 +25,8 @@ require( '../cases/modified.json' ).forEach( function ( caseItem, i ) {
$( fixture ).empty().append( dom );
var reverseExpectedHtml = fixture.innerHTML;
var parser = new Parser( data ).parse( fixture, title );
var comments = parser.getCommentItems();
var threadItemSet = new Parser( data ).parse( fixture, title );
var comments = threadItemSet.getCommentItems();
// Add a reply to every comment. Note that this inserts *all* of the replies, unlike the real
// thing, which only deals with one at a time. This isn't ideal but resetting everything after
@ -81,8 +81,8 @@ QUnit.test( '#addReplyLink', function ( assert ) {
$( fixture ).empty().append( dom );
var parser = new Parser( data ).parse( fixture, title );
var comments = parser.getCommentItems();
var threadItemSet = new Parser( data ).parse( fixture, title );
var comments = threadItemSet.getCommentItems();
// Add a reply link to every comment.
comments.forEach( function ( comment ) {

View file

@ -74,8 +74,8 @@ QUnit.test( '#getThreads', function ( assert ) {
$( fixture ).empty().append( $dom );
testUtils.overrideMwConfig( config );
var parser = new Parser( data ).parse( fixture, title );
var threads = parser.getThreads();
var threadItemSet = new Parser( data ).parse( fixture, title );
var threads = threadItemSet.getThreads();
threads.forEach( function ( thread, i ) {
testUtils.serializeComments( thread, fixture );

View file

@ -72,6 +72,8 @@ module.exports.serializeComments = function ( parent, root ) {
// Unimportant
delete parent.rootNode;
delete parent.legacyId;
parent.replies.forEach( function ( comment ) {
module.exports.serializeComments( comment, root );
} );