Create ThreadItem classes

Change-Id: Id2c5324d74eccb1209ccb76768c557722c6d9400
This commit is contained in:
Ed Sanders 2020-05-22 17:26:05 +01:00
parent b6695dae58
commit 7be0cc3209
16 changed files with 451 additions and 152 deletions

View file

@ -47,6 +47,9 @@
"modifier.js",
"utils.js",
"parser.js",
"ThreadItem.js",
"CommentItem.js",
"HeadingItem.js",
"lib/moment-timezone/moment-timezone-with-data-1970-2030.js",
{
"name": "parser/data.json",

113
includes/CommentItem.php Normal file
View file

@ -0,0 +1,113 @@
<?php
namespace MediaWiki\Extension\DiscussionTools;
class CommentItem extends ThreadItem {
private $signatureRanges;
private $timestamp;
private $author;
private $warnings = [];
private $parent;
/**
* @param int $level
* @param ImmutableRange $range
* @param ImmutableRange[] $signatureRanges
* @param string|null $timestamp
* @param string|null $author
*/
public function __construct(
int $level, ImmutableRange $range,
array $signatureRanges = [], ?string $timestamp = null, ?string $author = null
) {
parent::__construct( 'comment', $level, $range );
$this->signatureRanges = $signatureRanges;
$this->timestamp = $timestamp;
$this->author = $author;
}
/**
* @return ImmutableRange[] Comment signature ranges
*/
public function getSignatureRanges() : array {
return $this->signatureRanges;
}
/**
* @return string Comment timestamp
*/
public function getTimestamp() : string {
return $this->timestamp;
}
/**
* @return string|null Comment author
*/
public function getAuthor() : ?string {
return $this->author;
}
/**
* @return ThreadItem Parent thread item
*/
public function getParent() : ThreadItem {
return $this->parent;
}
/**
* @return string[] Comment warnings
*/
public function getWarnings() : array {
return $this->warnings;
}
/**
* @param ImmutableRange $signatureRange Comment signature range to add
*/
public function addSignatureRange( ImmutableRange $signatureRange ) : void {
$this->signatureRanges[] = $signatureRange;
}
/**
* @param ImmutableRange[] $signatureRanges Comment signature ranges
*/
public function setSignatureRanges( array $signatureRanges ) : void {
$this->signatureRanges = $signatureRanges;
}
/**
* @param string $timestamp Comment timestamp
*/
public function setTimestamp( string $timestamp ) : void {
$this->timestamp = $timestamp;
}
/**
* @param string|null $author Comment author
*/
public function setAuthor( ?string $author ) : void {
$this->author = $author;
}
/**
* @param ThreadItem $parent Parent thread item
*/
public function setParent( ThreadItem $parent ) {
$this->parent = $parent;
}
/**
* @param string $warning Comment warning
*/
public function addWarning( string $warning ) : void {
$this->warnings[] = $warning;
}
/**
* @param string[] $warnings Comment warnings
*/
public function addWarnings( array $warnings ) : void {
$this->warnings = array_merge( $this->warnings, $warnings );
}
}

View file

@ -5,7 +5,6 @@ namespace MediaWiki\Extension\DiscussionTools;
use DOMDocument;
use DOMElement;
use DOMNode;
use stdClass;
class CommentModifier {
@ -54,11 +53,11 @@ class CommentModifier {
* Given a comment and a reply link, add the reply link to its document's DOM tree, at the end of
* the comment.
*
* @param stdClass $comment Comment data returned by parser#groupThreads
* @param CommentItem $comment Comment data returned by parser#groupThreads
* @param DOMElement $linkNode Reply link
*/
public static function addReplyLink( stdClass $comment, DOMElement $linkNode ) : void {
$target = $comment->range->endContainer;
public static function addReplyLink( CommentItem $comment, DOMElement $linkNode ) : void {
$target = $comment->getRange()->endContainer;
// Skip to the end of the "paragraph". This only looks at tag names and can be fooled by CSS, but
// avoiding that would be more difficult and slower.
@ -90,10 +89,10 @@ class CommentModifier {
* The DOM tree is suitably rearranged to ensure correct indentation level of the reply (wrapper
* nodes are added, and other nodes may be moved around).
*
* @param stdClass $comment Comment data returned by parser#groupThreads
* @param CommentItem $comment Comment data returned by parser#groupThreads
* @return DOMElement
*/
public static function addListItem( stdClass $comment ) : DOMElement {
public static function addListItem( CommentItem $comment ) : DOMElement {
$listTypeMap = [
'li' => 'ul',
'dd' => 'dl'
@ -105,13 +104,14 @@ class CommentModifier {
// 3. Add comment with level of the given comment plus 1
$curComment = $comment;
while ( count( $curComment->replies ) ) {
$curComment = end( $curComment->replies );
while ( count( $curComment->getReplies() ) ) {
$replies = $curComment->getReplies();
$curComment = end( $replies );
}
$desiredLevel = $comment->level + 1;
$curLevel = $curComment->level;
$target = $curComment->range->endContainer;
$desiredLevel = $comment->getLevel() + 1;
$curLevel = $curComment->getLevel();
$target = $curComment->getRange()->endContainer;
// Skip to the end of the "paragraph". This only looks at tag names and can be fooled by CSS, but
// avoiding that would be more difficult and slower.

View file

@ -15,10 +15,8 @@ use IP;
use Language;
use MediaWiki\MediaWikiServices;
use MWException;
use stdClass;
use Title;
// TODO make a class for comments
// TODO clean up static vs non-static
// TODO consider rewriting as single traversal, without XPath
@ -713,7 +711,7 @@ class CommentParser {
* [ 'type' => 'comment', 'level' => 2, 'range' => (li: I) }
* ]
*
* The elements of the array are stdClass objects with the following fields:
* The elements of the array are ThreadItem objects with the following fields:
* - 'type' (string): 'heading' or 'comment'
* - 'range' (ImmutableRange): The extent of the comment, including the signature and timestamp.
* Comments can start or end in the middle of a DOM node.
@ -728,7 +726,7 @@ class CommentParser {
* Not set for headings.
*
* @param DOMElement $rootNode
* @return stdClass[] Results. Each result is an object.
* @return ThreadItem[] Thread items
*/
public function getComments( DOMElement $rootNode ) : array {
$timestamps = $this->findTimestamps( $rootNode );
@ -741,12 +739,7 @@ class CommentParser {
// Placeholder heading in case there are comments in the 0th section
$range = new ImmutableRange( $rootNode, 0, $rootNode, 0 );
$fakeHeading = (object)[
'placeholderHeading' => true,
'type' => 'heading',
'range' => $range,
'level' => 0
];
$fakeHeading = new HeadingItem( $range, true );
$curComment = $fakeHeading;
@ -759,15 +752,11 @@ class CommentParser {
if ( $node->nodeType === XML_ELEMENT_NODE && preg_match( '/^h[1-6]$/i', $node->nodeName ) ) {
$range = new ImmutableRange( $node, 0, $node, $node->childNodes->length );
$curComment = (object)[
'type' => 'heading',
'range' => $range,
'level' => 0
];
$curComment = new HeadingItem( $range );
$comments[] = $curComment;
} elseif ( isset( $timestamps[$nextTimestamp] ) && $node === $timestamps[$nextTimestamp][0] ) {
$warnings = [];
$foundSignature = $this->findSignature( $node, $curComment->range->endContainer );
$foundSignature = $this->findSignature( $node, $curComment->getRange()->endContainer );
$author = $foundSignature[1];
$firstSigNode = end( $foundSignature[0] );
$lastSigNode = $foundSignature[0][0];
@ -780,7 +769,7 @@ class CommentParser {
}
// Everything from the last comment up to here is the next comment
$startNode = $this->nextInterestingLeafNode( $curComment->range->endContainer, $rootNode );
$startNode = $this->nextInterestingLeafNode( $curComment->getRange()->endContainer, $rootNode );
$match = $timestamps[$nextTimestamp][1];
$offset = $lastSigNode === $node ?
$match[0][1] + strlen( $match[0][0] ) :
@ -813,7 +802,7 @@ class CommentParser {
// no way to indicate which one you're replying to (this might matter in the future for
// notifications or something).
if (
$curComment->type === 'comment' &&
$curComment instanceof CommentItem &&
(
CommentUtils::closestElement(
$node, [ 'li', 'dd', 'p' ]
@ -821,14 +810,16 @@ class CommentParser {
) ===
(
CommentUtils::closestElement(
$curComment->range->endContainer, [ 'li', 'dd', 'p' ]
) ?? $curComment->range->endContainer->parentNode
$curComment->getRange()->endContainer, [ 'li', 'dd', 'p' ]
) ?? $curComment->getRange()->endContainer->parentNode
)
) {
// Merge this with the previous comment. Use that comment's author and timestamp.
$curComment->range = $curComment->range->setEnd( $range->endContainer, $range->endOffset );
$curComment->signatureRanges[] = $sigRange;
$curComment->level = min( min( $startLevel, $endLevel ), $curComment->level );
$curComment->setRange(
$curComment->getRange()->setEnd( $range->endContainer, $range->endOffset )
);
$curComment->addSignatureRange( $sigRange );
$curComment->setLevel( min( min( $startLevel, $endLevel ), $curComment->getLevel() ) );
$nextTimestamp++;
continue;
@ -839,19 +830,18 @@ class CommentParser {
$warnings[] = $dateTime->discussionToolsWarning;
}
$curComment = (object)[
'type' => 'comment',
$curComment = new CommentItem(
// Should this use the indent level of $startNode or $node?
min( $startLevel, $endLevel ),
$range,
[ $sigRange ],
// ISO 8601 date. Almost DateTimeInterface::RFC3339_EXTENDED, but ending with 'Z' instead
// of '+00:00', like Date#toISOString in JavaScript.
'timestamp' => $dateTime->format( 'Y-m-d\TH:i:s.v\Z' ),
'author' => $author,
'range' => $range,
'signatureRanges' => [ $sigRange ],
// Should this use the indent level of $startNode or $node?
'level' => min( $startLevel, $endLevel )
];
$dateTime->format( 'Y-m-d\TH:i:s.v\Z' ),
$author
);
if ( $warnings ) {
$curComment->warnings = $warnings;
$curComment->addWarnings( $warnings );
}
$comments[] = $curComment;
$nextTimestamp++;
@ -860,7 +850,7 @@ class CommentParser {
// Insert the fake placeholder heading if there are any comments in the 0th section
// (before the first real heading)
if ( count( $comments ) && $comments[ 0 ]->type !== 'heading' ) {
if ( count( $comments ) && !( $comments[ 0 ] instanceof HeadingItem ) ) {
array_unshift( $comments, $fakeHeading );
}
@ -908,9 +898,9 @@ class CommentParser {
* ] ],
* ]
*
* @param stdClass[] &$comments Result of #getComments, will be modified to add more properties
* @return stdClass[] Tree structure of comments, using the same objects as `comments`. Top-level
* items are the headings. The following properties are added:
* @param ThreadItem[] &$comments Result of #getComments, will be modified to add more properties
* @return HeadingItem[] Tree structure of comments, using the same objects as `comments`.
* Top-level items are the headings. The following properties are added:
* - id: Unique ID (within the page) for this comment, intended to be used to
* find this comment in other revisions of the same page
* - replies: Comment objects which are replies to this comment
@ -922,12 +912,12 @@ class CommentParser {
$commentsById = [];
foreach ( $comments as &$comment ) {
if ( $comment->level === 0 ) {
if ( $comment instanceof HeadingItem ) {
// We don't need ids for section headings right now, but we might in the future
// e.g. if we allow replying directly to sections (adding top-level comments)
$id = null;
} else {
$id = ( $comment->author ?? '' ) . '|' . $comment->timestamp;
} elseif ( $comment instanceof CommentItem ) {
$id = ( $comment->getAuthor() ?? '' ) . '|' . $comment->getTimestamp();
// If there would be multiple comments with the same ID (i.e. the user left multiple comments
// in one edit, or within a minute), append sequential numbers
@ -936,6 +926,8 @@ class CommentParser {
$number++;
}
$id = "$id|$number";
} else {
throw new MWException( 'Unknown ThreadItem type' );
}
if ( $id !== null ) {
@ -943,34 +935,32 @@ class CommentParser {
}
// This modifies the original objects in $comments!
$comment->id = $id;
$comment->replies = [];
$comment->parent = null;
$comment->setId( $id );
if ( count( $replies ) < $comment->level ) {
if ( count( $replies ) < $comment->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.
$comment->warnings[] = 'Comment skips indentation level';
while ( count( $replies ) < $comment->level ) {
$comment->addWarning( 'Comment skips indentation level' );
while ( count( $replies ) < $comment->getLevel() ) {
// FIXME this will clone the reply, not just set a reference
$replies[] = end( $replies );
}
}
if ( $comment->level === 0 ) {
if ( $comment instanceof HeadingItem ) {
// New root (thread)
$threads[] = $comment;
} elseif ( isset( $replies[ $comment->level - 1 ] ) ) {
} elseif ( isset( $replies[ $comment->getLevel() - 1 ] ) ) {
// Add as a reply to the closest less-nested comment
$comment->parent = $replies[ $comment->level - 1 ];
$comment->parent->replies[] = $comment;
$comment->setParent( $replies[ $comment->getLevel() - 1 ] );
$comment->getParent()->addReply( $comment );
} else {
$comment->warnings[] = 'Comment could not be connected to a thread';
$comment->addWarning( 'Comment could not be connected to a thread' );
}
$replies[ $comment->level ] = $comment;
$replies[ $comment->getLevel() ] = $comment;
// Cut off more deeply nested replies
array_splice( $replies, $comment->level + 1 );
array_splice( $replies, $comment->getLevel() + 1 );
}
return $threads;
@ -979,22 +969,21 @@ class CommentParser {
/**
* Get the list of authors involved in a comment and its replies.
*
* You probably want to pass a thread root here (a heading).
*
* @param stdClass $comment Comment object, as returned by #groupThreads
* @param HeadingItem $heading Heading object, as returned by #groupThreads
* @return string[] Author usernames
*/
public function getAuthors( stdClass $comment ) : array {
public function getAuthors( HeadingItem $heading ) : array {
$authors = [];
$getAuthorSet = function ( stdClass $comment ) use ( &$authors, &$getAuthorSet ) {
if ( $comment->author ?? false ) {
$authors[ $comment->author ] = true;
$getAuthorSet = function ( CommentItem $comment ) use ( &$authors, &$getAuthorSet ) {
$author = $comment->getAuthor();
if ( $author ) {
$authors[ $author ] = true;
}
// Get the set of authors in the same format from each reply
array_map( $getAuthorSet, $comment->replies );
array_map( $getAuthorSet, $comment->getReplies() );
};
$getAuthorSet( $comment );
array_map( $getAuthorSet, $heading->getReplies() );
ksort( $authors );
return array_keys( $authors );
@ -1003,19 +992,19 @@ class CommentParser {
/**
* Get the name of the page from which this comment is transcluded (if any).
*
* @param stdClass $comment Comment object, as returned by #groupThreads
* @param CommentItem $comment Comment object, as returned by #groupThreads
* @return string|bool `false` if this comment is not transcluded. A string if it's transcluded
* from a single page (the page title, in text form with spaces). `true` if it's transcluded, but
* we can't determine the source.
*/
public function getTranscludedFrom( stdClass $comment ) {
public function getTranscludedFrom( CommentItem $comment ) {
// If some template is used within the comment (e.g. {{ping|…}} or {{tl|…}}, or a
// non-substituted signature template), that *does not* mean the comment is transcluded.
// We only want to consider comments to be transcluded if the wrapper element (usually
// <li> or <p>) is marked as part of a transclusion. If we can't find a wrapper, using
// endContainer should avoid false negatives (although may have false positives).
$node = CommentUtils::getTranscludedFromElement(
CommentUtils::getFullyCoveredWrapper( $comment ) ?: $comment->range->endContainer
CommentUtils::getFullyCoveredWrapper( $comment ) ?: $comment->getRange()->endContainer
);
if ( !$node ) {

View file

@ -5,7 +5,6 @@ namespace MediaWiki\Extension\DiscussionTools;
use DOMElement;
use DOMNode;
use DOMXPath;
use stdClass;
class CommentUtils {
private function __construct() {
@ -119,11 +118,11 @@ class CommentUtils {
/**
* Get a node (if any) that contains the given comment, and nothing else.
*
* @param stdClass $comment Comment data returned by parser#groupThreads
* @param CommentItem $comment Comment data returned by parser#groupThreads
* @return DOMElement|null
*/
public static function getFullyCoveredWrapper( $comment ) {
$ancestor = $comment->range->commonAncestorContainer;
public static function getFullyCoveredWrapper( CommentItem $comment ) : ?DOMElement {
$ancestor = $comment->getRange()->commonAncestorContainer;
$isIgnored = function ( $node ) {
// Ignore empty text nodes
@ -149,7 +148,7 @@ class CommentUtils {
$startMatches = false;
$node = $ancestor;
while ( $node ) {
if ( $comment->range->startContainer === $node && $comment->range->startOffset === 0 ) {
if ( $comment->getRange()->startContainer === $node && $comment->getRange()->startOffset === 0 ) {
$startMatches = true;
break;
}
@ -164,7 +163,7 @@ class CommentUtils {
// PHP bug: childNodes can be null for comment nodes
// (it should always be a DOMNodeList, even if the node can't have children)
( $node->childNodes ? $node->childNodes->length : 0 );
if ( $comment->range->endContainer === $node && $comment->range->endOffset === $length ) {
if ( $comment->getRange()->endContainer === $node && $comment->getRange()->endOffset === $length ) {
$endMatches = true;
break;
}

32
includes/HeadingItem.php Normal file
View file

@ -0,0 +1,32 @@
<?php
namespace MediaWiki\Extension\DiscussionTools;
class HeadingItem extends ThreadItem {
private $placeholderHeading = false;
/**
* @param ImmutableRange $range
* @param bool $placeholderHeading
*/
public function __construct(
ImmutableRange $range, bool $placeholderHeading = false
) {
parent::__construct( 'heading', 0, $range );
$this->placeholderHeading = $placeholderHeading;
}
/**
* @return bool
*/
public function isPlaceholderHeading() : bool {
return $this->placeholderHeading;
}
/**
* @param bool $placeholderHeading
*/
public function setPlaceholderHeading( bool $placeholderHeading ) : void {
$this->placeholderHeading = $placeholderHeading;
}
}

88
includes/ThreadItem.php Normal file
View file

@ -0,0 +1,88 @@
<?php
namespace MediaWiki\Extension\DiscussionTools;
abstract class ThreadItem {
private $type;
private $range;
private $level;
private $id = null;
private $replies = [];
/**
* @param string $type
* @param int $level
* @param ImmutableRange $range
*/
public function __construct(
string $type, int $level, ImmutableRange $range
) {
$this->type = $type;
$this->level = $level;
$this->range = $range;
}
/**
* @return string Thread item type
*/
public function getType() : string {
return $this->type;
}
/**
* @return int Thread item level
*/
public function getLevel() : int {
return $this->level;
}
/**
* @return ImmutableRange Thread item range
*/
public function getRange() : ImmutableRange {
return $this->range;
}
/**
* @return string|null Thread ID
*/
public function getId() : ?string {
return $this->id;
}
/**
* @return CommentItem[] Thread item replies
*/
public function getReplies() : array {
return $this->replies;
}
/**
* @param int $level Thread item level
*/
public function setLevel( int $level ) : void {
$this->level = $level;
}
/**
* @param ImmutableRange $range Thread item range
*/
public function setRange( ImmutableRange $range ) : void {
$this->range = $range;
}
/**
* @param string|null $id Thread ID
*/
public function setId( ?string $id ) : void {
$this->id = $id;
}
/**
* @param CommentItem $reply Reply comment
*/
public function addReply( CommentItem $reply ) : void {
$this->replies[] = $reply;
}
}

14
modules/CommentItem.js Normal file
View file

@ -0,0 +1,14 @@
var ThreadItem = require( './ThreadItem.js' );
function CommentItem( level, range, signatureRanges, timestamp, author ) {
// Parent constructor
CommentItem.super.call( this, 'comment', level, range );
this.signatureRanges = signatureRanges || [];
this.timestamp = timestamp || null;
this.author = author || null;
}
OO.inheritClass( CommentItem, ThreadItem );
module.exports = CommentItem;

14
modules/HeadingItem.js Normal file
View file

@ -0,0 +1,14 @@
var ThreadItem = require( './ThreadItem.js' );
function HeadingItem( range, placeholderHeading ) {
// Parent constructor
HeadingItem.super.call( this, 'heading', 0, range );
if ( placeholderHeading ) {
this.placeholderHeading = true;
}
}
OO.inheritClass( HeadingItem, ThreadItem );
module.exports = HeadingItem;

12
modules/ThreadItem.js Normal file
View file

@ -0,0 +1,12 @@
function ThreadItem( type, level, range ) {
this.type = type;
this.level = level;
this.range = range;
this.id = null;
this.replies = [];
}
OO.initClass( ThreadItem );
module.exports = ThreadItem;

View file

@ -1,5 +1,9 @@
/* global $:off */
'use strict';
/* global $:off */
/**
* @external CommentItem
*/
var
utils = require( './utils.js' );
@ -20,7 +24,7 @@ function whitespaceParsoidHack( listItem ) {
* Given a comment and a reply link, add the reply link to its document's DOM tree, at the end of
* the comment.
*
* @param {Object} comment Comment data returned by parser#groupThreads
* @param {CommentItem} comment Comment data returned by parser#groupThreads
* @param {HTMLElement} linkNode Reply link
*/
function addReplyLink( comment, linkNode ) {
@ -51,7 +55,7 @@ function addReplyLink( comment, linkNode ) {
* The DOM tree is suitably rearranged to ensure correct indentation level of the reply (wrapper
* nodes are added, and other nodes may be moved around).
*
* @param {Object} comment Comment data returned by parser#groupThreads
* @param {CommentItem} comment Comment data returned by parser#groupThreads
* @return {HTMLElement}
*/
function addListItem( comment ) {

View file

@ -1,7 +1,14 @@
'use strict';
/* global $:off */
/**
* @external ThreadItem
*/
var
utils = require( './utils.js' ),
CommentItem = require( './CommentItem.js' ),
HeadingItem = require( './HeadingItem.js' ),
// Hooks::getLocalData()
data = require( './parser/data.json' ),
moment = require( './lib/moment-timezone/moment-timezone-with-data-1970-2030.js' );
@ -682,7 +689,7 @@ function nextInterestingLeafNode( node, rootNode ) {
* ]
*
* @param {HTMLElement} rootNode
* @return {Object[]} Results. Each result is an object.
* @return {ThreadItem[]} Results. Each result is an object.
* @return {string} return.type `heading` or `comment`
* @return {Object} return.range Object describing the extent of the comment, including the
* signature and timestamp. It has the same properties as a Range object: `startContainer`,
@ -722,12 +729,7 @@ function getComments( rootNode ) {
endContainer: rootNode,
endOffset: 0
};
fakeHeading = {
placeholderHeading: true,
type: 'heading',
range: range,
level: 0
};
fakeHeading = new HeadingItem( range, true );
curComment = fakeHeading;
@ -740,11 +742,7 @@ function getComments( rootNode ) {
endContainer: node,
endOffset: node.childNodes.length
};
curComment = {
type: 'heading',
range: range,
level: 0
};
curComment = new HeadingItem( range );
comments.push( curComment );
} else if ( timestamps[ nextTimestamp ] && node === timestamps[ nextTimestamp ][ 0 ] ) {
warnings = [];
@ -789,7 +787,7 @@ function getComments( rootNode ) {
// no way to indicate which one you're replying to (this might matter in the future for
// notifications or something).
if (
curComment.type === 'comment' &&
curComment instanceof CommentItem &&
( utils.closestElement( node, [ 'li', 'dd', 'p' ] ) || node.parentNode ) ===
( utils.closestElement( curComment.range.endContainer, [ 'li', 'dd', 'p' ] ) || curComment.range.endContainer.parentNode )
) {
@ -808,15 +806,14 @@ function getComments( rootNode ) {
warnings.push( dateTime.discussionToolsWarning );
}
curComment = {
type: 'comment',
timestamp: dateTime,
author: author,
range: range,
signatureRanges: [ sigRange ],
curComment = new CommentItem(
// Should this use the indent level of `startNode` or `node`?
level: Math.min( startLevel, endLevel )
};
Math.min( startLevel, endLevel ),
range,
[ sigRange ],
dateTime,
author
);
if ( warnings.length ) {
curComment.warnings = warnings;
}
@ -827,7 +824,7 @@ function getComments( rootNode ) {
// Insert the fake placeholder heading if there are any comments in the 0th section
// (before the first real heading)
if ( comments.length && comments[ 0 ].type !== 'heading' ) {
if ( comments.length && !( comments[ 0 ] instanceof HeadingItem ) ) {
comments.unshift( fakeHeading );
}
@ -875,8 +872,8 @@ function getComments( rootNode ) {
* ] },
* ]
*
* @param {Object} comments Result of #getComments
* @return {Object[]} Tree structure of comments, using the same objects as `comments`. Top-level
* @param {ThreadItem} comments Result of #getComments
* @return {HeadingItem[]} Tree structure of comments, using the same objects as `comments`. Top-level
* items are the headings. The following properties are added:
* @return {string} return.id Unique ID (within the page) for this comment, intended to be used to
* find this comment in other revisions of the same page
@ -893,7 +890,7 @@ function groupThreads( comments ) {
for ( i = 0; i < comments.length; i++ ) {
comment = comments[ i ];
if ( comment.level === 0 ) {
if ( comment instanceof HeadingItem ) {
// We don't need ids for section headings right now, but we might in the future
// e.g. if we allow replying directly to sections (adding top-level comments)
id = null;
@ -919,8 +916,6 @@ function groupThreads( comments ) {
// This modifies the original objects in `comments`!
comment.id = id;
comment.replies = [];
comment.parent = null;
if ( replies.length < comment.level ) {
// Someone skipped an indentation level (or several). Pretend that the previous reply
@ -932,7 +927,7 @@ function groupThreads( comments ) {
}
}
if ( comment.level === 0 ) {
if ( comment instanceof HeadingItem ) {
// New root (thread)
threads.push( comment );
} else if ( replies[ comment.level - 1 ] ) {
@ -955,22 +950,18 @@ function groupThreads( comments ) {
/**
* Get the list of authors involved in a comment and its replies.
*
* You probably want to pass a thread root here (a heading).
*
* @param {Object} comment Comment object, as returned by #groupThreads
* @param {HeadingItem} heading Comment object, as returned by #groupThreads
* @return {string[]} Author usernames
*/
function getAuthors( comment ) {
function getAuthors( heading ) {
var authors = {};
function getAuthorSet( comment ) {
if ( comment.author ) {
authors[ comment.author ] = true;
}
authors[ comment.author ] = true;
// Get the set of authors in the same format from each reply
comment.replies.map( getAuthorSet );
}
getAuthorSet( comment );
heading.replies.map( getAuthorSet );
return Object.keys( authors ).sort();
}
@ -978,7 +969,7 @@ function getAuthors( comment ) {
/**
* Get the name of the page from which this comment is transcluded (if any).
*
* @param {Object} comment Comment object, as returned by #groupThreads
* @param {CommentItem} comment Comment object, as returned by #groupThreads
* @return {string|boolean} `false` if this comment is not transcluded. A string if it's transcluded
* from a single page (the page title, in text form with spaces). `true` if it's transcluded, but
* we can't determine the source.

View file

@ -1,4 +1,5 @@
'use strict';
/* global $:off */
/**
* Return a native Range object corresponding to our comment's range.

View file

@ -1,15 +1,19 @@
[
{
"thread": {
"type": "heading",
"replies": [
{
"type": "comment",
"author": "Eve",
"replies": []
},
{
"type": "comment",
"author": "Bob",
"replies": [
{
"type": "comment",
"author": "Alice",
"replies": []
}

View file

@ -2,6 +2,7 @@
namespace MediaWiki\Extension\DiscussionTools\Tests;
use MediaWiki\Extension\DiscussionTools\CommentItem;
use MediaWiki\Extension\DiscussionTools\CommentModifier;
/**
@ -34,12 +35,11 @@ class CommentModifierTest extends CommentTestCase {
$nodes = [];
foreach ( $comments as $comment ) {
if ( $comment->type === 'heading' ) {
continue;
if ( $comment instanceof CommentItem ) {
$node = CommentModifier::addListItem( $comment );
$node->textContent = 'Reply to ' . $comment->getId();
$nodes[] = $node;
}
$node = CommentModifier::addListItem( $comment );
$node->textContent = 'Reply to ' . $comment->id;
$nodes[] = $node;
}
$expectedDoc = self::createDocument( $expected );
@ -75,13 +75,12 @@ class CommentModifierTest extends CommentTestCase {
$parser->groupThreads( $comments );
foreach ( $comments as $comment ) {
if ( $comment->type === 'heading' ) {
continue;
if ( $comment instanceof CommentItem ) {
$linkNode = $doc->createElement( 'a' );
$linkNode->nodeValue = 'Reply';
$linkNode->setAttribute( 'href', '#' );
CommentModifier::addReplyLink( $comment, $linkNode );
}
$linkNode = $doc->createElement( 'a' );
$linkNode->nodeValue = 'Reply';
$linkNode->setAttribute( 'href', '#' );
CommentModifier::addReplyLink( $comment, $linkNode );
}
$expectedDoc = self::createDocument( $expected );

View file

@ -5,9 +5,12 @@ namespace MediaWiki\Extension\DiscussionTools\Tests;
use DateTimeImmutable;
use DOMElement;
use DOMNode;
use MediaWiki\Extension\DiscussionTools\CommentItem;
use MediaWiki\Extension\DiscussionTools\CommentParser;
use MediaWiki\Extension\DiscussionTools\CommentUtils;
use MediaWiki\Extension\DiscussionTools\HeadingItem;
use MediaWiki\Extension\DiscussionTools\ImmutableRange;
use MediaWiki\Extension\DiscussionTools\ThreadItem;
use stdClass;
use Wikimedia\TestingAccessWrapper;
@ -80,27 +83,45 @@ class CommentParserTest extends CommentTestCase {
return implode( '/', $path );
}
private static function serializeComments( stdClass &$parent, DOMElement $root ) : void {
unset( $parent->parent );
private static function serializeComments( ThreadItem &$threadItem, DOMElement $root ) : stdClass {
$serialized = new stdClass();
$serialized->type = $threadItem->getType();
$serialized->level = $threadItem->getLevel();
// Can't serialize the DOM nodes involved in the range,
// instead use their offsets within their parent nodes
$parent->range = [
self::getOffsetPath( $root, $parent->range->startContainer, $parent->range->startOffset ),
self::getOffsetPath( $root, $parent->range->endContainer, $parent->range->endOffset )
$range = $threadItem->getRange();
$serialized->id = $threadItem->getId();
$serialized->range = [
self::getOffsetPath( $root, $range->startContainer, $range->startOffset ),
self::getOffsetPath( $root, $range->endContainer, $range->endOffset )
];
if ( isset( $parent->signatureRanges ) ) {
$parent->signatureRanges = array_map( function ( ImmutableRange $range ) use ( $root ) {
$serialized->replies = [];
foreach ( $threadItem->getReplies() as $reply ) {
$serialized->replies[] = self::serializeComments( $reply, $root );
}
if ( $threadItem instanceof CommentItem ) {
$serialized->signatureRanges = array_map( function ( ImmutableRange $range ) use ( $root ) {
return [
self::getOffsetPath( $root, $range->startContainer, $range->startOffset ),
self::getOffsetPath( $root, $range->endContainer, $range->endOffset )
];
}, $parent->signatureRanges );
}, $threadItem->getSignatureRanges() );
$serialized->timestamp = $threadItem->getTimestamp();
$serialized->author = $threadItem->getAuthor();
$warnings = $threadItem->getWarnings();
if ( count( $warnings ) ) {
$serialized->warnings = $threadItem->getWarnings();
}
}
foreach ( $parent->replies as $reply ) {
self::serializeComments( $reply, $root );
if ( $threadItem instanceof HeadingItem && $threadItem->isPlaceholderHeading() ) {
$serialized->placeholderHeading = $threadItem->isPlaceholderHeading();
}
return $serialized;
}
/**
@ -182,17 +203,32 @@ class CommentParserTest extends CommentTestCase {
* @dataProvider provideAuthors
* @covers ::getAuthors
*/
public function testGetAuthors( stdClass $thread, array $expected ) : void {
public function testGetAuthors( array $thread, array $expected ) : void {
$parser = CommentParser::newFromGlobalState();
$doc = $this->createDocument( '' );
$node = $doc->createElement( 'div' );
$range = new ImmutableRange( $node, 0, $node, 0 );
self::assertEquals( $expected, $parser->getAuthors( $thread ) );
$makeThreadItem = function ( array $arr ) use ( &$makeThreadItem, $range ) : ThreadItem {
if ( $arr['type'] === 'comment' ) {
$item = new CommentItem( 1, $range );
$item->setAuthor( $arr['author'] );
} else {
$item = new HeadingItem( $range );
}
foreach ( $arr['replies'] as $reply ) {
$item->addReply( $makeThreadItem( $reply ) );
}
return $item;
};
$threadItem = $makeThreadItem( $thread );
self::assertEquals( $expected, $parser->getAuthors( $threadItem ) );
}
public function provideAuthors() : array {
return array_map( function ( $caseItem ) {
// PHPUnit requires associative arrays, not stdClass objects
return (array)$caseItem;
}, self::getJson( '../cases/authors.json', false ) );
return self::getJson( '../cases/authors.json' );
}
/**
@ -220,7 +256,7 @@ class CommentParserTest extends CommentTestCase {
$processedThreads = [];
foreach ( $threads as $i => $thread ) {
self::serializeComments( $thread, $container );
$thread = self::serializeComments( $thread, $container );
$thread = json_decode( json_encode( $thread ), true );
$processedThreads[] = $thread;
self::assertEquals( $expected[$i], $processedThreads[$i], $name . ' section ' . $i );
@ -258,8 +294,8 @@ class CommentParserTest extends CommentTestCase {
$transcludedFrom = [];
foreach ( $comments as $comment ) {
if ( $comment->id ) {
$transcludedFrom[ $comment->id ] =
if ( $comment instanceof CommentItem ) {
$transcludedFrom[ $comment->getId() ] =
$parser->getTranscludedFrom( $comment );
}
}