mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/DiscussionTools
synced 2024-11-24 00:13:36 +00:00
Create ThreadItem classes
Change-Id: Id2c5324d74eccb1209ccb76768c557722c6d9400
This commit is contained in:
parent
b6695dae58
commit
7be0cc3209
|
@ -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
113
includes/CommentItem.php
Normal 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 );
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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 ) {
|
||||
|
|
|
@ -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
32
includes/HeadingItem.php
Normal 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
88
includes/ThreadItem.php
Normal 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
14
modules/CommentItem.js
Normal 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
14
modules/HeadingItem.js
Normal 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
12
modules/ThreadItem.js
Normal 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;
|
|
@ -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 ) {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
'use strict';
|
||||
/* global $:off */
|
||||
|
||||
/**
|
||||
* Return a native Range object corresponding to our comment's range.
|
||||
|
|
|
@ -1,15 +1,19 @@
|
|||
[
|
||||
{
|
||||
"thread": {
|
||||
"type": "heading",
|
||||
"replies": [
|
||||
{
|
||||
"type": "comment",
|
||||
"author": "Eve",
|
||||
"replies": []
|
||||
},
|
||||
{
|
||||
"type": "comment",
|
||||
"author": "Bob",
|
||||
"replies": [
|
||||
{
|
||||
"type": "comment",
|
||||
"author": "Alice",
|
||||
"replies": []
|
||||
}
|
||||
|
|
|
@ -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 );
|
||||
|
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue