2020-05-11 15:54:06 +00:00
|
|
|
<?php
|
|
|
|
|
2020-05-14 22:44:49 +00:00
|
|
|
namespace MediaWiki\Extension\DiscussionTools;
|
|
|
|
|
2022-10-28 18:24:02 +00:00
|
|
|
use InvalidArgumentException;
|
|
|
|
use LogicException;
|
2022-03-18 03:28:06 +00:00
|
|
|
use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem;
|
|
|
|
use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentThreadItem;
|
2021-03-15 14:29:19 +00:00
|
|
|
use MediaWiki\MediaWikiServices;
|
2022-10-28 18:24:02 +00:00
|
|
|
use UnexpectedValueException;
|
2022-02-19 03:25:14 +00:00
|
|
|
use Wikimedia\Assert\Assert;
|
2021-07-29 02:16:15 +00:00
|
|
|
use Wikimedia\Parsoid\DOM\Document;
|
|
|
|
use Wikimedia\Parsoid\DOM\DocumentFragment;
|
|
|
|
use Wikimedia\Parsoid\DOM\Element;
|
|
|
|
use Wikimedia\Parsoid\DOM\Node;
|
|
|
|
use Wikimedia\Parsoid\DOM\Text;
|
2020-06-26 22:37:35 +00:00
|
|
|
use Wikimedia\Parsoid\Utils\DOMCompat;
|
2022-01-29 20:20:51 +00:00
|
|
|
use Wikimedia\Parsoid\Utils\DOMUtils;
|
2020-05-14 22:44:49 +00:00
|
|
|
|
|
|
|
class CommentModifier {
|
2020-05-11 15:54:06 +00:00
|
|
|
|
|
|
|
private function __construct() {
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Add an attribute to a list item to remove pre-whitespace in Parsoid
|
|
|
|
*/
|
2021-07-29 02:16:15 +00:00
|
|
|
private static function whitespaceParsoidHack( Element $listItem ): void {
|
2020-05-11 15:54:06 +00:00
|
|
|
// HACK: Setting data-parsoid removes the whitespace after the list item,
|
|
|
|
// which makes nested lists work.
|
|
|
|
// This is undocumented behaviour and probably very fragile.
|
|
|
|
$listItem->setAttribute( 'data-parsoid', '{}' );
|
|
|
|
}
|
|
|
|
|
2020-06-26 22:24:14 +00:00
|
|
|
/**
|
|
|
|
* Remove extra linebreaks from a wikitext string
|
|
|
|
*/
|
2021-07-22 07:25:13 +00:00
|
|
|
public static function sanitizeWikitextLinebreaks( string $wikitext ): string {
|
2020-07-15 21:43:56 +00:00
|
|
|
$wikitext = CommentUtils::htmlTrim( $wikitext );
|
2020-06-26 22:24:14 +00:00
|
|
|
$wikitext = preg_replace( "/\r/", "\n", $wikitext );
|
|
|
|
$wikitext = preg_replace( "/\n+/", "\n", $wikitext );
|
|
|
|
return $wikitext;
|
|
|
|
}
|
|
|
|
|
2020-05-11 15:54:06 +00:00
|
|
|
/**
|
|
|
|
* Given a comment and a reply link, add the reply link to its document's DOM tree, at the end of
|
|
|
|
* the comment.
|
|
|
|
*
|
2022-03-18 03:28:06 +00:00
|
|
|
* @param ContentCommentItem $comment
|
2022-04-21 13:08:59 +00:00
|
|
|
* @param Node $linkNode Reply link
|
2020-05-11 15:54:06 +00:00
|
|
|
*/
|
2022-04-21 13:08:59 +00:00
|
|
|
public static function addReplyLink( ContentCommentItem $comment, Node $linkNode ): void {
|
2020-05-22 16:26:05 +00:00
|
|
|
$target = $comment->getRange()->endContainer;
|
2020-05-11 15:54:06 +00:00
|
|
|
|
|
|
|
// Insert the link before trailing whitespace.
|
|
|
|
// In the MediaWiki parser output, <ul>/<dl> nodes are preceded by a newline. Normally it isn't
|
|
|
|
// visible on the page. But if we insert an inline element (the reply link) after it, it becomes
|
|
|
|
// meaningful and gets rendered, which results in additional spacing before some reply links.
|
|
|
|
// Split the text node, so that we can insert the link before the trailing whitespace.
|
2022-02-21 18:42:36 +00:00
|
|
|
if ( $target instanceof Text ) {
|
|
|
|
preg_match( '/\s*$/', $target->nodeValue ?? '', $matches, PREG_OFFSET_CAPTURE );
|
2020-05-11 15:54:06 +00:00
|
|
|
$byteOffset = $matches[0][1];
|
|
|
|
$charOffset = mb_strlen(
|
2022-02-21 18:42:36 +00:00
|
|
|
substr( $target->nodeValue ?? '', 0, $byteOffset )
|
2020-05-11 15:54:06 +00:00
|
|
|
);
|
|
|
|
$target->splitText( $charOffset );
|
|
|
|
}
|
|
|
|
|
|
|
|
$target->parentNode->insertBefore( $linkNode, $target->nextSibling );
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Given a comment, add a list item to its document's DOM tree, inside of which a reply to said
|
|
|
|
* comment can be added.
|
|
|
|
*
|
|
|
|
* 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).
|
|
|
|
*
|
2022-03-18 03:28:06 +00:00
|
|
|
* @param ContentThreadItem $comment
|
2021-03-15 14:29:19 +00:00
|
|
|
* @param string $replyIndentation Reply indentation syntax to use, one of:
|
|
|
|
* - 'invisible' (use `<dl><dd>` tags to output `:` in wikitext)
|
|
|
|
* - 'bullet' (use `<ul><li>` tags to output `*` in wikitext)
|
2021-07-29 02:16:15 +00:00
|
|
|
* @return Element
|
2020-05-11 15:54:06 +00:00
|
|
|
*/
|
2022-03-18 03:28:06 +00:00
|
|
|
public static function addListItem( ContentThreadItem $comment, string $replyIndentation ): Element {
|
2020-05-11 15:54:06 +00:00
|
|
|
$listTypeMap = [
|
|
|
|
'li' => 'ul',
|
|
|
|
'dd' => 'dl'
|
|
|
|
];
|
|
|
|
|
|
|
|
// 1. Start at given comment
|
|
|
|
// 2. Skip past all comments with level greater than the given
|
|
|
|
// (or in other words, all replies, and replies to replies, and so on)
|
|
|
|
// 3. Add comment with level of the given comment plus 1
|
|
|
|
|
|
|
|
$curComment = $comment;
|
2020-05-22 16:26:05 +00:00
|
|
|
while ( count( $curComment->getReplies() ) ) {
|
|
|
|
$replies = $curComment->getReplies();
|
|
|
|
$curComment = end( $replies );
|
2020-05-11 15:54:06 +00:00
|
|
|
}
|
|
|
|
|
2020-08-04 00:04:20 +00:00
|
|
|
// Tag names for lists and items we're going to insert
|
2021-03-15 14:29:19 +00:00
|
|
|
if ( $replyIndentation === 'invisible' ) {
|
|
|
|
$itemType = 'dd';
|
|
|
|
} elseif ( $replyIndentation === 'bullet' ) {
|
|
|
|
$itemType = 'li';
|
|
|
|
} else {
|
2022-10-28 18:24:02 +00:00
|
|
|
throw new InvalidArgumentException( "Invalid reply indentation syntax '$replyIndentation'" );
|
2021-03-15 14:29:19 +00:00
|
|
|
}
|
2020-08-04 00:04:20 +00:00
|
|
|
$listType = $listTypeMap[ $itemType ];
|
|
|
|
|
2020-05-22 16:26:05 +00:00
|
|
|
$desiredLevel = $comment->getLevel() + 1;
|
|
|
|
$target = $curComment->getRange()->endContainer;
|
2020-05-11 15:54:06 +00:00
|
|
|
|
|
|
|
// target is a text node or an inline element at the end of a "paragraph"
|
|
|
|
// (not necessarily paragraph node).
|
|
|
|
// First, we need to find a block-level parent that we can mess with.
|
|
|
|
// If we can't find a surrounding list item or paragraph (e.g. maybe we're inside a table cell
|
|
|
|
// or something), take the parent node and hope for the best.
|
2020-05-14 22:44:49 +00:00
|
|
|
$parent = CommentUtils::closestElement( $target, [ 'li', 'dd', 'p' ] ) ??
|
2020-05-11 15:54:06 +00:00
|
|
|
$target->parentNode;
|
|
|
|
while ( $target->parentNode !== $parent ) {
|
|
|
|
$target = $target->parentNode;
|
|
|
|
}
|
|
|
|
// parent is a list item or paragraph (hopefully)
|
|
|
|
// target is an inline node within it
|
|
|
|
|
2021-02-08 20:52:30 +00:00
|
|
|
// If the comment is fully covered by some wrapper element, insert replies outside that wrapper.
|
|
|
|
// This will often just be a paragraph node (<p>), but it can be a <div> or <table> that serves
|
|
|
|
// as some kind of a fancy frame, which are often used for barnstars and announcements.
|
2022-10-09 19:30:10 +00:00
|
|
|
// @phan-suppress-next-line PhanTypeMismatchArgumentNullable
|
|
|
|
$excludedWrapper = CommentUtils::closestElement( $target, [ 'section' ] ) ?:
|
|
|
|
$curComment->getRootNode();
|
|
|
|
$covered = CommentUtils::getFullyCoveredSiblings( $curComment, $excludedWrapper );
|
2021-02-08 20:52:30 +00:00
|
|
|
if ( $curComment->getLevel() === 1 && $covered ) {
|
|
|
|
$target = end( $covered );
|
|
|
|
$parent = $target->parentNode;
|
|
|
|
}
|
|
|
|
|
2022-10-08 16:25:39 +00:00
|
|
|
// If the comment is in a transclusion, insert replies after the transclusion. (T313100)
|
|
|
|
// This method should never be called in cases where that would be a bad idea.
|
|
|
|
// @phan-suppress-next-line PhanTypeMismatchArgumentNullable
|
|
|
|
$transclusionNode = CommentUtils::getTranscludedFromElement( $target );
|
|
|
|
if ( $transclusionNode ) {
|
|
|
|
while (
|
|
|
|
( $nextSibling = $transclusionNode->nextSibling ) &&
|
|
|
|
$nextSibling instanceof Element &&
|
|
|
|
$nextSibling->getAttribute( 'about' ) === $transclusionNode->getAttribute( 'about' )
|
|
|
|
) {
|
|
|
|
$transclusionNode = $nextSibling;
|
|
|
|
}
|
|
|
|
$target = $transclusionNode;
|
|
|
|
$parent = $target->parentNode;
|
|
|
|
}
|
|
|
|
|
2021-02-08 20:52:30 +00:00
|
|
|
// If we can't insert a list directly inside this element, insert after it.
|
2021-04-21 09:31:10 +00:00
|
|
|
// The covered wrapper check above handles most cases, but we still need this sometimes, such as:
|
|
|
|
// * If the comment starts in the middle of a list, then ends with an unindented p/pre, the
|
|
|
|
// wrapper check doesn't adjust the parent
|
|
|
|
// * If the comment consists of multiple list items (starting with a <dt>, so that the comment is
|
|
|
|
// considered to be unindented, that is level === 1), but not all of them, the wrapper check
|
|
|
|
// adjusts the parent to be the list, and the rest of the algorithm doesn't handle that well
|
|
|
|
if (
|
|
|
|
strtolower( $parent->tagName ) === 'p' ||
|
|
|
|
strtolower( $parent->tagName ) === 'pre' ||
|
|
|
|
strtolower( $parent->tagName ) === 'ul' ||
|
|
|
|
strtolower( $parent->tagName ) === 'dl'
|
|
|
|
) {
|
2021-02-08 20:52:30 +00:00
|
|
|
$parent = $parent->parentNode;
|
|
|
|
$target = $target->parentNode;
|
|
|
|
}
|
|
|
|
|
2022-02-19 03:25:14 +00:00
|
|
|
Assert::precondition( $target !== null, 'We have not stepped outside the document' );
|
2020-07-29 23:57:51 +00:00
|
|
|
// Instead of just using $curComment->getLevel(), consider indentation of lists within the
|
|
|
|
// comment (T252702)
|
|
|
|
$curLevel = CommentUtils::getIndentLevel( $target, $curComment->getRootNode() ) + 1;
|
|
|
|
|
2020-06-10 20:20:05 +00:00
|
|
|
$item = null;
|
2020-08-29 12:00:51 +00:00
|
|
|
if ( $desiredLevel === 1 ) {
|
|
|
|
// Special handling for top-level comments
|
|
|
|
// We use section=new API for adding them in PHP, so this should never happen
|
2022-10-28 18:24:02 +00:00
|
|
|
throw new UnexpectedValueException( "Can't add a top-level comment" );
|
2020-08-29 12:00:51 +00:00
|
|
|
|
|
|
|
} elseif ( $curLevel < $desiredLevel ) {
|
2020-05-11 15:54:06 +00:00
|
|
|
// Insert more lists after the target to increase nesting.
|
|
|
|
|
2020-12-05 21:49:23 +00:00
|
|
|
// Parsoid puts HTML comments (and other "rendering-transparent nodes", e.g. category links)
|
|
|
|
// which appear at the end of the line in wikitext outside the paragraph,
|
2020-07-22 19:01:13 +00:00
|
|
|
// but we usually shouldn't insert replies between the paragraph and such comments. (T257651)
|
2020-09-11 13:13:14 +00:00
|
|
|
// Skip over comments and whitespace, but only update target when skipping past comments.
|
|
|
|
$pointer = $target;
|
|
|
|
while (
|
|
|
|
$pointer->nextSibling && (
|
2020-12-05 21:49:23 +00:00
|
|
|
CommentUtils::isRenderingTransparentNode( $pointer->nextSibling ) ||
|
2020-09-11 13:13:14 +00:00
|
|
|
(
|
2021-07-29 02:16:15 +00:00
|
|
|
$pointer->nextSibling instanceof Text &&
|
|
|
|
CommentUtils::htmlTrim( $pointer->nextSibling->nodeValue ?? '' ) === '' &&
|
2020-09-28 17:32:42 +00:00
|
|
|
// If more that two lines of whitespace are detected, the following HTML
|
|
|
|
// comments are not considered to be part of the reply (T264026)
|
2021-07-29 02:16:15 +00:00
|
|
|
!preg_match( '/(\r?\n){2,}/', $pointer->nextSibling->nodeValue ?? '' )
|
2020-09-11 13:13:14 +00:00
|
|
|
)
|
|
|
|
)
|
|
|
|
) {
|
|
|
|
$pointer = $pointer->nextSibling;
|
2020-12-05 21:49:23 +00:00
|
|
|
if ( CommentUtils::isRenderingTransparentNode( $pointer ) ) {
|
2020-09-11 13:13:14 +00:00
|
|
|
$target = $pointer;
|
|
|
|
}
|
2020-07-22 19:01:13 +00:00
|
|
|
}
|
|
|
|
|
2020-05-11 15:54:06 +00:00
|
|
|
// Insert required number of wrappers
|
|
|
|
while ( $curLevel < $desiredLevel ) {
|
|
|
|
$list = $target->ownerDocument->createElement( $listType );
|
2020-05-15 00:23:50 +00:00
|
|
|
// Setting modified would only be needed for removeAddedListItem,
|
|
|
|
// which isn't needed on the server
|
|
|
|
// $list->setAttribute( 'dt-modified', 'new' );
|
2020-05-11 15:54:06 +00:00
|
|
|
$item = $target->ownerDocument->createElement( $itemType );
|
2020-05-15 00:23:50 +00:00
|
|
|
// $item->setAttribute( 'dt-modified', 'new' );
|
2020-05-11 15:54:06 +00:00
|
|
|
|
|
|
|
$parent->insertBefore( $list, $target->nextSibling );
|
|
|
|
$list->appendChild( $item );
|
|
|
|
|
|
|
|
$target = $item;
|
|
|
|
$parent = $list;
|
|
|
|
$curLevel++;
|
|
|
|
}
|
2020-06-04 18:48:58 +00:00
|
|
|
} else {
|
2020-05-11 15:54:06 +00:00
|
|
|
// Split the ancestor nodes after the target to decrease nesting.
|
|
|
|
|
|
|
|
do {
|
2021-04-21 11:42:29 +00:00
|
|
|
if ( !$target || !$parent ) {
|
2022-10-28 18:24:02 +00:00
|
|
|
throw new LogicException( 'Can not decrease nesting any more' );
|
2021-04-21 11:42:29 +00:00
|
|
|
}
|
|
|
|
|
2020-05-11 15:54:06 +00:00
|
|
|
// If target is the last child of its parent, no need to split it
|
|
|
|
if ( $target->nextSibling ) {
|
|
|
|
// Create new identical node after the parent
|
|
|
|
$newNode = $parent->cloneNode( false );
|
2020-05-15 00:23:50 +00:00
|
|
|
// $parent->setAttribute( 'dt-modified', 'split' );
|
2020-05-11 15:54:06 +00:00
|
|
|
$parent->parentNode->insertBefore( $newNode, $parent->nextSibling );
|
|
|
|
|
|
|
|
// Move nodes following target to the new node
|
|
|
|
while ( $target->nextSibling ) {
|
|
|
|
$newNode->appendChild( $target->nextSibling );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
$target = $parent;
|
|
|
|
$parent = $parent->parentNode;
|
|
|
|
|
|
|
|
// Decrease nesting level if we escaped outside of a list
|
|
|
|
if ( isset( $listTypeMap[ strtolower( $target->tagName ) ] ) ) {
|
|
|
|
$curLevel--;
|
|
|
|
}
|
|
|
|
} while ( $curLevel >= $desiredLevel );
|
|
|
|
|
|
|
|
// parent is now a list, target is a list item
|
2020-08-04 00:04:20 +00:00
|
|
|
if ( $itemType === strtolower( $target->tagName ) ) {
|
|
|
|
$item = $target->ownerDocument->createElement( $itemType );
|
|
|
|
// $item->setAttribute( 'dt-modified', 'new' );
|
|
|
|
$parent->insertBefore( $item, $target->nextSibling );
|
|
|
|
|
|
|
|
} else {
|
|
|
|
// This is the wrong type of list, split it one more time
|
|
|
|
|
|
|
|
// If target is the last child of its parent, no need to split it
|
|
|
|
if ( $target->nextSibling ) {
|
|
|
|
// Create new identical node after the parent
|
|
|
|
$newNode = $parent->cloneNode( false );
|
|
|
|
// $parent->setAttribute( 'dt-modified', 'split' );
|
|
|
|
$parent->parentNode->insertBefore( $newNode, $parent->nextSibling );
|
|
|
|
|
|
|
|
// Move nodes following target to the new node
|
|
|
|
while ( $target->nextSibling ) {
|
|
|
|
$newNode->appendChild( $target->nextSibling );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
$target = $parent;
|
|
|
|
$parent = $parent->parentNode;
|
|
|
|
|
|
|
|
// Insert a list of the right type in the middle
|
|
|
|
$list = $target->ownerDocument->createElement( $listType );
|
|
|
|
// Setting modified would only be needed for removeAddedListItem,
|
|
|
|
// which isn't needed on the server
|
|
|
|
// $list->setAttribute( 'dt-modified', 'new' );
|
|
|
|
$item = $target->ownerDocument->createElement( $itemType );
|
|
|
|
// $item->setAttribute( 'dt-modified', 'new' );
|
|
|
|
|
|
|
|
$parent->insertBefore( $list, $target->nextSibling );
|
|
|
|
$list->appendChild( $item );
|
|
|
|
}
|
2020-05-11 15:54:06 +00:00
|
|
|
}
|
|
|
|
|
2020-06-10 20:20:05 +00:00
|
|
|
if ( $item === null ) {
|
2022-10-28 18:24:02 +00:00
|
|
|
throw new LogicException( 'No item found' );
|
2020-06-10 20:20:05 +00:00
|
|
|
}
|
|
|
|
|
2020-05-11 15:54:06 +00:00
|
|
|
return $item;
|
|
|
|
}
|
|
|
|
|
2020-11-20 00:21:30 +00:00
|
|
|
/**
|
|
|
|
* Check all elements in a node list are of a given type
|
|
|
|
*
|
|
|
|
* Also returns false if there are no elements in the list
|
|
|
|
*
|
2022-03-03 16:29:39 +00:00
|
|
|
* @param iterable<Node> $nodes Node list
|
2020-11-20 00:21:30 +00:00
|
|
|
* @param string $type Element type
|
|
|
|
* @return bool
|
|
|
|
*/
|
2022-03-03 16:29:39 +00:00
|
|
|
private static function allOfType( iterable $nodes, string $type ): bool {
|
2020-11-20 00:21:30 +00:00
|
|
|
$hasElements = false;
|
|
|
|
foreach ( $nodes as $node ) {
|
2022-02-21 18:42:36 +00:00
|
|
|
if ( $node instanceof Element ) {
|
2022-02-21 18:54:15 +00:00
|
|
|
if ( strtolower( $node->tagName ) !== strtolower( $type ) ) {
|
2020-11-20 00:21:30 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
$hasElements = true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return $hasElements;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Remove unnecessary list wrappers from a comment fragment
|
|
|
|
*
|
|
|
|
* TODO: Implement this in JS if required
|
|
|
|
*
|
2021-07-29 02:16:15 +00:00
|
|
|
* @param DocumentFragment $fragment Fragment
|
2020-11-20 00:21:30 +00:00
|
|
|
*/
|
2021-12-01 14:53:20 +00:00
|
|
|
public static function unwrapFragment( DocumentFragment $fragment ): void {
|
2020-11-20 00:21:30 +00:00
|
|
|
// Wrap orphaned list items
|
|
|
|
$list = null;
|
2022-06-09 13:51:33 +00:00
|
|
|
if ( static::allOfType( $fragment->childNodes, 'dd' ) ) {
|
2020-11-20 00:21:30 +00:00
|
|
|
$list = $fragment->ownerDocument->createElement( 'dl' );
|
2022-06-09 13:51:33 +00:00
|
|
|
} elseif ( static::allOfType( $fragment->childNodes, 'li' ) ) {
|
2020-11-20 00:21:30 +00:00
|
|
|
$list = $fragment->ownerDocument->createElement( 'ul' );
|
|
|
|
}
|
|
|
|
if ( $list ) {
|
|
|
|
while ( $fragment->firstChild ) {
|
|
|
|
$list->appendChild( $fragment->firstChild );
|
|
|
|
}
|
|
|
|
$fragment->appendChild( $list );
|
|
|
|
}
|
|
|
|
|
|
|
|
// If all child nodes are lists of the same type, unwrap them
|
|
|
|
while (
|
2022-06-09 13:51:33 +00:00
|
|
|
static::allOfType( $fragment->childNodes, 'dl' ) ||
|
|
|
|
static::allOfType( $fragment->childNodes, 'ul' ) ||
|
2024-06-20 15:22:03 +00:00
|
|
|
static::allOfType( $fragment->childNodes, 'ol' ) ||
|
|
|
|
(
|
|
|
|
// Or if the comment starts with a bullet followed by indents
|
|
|
|
count( $fragment->childNodes ) > 1 &&
|
|
|
|
static::allOfType( [ $fragment->childNodes[0] ], 'ul' ) &&
|
|
|
|
static::allOfType( array_slice( iterator_to_array( $fragment->childNodes ), 1 ), 'dl' )
|
|
|
|
)
|
2020-11-20 00:21:30 +00:00
|
|
|
) {
|
2022-03-03 16:29:39 +00:00
|
|
|
// Do not iterate over childNodes while we're modifying it
|
|
|
|
$childNodeList = iterator_to_array( $fragment->childNodes );
|
2020-11-20 00:21:30 +00:00
|
|
|
foreach ( $childNodeList as $node ) {
|
2022-06-09 13:51:33 +00:00
|
|
|
static::unwrapList( $node, $fragment );
|
2020-11-20 00:21:30 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-05-15 00:23:50 +00:00
|
|
|
// removeAddedListItem is only needed in the client
|
2020-05-11 15:54:06 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Unwrap a top level list, converting list item text to paragraphs
|
|
|
|
*
|
2020-11-20 00:21:30 +00:00
|
|
|
* Assumes that the list has a parent node, or is a root child in the provided
|
|
|
|
* document fragment.
|
2020-05-11 15:54:06 +00:00
|
|
|
*
|
2021-07-29 02:16:15 +00:00
|
|
|
* @param Node $list DOM node, will be wrapped if it is a list element (dl/ol/ul)
|
|
|
|
* @param DocumentFragment|null $fragment Containing document fragment if list has no parent
|
2020-05-11 15:54:06 +00:00
|
|
|
*/
|
2021-07-29 02:16:15 +00:00
|
|
|
public static function unwrapList( Node $list, ?DocumentFragment $fragment = null ): void {
|
2020-06-24 18:19:06 +00:00
|
|
|
if ( !(
|
2021-07-29 02:16:15 +00:00
|
|
|
$list instanceof Element && (
|
2020-06-24 18:19:06 +00:00
|
|
|
strtolower( $list->tagName ) === 'dl' ||
|
|
|
|
strtolower( $list->tagName ) === 'ol' ||
|
|
|
|
strtolower( $list->tagName ) === 'ul'
|
|
|
|
)
|
|
|
|
) ) {
|
|
|
|
// Not a list, leave alone (e.g. auto-generated ref block)
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-05-26 20:47:46 +00:00
|
|
|
// If the whole list is a template return it unmodified (T253150)
|
|
|
|
if ( CommentUtils::getTranscludedFromElement( $list ) ) {
|
|
|
|
return;
|
|
|
|
}
|
2020-05-11 15:54:06 +00:00
|
|
|
|
2024-05-24 12:43:07 +00:00
|
|
|
$doc = $list->ownerDocument;
|
|
|
|
$container = $fragment ?: $list->parentNode;
|
|
|
|
$referenceNode = $list;
|
2020-05-11 15:54:06 +00:00
|
|
|
while ( $list->firstChild ) {
|
2022-02-21 18:42:36 +00:00
|
|
|
if ( $list->firstChild instanceof Element ) {
|
2020-05-11 15:54:06 +00:00
|
|
|
// Move <dd> contents to <p>
|
|
|
|
$p = $doc->createElement( 'p' );
|
|
|
|
while ( $list->firstChild->firstChild ) {
|
|
|
|
// If contents is a block element, place outside the paragraph
|
|
|
|
// and start a new paragraph after
|
2020-08-11 04:22:55 +00:00
|
|
|
if ( CommentUtils::isBlockElement( $list->firstChild->firstChild ) ) {
|
2020-05-11 15:54:06 +00:00
|
|
|
if ( $p->firstChild ) {
|
2020-06-03 12:53:36 +00:00
|
|
|
$insertBefore = $referenceNode->nextSibling;
|
2020-05-26 20:47:46 +00:00
|
|
|
$referenceNode = $p;
|
2020-06-03 12:53:36 +00:00
|
|
|
$container->insertBefore( $p, $insertBefore );
|
2020-05-11 15:54:06 +00:00
|
|
|
}
|
2020-06-03 12:53:36 +00:00
|
|
|
$insertBefore = $referenceNode->nextSibling;
|
2020-05-26 20:47:46 +00:00
|
|
|
$referenceNode = $list->firstChild->firstChild;
|
2020-06-03 12:53:36 +00:00
|
|
|
$container->insertBefore( $list->firstChild->firstChild, $insertBefore );
|
2020-05-11 15:54:06 +00:00
|
|
|
$p = $doc->createElement( 'p' );
|
|
|
|
} else {
|
|
|
|
$p->appendChild( $list->firstChild->firstChild );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if ( $p->firstChild ) {
|
2020-06-03 12:53:36 +00:00
|
|
|
$insertBefore = $referenceNode->nextSibling;
|
2020-05-26 20:47:46 +00:00
|
|
|
$referenceNode = $p;
|
2020-06-03 12:53:36 +00:00
|
|
|
$container->insertBefore( $p, $insertBefore );
|
2020-05-11 15:54:06 +00:00
|
|
|
}
|
|
|
|
$list->removeChild( $list->firstChild );
|
|
|
|
} else {
|
|
|
|
// Text node / comment node, probably empty
|
2020-06-03 12:53:36 +00:00
|
|
|
$insertBefore = $referenceNode->nextSibling;
|
2020-05-26 20:47:46 +00:00
|
|
|
$referenceNode = $list->firstChild;
|
2020-06-03 12:53:36 +00:00
|
|
|
$container->insertBefore( $list->firstChild, $insertBefore );
|
2020-05-11 15:54:06 +00:00
|
|
|
}
|
|
|
|
}
|
2020-05-26 20:47:46 +00:00
|
|
|
$container->removeChild( $list );
|
2020-05-11 15:54:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Add another list item after the given one.
|
|
|
|
*/
|
2021-07-29 02:16:15 +00:00
|
|
|
public static function addSiblingListItem( Element $previousItem ): Element {
|
2020-06-10 16:22:27 +00:00
|
|
|
$listItem = $previousItem->ownerDocument->createElement( $previousItem->tagName );
|
2020-05-11 15:54:06 +00:00
|
|
|
$previousItem->parentNode->insertBefore( $listItem, $previousItem->nextSibling );
|
|
|
|
return $listItem;
|
|
|
|
}
|
|
|
|
|
2020-06-26 22:37:35 +00:00
|
|
|
/**
|
|
|
|
* Create an element that will convert to the provided wikitext
|
|
|
|
*/
|
2021-07-29 02:16:15 +00:00
|
|
|
public static function createWikitextNode( Document $doc, string $wikitext ): Element {
|
2020-06-26 22:37:35 +00:00
|
|
|
$span = $doc->createElement( 'span' );
|
|
|
|
|
|
|
|
$span->setAttribute( 'typeof', 'mw:Transclusion' );
|
2020-11-11 08:31:59 +00:00
|
|
|
$span->setAttribute( 'data-mw', json_encode( [ 'parts' => [ $wikitext ] ] ) );
|
2020-06-26 22:37:35 +00:00
|
|
|
|
|
|
|
return $span;
|
|
|
|
}
|
|
|
|
|
2022-08-12 20:03:00 +00:00
|
|
|
/**
|
|
|
|
* Check if an element created by ::createWikitextNode() starts with list item markup.
|
|
|
|
*/
|
|
|
|
private static function isWikitextNodeListItem( Element $node ): bool {
|
|
|
|
$dataMw = json_decode( $node->getAttribute( 'data-mw' ) ?? '', true );
|
|
|
|
$wikitextLine = $dataMw['parts'][0] ?? null;
|
|
|
|
return $wikitextLine && is_string( $wikitextLine ) &&
|
2023-06-06 12:08:00 +00:00
|
|
|
in_array( $wikitextLine[0], [ '*', '#', ':', ';' ], true );
|
2022-08-12 20:03:00 +00:00
|
|
|
}
|
|
|
|
|
2020-07-15 21:43:56 +00:00
|
|
|
/**
|
|
|
|
* Append a user signature to the comment in the container.
|
|
|
|
*/
|
2022-01-29 20:20:51 +00:00
|
|
|
public static function appendSignature( DocumentFragment $container, string $signature ): void {
|
2020-07-15 21:43:56 +00:00
|
|
|
$doc = $container->ownerDocument;
|
|
|
|
|
2022-08-06 12:51:02 +00:00
|
|
|
// If the last node isn't a paragraph (e.g. it's a list created in visual mode),
|
|
|
|
// or looks like a list item created in wikitext mode (T263217),
|
|
|
|
// then add another paragraph to contain the signature.
|
|
|
|
$wrapperNode = $container->lastChild;
|
|
|
|
if (
|
|
|
|
!( $wrapperNode instanceof Element ) ||
|
|
|
|
strtolower( $wrapperNode->tagName ) !== 'p' ||
|
|
|
|
(
|
|
|
|
// This would be easier to check in prepareWikitextReply(), but that would result
|
|
|
|
// in an empty list item being added at the end if we don't need to add a signature.
|
|
|
|
( $wtNode = $wrapperNode->lastChild ) &&
|
|
|
|
$wtNode instanceof Element &&
|
2022-08-12 20:03:00 +00:00
|
|
|
static::isWikitextNodeListItem( $wtNode )
|
2022-08-06 12:51:02 +00:00
|
|
|
)
|
|
|
|
) {
|
2020-07-15 21:43:56 +00:00
|
|
|
$container->appendChild( $doc->createElement( 'p' ) );
|
2021-03-06 18:07:56 +00:00
|
|
|
}
|
|
|
|
// If the last node is empty, trim the signature to prevent leading whitespace triggering
|
|
|
|
// preformatted text (T269188, T276612)
|
|
|
|
if ( !$container->lastChild->firstChild ) {
|
2021-03-24 17:48:04 +00:00
|
|
|
$signature = ltrim( $signature, ' ' );
|
2020-07-15 21:43:56 +00:00
|
|
|
}
|
|
|
|
// Sign the last line
|
|
|
|
$container->lastChild->appendChild(
|
2022-06-09 13:51:33 +00:00
|
|
|
static::createWikitextNode(
|
2020-07-15 21:43:56 +00:00
|
|
|
$doc,
|
2020-12-02 19:33:44 +00:00
|
|
|
$signature
|
2020-07-15 21:43:56 +00:00
|
|
|
)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2022-08-06 12:51:02 +00:00
|
|
|
/**
|
|
|
|
* Append a user signature to the comment in the provided wikitext.
|
|
|
|
*/
|
|
|
|
public static function appendSignatureWikitext( string $wikitext, string $signature ): string {
|
|
|
|
$wikitext = CommentUtils::htmlTrim( $wikitext );
|
|
|
|
|
|
|
|
$lines = explode( "\n", $wikitext );
|
|
|
|
$lastLine = end( $lines );
|
|
|
|
|
|
|
|
// If last line looks like a list item, add an empty line afterwards for the signature (T263217)
|
2023-06-06 12:08:00 +00:00
|
|
|
if ( $lastLine && in_array( $lastLine[0], [ '*', '#', ':', ';' ], true ) ) {
|
2022-08-06 12:51:02 +00:00
|
|
|
$wikitext .= "\n";
|
|
|
|
// Trim the signature to prevent leading whitespace triggering preformatted text (T269188, T276612)
|
|
|
|
$signature = ltrim( $signature, ' ' );
|
|
|
|
}
|
|
|
|
|
|
|
|
return $wikitext . $signature;
|
|
|
|
}
|
|
|
|
|
2020-06-26 22:00:34 +00:00
|
|
|
/**
|
|
|
|
* Add a reply to a specific comment
|
|
|
|
*
|
2022-03-18 03:28:06 +00:00
|
|
|
* @param ContentThreadItem $comment Comment being replied to
|
2022-01-29 20:20:51 +00:00
|
|
|
* @param DocumentFragment $container Container of comment DOM nodes
|
2020-06-26 22:00:34 +00:00
|
|
|
*/
|
2022-03-18 03:28:06 +00:00
|
|
|
public static function addReply( ContentThreadItem $comment, DocumentFragment $container ): void {
|
2021-03-15 14:29:19 +00:00
|
|
|
$services = MediaWikiServices::getInstance();
|
|
|
|
$dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' );
|
|
|
|
$replyIndentation = $dtConfig->get( 'DiscussionToolsReplyIndentation' );
|
|
|
|
|
2020-06-26 22:00:34 +00:00
|
|
|
$newParsoidItem = null;
|
|
|
|
// Transfer comment DOM to Parsoid DOM
|
|
|
|
// Wrap every root node of the document in a new list item (dd/li).
|
|
|
|
// In wikitext mode every root node is a paragraph.
|
|
|
|
// In visual mode the editor takes care of preventing problematic nodes
|
|
|
|
// like <table> or <h2> from ever occurring in the comment.
|
|
|
|
while ( $container->childNodes->length ) {
|
|
|
|
if ( !$newParsoidItem ) {
|
2022-06-09 13:51:33 +00:00
|
|
|
$newParsoidItem = static::addListItem( $comment, $replyIndentation );
|
2020-06-26 22:00:34 +00:00
|
|
|
} else {
|
2022-06-09 13:51:33 +00:00
|
|
|
$newParsoidItem = static::addSiblingListItem( $newParsoidItem );
|
2020-06-26 22:00:34 +00:00
|
|
|
}
|
2022-08-12 20:03:00 +00:00
|
|
|
|
|
|
|
// Suppress space after the indentation character to support nested lists (T238218).
|
|
|
|
// By request from the community, avoid this if possible after bullet indentation (T259864).
|
|
|
|
if ( !(
|
|
|
|
$replyIndentation === 'bullet' &&
|
|
|
|
( $wtNode = $container->firstChild->lastChild ) &&
|
|
|
|
$wtNode instanceof Element &&
|
|
|
|
!static::isWikitextNodeListItem( $wtNode )
|
|
|
|
) ) {
|
|
|
|
static::whitespaceParsoidHack( $newParsoidItem );
|
|
|
|
}
|
|
|
|
|
2020-06-26 22:00:34 +00:00
|
|
|
$newParsoidItem->appendChild( $container->firstChild );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-02-02 00:13:32 +00:00
|
|
|
/**
|
|
|
|
* Transfer comment DOM nodes into a list node, as if adding a reply, but without requiring a
|
|
|
|
* ThreadItem.
|
|
|
|
*
|
|
|
|
* @param DocumentFragment $container Container of comment DOM nodes
|
|
|
|
* @return Element $node List node
|
|
|
|
*/
|
|
|
|
public static function transferReply( DocumentFragment $container ): Element {
|
|
|
|
$services = MediaWikiServices::getInstance();
|
|
|
|
$dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' );
|
|
|
|
$replyIndentation = $dtConfig->get( 'DiscussionToolsReplyIndentation' );
|
|
|
|
|
|
|
|
$doc = $container->ownerDocument;
|
|
|
|
|
|
|
|
// Like addReply(), but we make our own list
|
|
|
|
$list = $doc->createElement( $replyIndentation === 'invisible' ? 'dl' : 'ul' );
|
|
|
|
while ( $container->childNodes->length ) {
|
|
|
|
$item = $doc->createElement( $replyIndentation === 'invisible' ? 'dd' : 'li' );
|
2022-08-12 20:03:00 +00:00
|
|
|
// Suppress space after the indentation character to support nested lists (T238218).
|
|
|
|
// By request from the community, avoid this if possible after bullet indentation (T259864).
|
|
|
|
if ( !(
|
|
|
|
$replyIndentation === 'bullet' &&
|
|
|
|
( $wtNode = $container->firstChild->lastChild ) &&
|
|
|
|
$wtNode instanceof Element &&
|
|
|
|
!static::isWikitextNodeListItem( $wtNode )
|
|
|
|
) ) {
|
|
|
|
static::whitespaceParsoidHack( $item );
|
|
|
|
}
|
2022-02-02 00:13:32 +00:00
|
|
|
$item->appendChild( $container->firstChild );
|
|
|
|
$list->appendChild( $item );
|
|
|
|
}
|
|
|
|
return $list;
|
|
|
|
}
|
|
|
|
|
2020-05-11 15:54:06 +00:00
|
|
|
/**
|
2020-06-26 22:37:35 +00:00
|
|
|
* Create a container of comment DOM nodes from wikitext
|
2020-05-15 21:35:50 +00:00
|
|
|
*
|
2022-01-29 20:20:51 +00:00
|
|
|
* @param Document $doc Document where the DOM nodes will be inserted
|
2020-11-11 08:31:59 +00:00
|
|
|
* @param string $wikitext
|
2022-01-29 20:20:51 +00:00
|
|
|
* @return DocumentFragment DOM nodes
|
2020-05-11 15:54:06 +00:00
|
|
|
*/
|
2022-01-29 20:20:51 +00:00
|
|
|
public static function prepareWikitextReply( Document $doc, string $wikitext ): DocumentFragment {
|
|
|
|
$container = $doc->createDocumentFragment();
|
2020-06-26 22:37:35 +00:00
|
|
|
|
2022-06-09 13:51:33 +00:00
|
|
|
$wikitext = static::sanitizeWikitextLinebreaks( $wikitext );
|
2020-06-26 22:24:14 +00:00
|
|
|
|
2020-06-26 22:37:35 +00:00
|
|
|
$lines = explode( "\n", $wikitext );
|
|
|
|
foreach ( $lines as $line ) {
|
|
|
|
$p = $doc->createElement( 'p' );
|
2022-06-09 13:51:33 +00:00
|
|
|
$p->appendChild( static::createWikitextNode( $doc, $line ) );
|
2020-06-26 22:37:35 +00:00
|
|
|
$container->appendChild( $p );
|
|
|
|
}
|
2020-07-15 21:43:56 +00:00
|
|
|
|
2022-01-29 20:20:51 +00:00
|
|
|
return $container;
|
2020-06-26 22:37:35 +00:00
|
|
|
}
|
2020-05-11 15:54:06 +00:00
|
|
|
|
2020-06-26 22:37:35 +00:00
|
|
|
/**
|
|
|
|
* Create a container of comment DOM nodes from HTML
|
|
|
|
*
|
2022-01-29 20:20:51 +00:00
|
|
|
* @param Document $doc Document where the DOM nodes will be inserted
|
2020-11-11 08:31:59 +00:00
|
|
|
* @param string $html
|
2022-01-29 20:20:51 +00:00
|
|
|
* @return DocumentFragment DOM nodes
|
2020-06-26 22:37:35 +00:00
|
|
|
*/
|
2022-01-29 20:20:51 +00:00
|
|
|
public static function prepareHtmlReply( Document $doc, string $html ): DocumentFragment {
|
|
|
|
$container = DOMUtils::parseHTMLToFragment( $doc, $html );
|
2020-06-26 22:37:35 +00:00
|
|
|
|
|
|
|
// Remove empty lines
|
|
|
|
// This should really be anything that serializes to empty string in wikitext,
|
|
|
|
// (e.g. <h2></h2>) but this will catch most cases
|
|
|
|
// Create a non-live child node list, so we don't have to worry about it changing
|
|
|
|
// as nodes are removed.
|
|
|
|
$childNodeList = iterator_to_array( $container->childNodes );
|
|
|
|
foreach ( $childNodeList as $node ) {
|
2022-01-29 17:53:23 +00:00
|
|
|
if ( (
|
|
|
|
$node instanceof Text &&
|
|
|
|
CommentUtils::htmlTrim( $node->nodeValue ?? '' ) === ''
|
|
|
|
) || (
|
|
|
|
$node instanceof Element &&
|
|
|
|
strtolower( $node->tagName ) === 'p' &&
|
2020-06-26 22:37:35 +00:00
|
|
|
CommentUtils::htmlTrim( DOMCompat::getInnerHTML( $node ) ) === ''
|
2022-01-29 17:53:23 +00:00
|
|
|
) ) {
|
2020-06-26 22:37:35 +00:00
|
|
|
$container->removeChild( $node );
|
|
|
|
}
|
|
|
|
}
|
2020-07-15 21:43:56 +00:00
|
|
|
|
2022-01-29 20:20:51 +00:00
|
|
|
return $container;
|
|
|
|
}
|
2020-07-15 21:43:56 +00:00
|
|
|
|
2022-01-29 20:20:51 +00:00
|
|
|
/**
|
|
|
|
* Add a reply in the DOM to a comment using wikitext.
|
|
|
|
*
|
2022-03-18 03:28:06 +00:00
|
|
|
* @param ContentCommentItem $comment Comment being replied to
|
2022-01-29 20:20:51 +00:00
|
|
|
* @param string $wikitext
|
2022-02-04 21:58:20 +00:00
|
|
|
* @param string|null $signature
|
2022-01-29 20:20:51 +00:00
|
|
|
*/
|
2022-03-18 03:28:06 +00:00
|
|
|
public static function addWikitextReply(
|
|
|
|
ContentCommentItem $comment, string $wikitext, string $signature = null
|
|
|
|
): void {
|
2022-01-29 20:20:51 +00:00
|
|
|
$doc = $comment->getRange()->endContainer->ownerDocument;
|
2022-06-09 13:51:33 +00:00
|
|
|
$container = static::prepareWikitextReply( $doc, $wikitext );
|
2022-02-04 21:58:20 +00:00
|
|
|
if ( $signature !== null ) {
|
2022-06-09 13:51:33 +00:00
|
|
|
static::appendSignature( $container, $signature );
|
2022-01-29 20:20:51 +00:00
|
|
|
}
|
2022-06-09 13:51:33 +00:00
|
|
|
static::addReply( $comment, $container );
|
2020-05-11 15:54:06 +00:00
|
|
|
}
|
2020-06-26 22:37:35 +00:00
|
|
|
|
2022-01-29 20:20:51 +00:00
|
|
|
/**
|
|
|
|
* Add a reply in the DOM to a comment using HTML.
|
|
|
|
*
|
2022-03-18 03:28:06 +00:00
|
|
|
* @param ContentCommentItem $comment Comment being replied to
|
2022-01-29 20:20:51 +00:00
|
|
|
* @param string $html
|
2022-02-04 21:58:20 +00:00
|
|
|
* @param string|null $signature
|
2022-01-29 20:20:51 +00:00
|
|
|
*/
|
2022-03-18 03:28:06 +00:00
|
|
|
public static function addHtmlReply(
|
|
|
|
ContentCommentItem $comment, string $html, string $signature = null
|
|
|
|
): void {
|
2022-01-29 20:20:51 +00:00
|
|
|
$doc = $comment->getRange()->endContainer->ownerDocument;
|
2022-06-09 13:51:33 +00:00
|
|
|
$container = static::prepareHtmlReply( $doc, $html );
|
2022-02-04 21:58:20 +00:00
|
|
|
if ( $signature !== null ) {
|
2022-06-09 13:51:33 +00:00
|
|
|
static::appendSignature( $container, $signature );
|
2022-01-29 20:20:51 +00:00
|
|
|
}
|
2022-06-09 13:51:33 +00:00
|
|
|
static::addReply( $comment, $container );
|
2022-01-29 20:20:51 +00:00
|
|
|
}
|
2020-05-11 15:54:06 +00:00
|
|
|
}
|