Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
64.53% covered (warning)
64.53%
151 / 234
36.84% covered (danger)
36.84%
7 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentModifier
64.53% covered (warning)
64.53%
151 / 234
36.84% covered (danger)
36.84%
7 / 19
628.52
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 whitespaceParsoidHack
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 sanitizeWikitextLinebreaks
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 addReplyLink
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 addListItem
94.25% covered (success)
94.25%
82 / 87
0.00% covered (danger)
0.00%
0 / 1
34.22
 allOfType
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 unwrapFragment
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
90
 unwrapList
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
13
 addSiblingListItem
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 createWikitextNode
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isWikitextNodeListItem
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 appendSignature
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 appendSignatureWikitext
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 addReply
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
56
 transferReply
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
72
 prepareWikitextReply
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 prepareHtmlReply
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
56
 addWikitextReply
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 addHtmlReply
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools;
4
5use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem;
6use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentThreadItem;
7use MediaWiki\MediaWikiServices;
8use MWException;
9use Wikimedia\Assert\Assert;
10use Wikimedia\Parsoid\DOM\Document;
11use Wikimedia\Parsoid\DOM\DocumentFragment;
12use Wikimedia\Parsoid\DOM\Element;
13use Wikimedia\Parsoid\DOM\Node;
14use Wikimedia\Parsoid\DOM\Text;
15use Wikimedia\Parsoid\Utils\DOMCompat;
16use Wikimedia\Parsoid\Utils\DOMUtils;
17
18class CommentModifier {
19
20    private function __construct() {
21    }
22
23    /**
24     * Add an attribute to a list item to remove pre-whitespace in Parsoid
25     *
26     * @param Element $listItem
27     */
28    private static function whitespaceParsoidHack( Element $listItem ): void {
29        // HACK: Setting data-parsoid removes the whitespace after the list item,
30        // which makes nested lists work.
31        // This is undocumented behaviour and probably very fragile.
32        $listItem->setAttribute( 'data-parsoid', '{}' );
33    }
34
35    /**
36     * Remove extra linebreaks from a wikitext string
37     *
38     * @param string $wikitext
39     * @return string
40     */
41    public static function sanitizeWikitextLinebreaks( string $wikitext ): string {
42        $wikitext = CommentUtils::htmlTrim( $wikitext );
43        $wikitext = preg_replace( "/\r/", "\n", $wikitext );
44        $wikitext = preg_replace( "/\n+/", "\n", $wikitext );
45        return $wikitext;
46    }
47
48    /**
49     * Given a comment and a reply link, add the reply link to its document's DOM tree, at the end of
50     * the comment.
51     *
52     * @param ContentCommentItem $comment
53     * @param Node $linkNode Reply link
54     */
55    public static function addReplyLink( ContentCommentItem $comment, Node $linkNode ): void {
56        $target = $comment->getRange()->endContainer;
57
58        // Insert the link before trailing whitespace.
59        // In the MediaWiki parser output, <ul>/<dl> nodes are preceded by a newline. Normally it isn't
60        // visible on the page. But if we insert an inline element (the reply link) after it, it becomes
61        // meaningful and gets rendered, which results in additional spacing before some reply links.
62        // Split the text node, so that we can insert the link before the trailing whitespace.
63        if ( $target instanceof Text ) {
64            preg_match( '/\s*$/', $target->nodeValue ?? '', $matches, PREG_OFFSET_CAPTURE );
65            $byteOffset = $matches[0][1];
66            $charOffset = mb_strlen(
67                substr( $target->nodeValue ?? '', 0, $byteOffset )
68            );
69            $target->splitText( $charOffset );
70        }
71
72        $target->parentNode->insertBefore( $linkNode, $target->nextSibling );
73    }
74
75    /**
76     * Given a comment, add a list item to its document's DOM tree, inside of which a reply to said
77     * comment can be added.
78     *
79     * The DOM tree is suitably rearranged to ensure correct indentation level of the reply (wrapper
80     * nodes are added, and other nodes may be moved around).
81     *
82     * @param ContentThreadItem $comment
83     * @param string $replyIndentation Reply indentation syntax to use, one of:
84     *   - 'invisible' (use `<dl><dd>` tags to output `:` in wikitext)
85     *   - 'bullet' (use `<ul><li>` tags to output `*` in wikitext)
86     * @return Element
87     */
88    public static function addListItem( ContentThreadItem $comment, string $replyIndentation ): Element {
89        $listTypeMap = [
90            'li' => 'ul',
91            'dd' => 'dl'
92        ];
93
94        // 1. Start at given comment
95        // 2. Skip past all comments with level greater than the given
96        //    (or in other words, all replies, and replies to replies, and so on)
97        // 3. Add comment with level of the given comment plus 1
98
99        $curComment = $comment;
100        while ( count( $curComment->getReplies() ) ) {
101            $replies = $curComment->getReplies();
102            $curComment = end( $replies );
103        }
104
105        // Tag names for lists and items we're going to insert
106        if ( $replyIndentation === 'invisible' ) {
107            $itemType = 'dd';
108        } elseif ( $replyIndentation === 'bullet' ) {
109            $itemType = 'li';
110        } else {
111            throw new MWException( "Invalid reply indentation syntax '$replyIndentation'" );
112        }
113        $listType = $listTypeMap[ $itemType ];
114
115        $desiredLevel = $comment->getLevel() + 1;
116        $target = $curComment->getRange()->endContainer;
117
118        // target is a text node or an inline element at the end of a "paragraph"
119        // (not necessarily paragraph node).
120        // First, we need to find a block-level parent that we can mess with.
121        // If we can't find a surrounding list item or paragraph (e.g. maybe we're inside a table cell
122        // or something), take the parent node and hope for the best.
123        $parent = CommentUtils::closestElement( $target, [ 'li', 'dd', 'p' ] ) ??
124            $target->parentNode;
125        while ( $target->parentNode !== $parent ) {
126            $target = $target->parentNode;
127        }
128        // parent is a list item or paragraph (hopefully)
129        // target is an inline node within it
130
131        // If the comment is fully covered by some wrapper element, insert replies outside that wrapper.
132        // This will often just be a paragraph node (<p>), but it can be a <div> or <table> that serves
133        // as some kind of a fancy frame, which are often used for barnstars and announcements.
134        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
135        $excludedWrapper = CommentUtils::closestElement( $target, [ 'section' ] ) ?:
136            $curComment->getRootNode();
137        $covered = CommentUtils::getFullyCoveredSiblings( $curComment, $excludedWrapper );
138        if ( $curComment->getLevel() === 1 && $covered ) {
139            $target = end( $covered );
140            $parent = $target->parentNode;
141        }
142
143        // If the comment is in a transclusion, insert replies after the transclusion. (T313100)
144        // This method should never be called in cases where that would be a bad idea.
145        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
146        $transclusionNode = CommentUtils::getTranscludedFromElement( $target );
147        if ( $transclusionNode ) {
148            while (
149                ( $nextSibling = $transclusionNode->nextSibling ) &&
150                $nextSibling instanceof Element &&
151                $nextSibling->getAttribute( 'about' ) === $transclusionNode->getAttribute( 'about' )
152            ) {
153                $transclusionNode = $nextSibling;
154            }
155            $target = $transclusionNode;
156            $parent = $target->parentNode;
157        }
158
159        // If we can't insert a list directly inside this element, insert after it.
160        // The covered wrapper check above handles most cases, but we still need this sometimes, such as:
161        // * If the comment starts in the middle of a list, then ends with an unindented p/pre, the
162        //   wrapper check doesn't adjust the parent
163        // * If the comment consists of multiple list items (starting with a <dt>, so that the comment is
164        //   considered to be unindented, that is level === 1), but not all of them, the wrapper check
165        //   adjusts the parent to be the list, and the rest of the algorithm doesn't handle that well
166        if (
167            strtolower( $parent->tagName ) === 'p' ||
168            strtolower( $parent->tagName ) === 'pre' ||
169            strtolower( $parent->tagName ) === 'ul' ||
170            strtolower( $parent->tagName ) === 'dl'
171        ) {
172            $parent = $parent->parentNode;
173            $target = $target->parentNode;
174        }
175
176        Assert::precondition( $target !== null, 'We have not stepped outside the document' );
177        // Instead of just using $curComment->getLevel(), consider indentation of lists within the
178        // comment (T252702)
179        $curLevel = CommentUtils::getIndentLevel( $target, $curComment->getRootNode() ) + 1;
180
181        $item = null;
182        if ( $desiredLevel === 1 ) {
183            // Special handling for top-level comments
184            // We use section=new API for adding them in PHP, so this should never happen
185            throw new MWException( "Can't add a top-level comment" );
186
187        } elseif ( $curLevel < $desiredLevel ) {
188            // Insert more lists after the target to increase nesting.
189
190            // Parsoid puts HTML comments (and other "rendering-transparent nodes", e.g. category links)
191            // which appear at the end of the line in wikitext outside the paragraph,
192            // but we usually shouldn't insert replies between the paragraph and such comments. (T257651)
193            // Skip over comments and whitespace, but only update target when skipping past comments.
194            $pointer = $target;
195            while (
196                $pointer->nextSibling && (
197                    CommentUtils::isRenderingTransparentNode( $pointer->nextSibling ) ||
198                    (
199                        $pointer->nextSibling instanceof Text &&
200                        CommentUtils::htmlTrim( $pointer->nextSibling->nodeValue ?? '' ) === '' &&
201                        // If more that two lines of whitespace are detected, the following HTML
202                        // comments are not considered to be part of the reply (T264026)
203                        !preg_match( '/(\r?\n){2,}/', $pointer->nextSibling->nodeValue ?? '' )
204                    )
205                )
206            ) {
207                $pointer = $pointer->nextSibling;
208                if ( CommentUtils::isRenderingTransparentNode( $pointer ) ) {
209                    $target = $pointer;
210                }
211            }
212
213            // Insert required number of wrappers
214            while ( $curLevel < $desiredLevel ) {
215                $list = $target->ownerDocument->createElement( $listType );
216                // Setting modified would only be needed for removeAddedListItem,
217                // which isn't needed on the server
218                // $list->setAttribute( 'dt-modified', 'new' );
219                $item = $target->ownerDocument->createElement( $itemType );
220                // $item->setAttribute( 'dt-modified', 'new' );
221
222                $parent->insertBefore( $list, $target->nextSibling );
223                $list->appendChild( $item );
224
225                $target = $item;
226                $parent = $list;
227                $curLevel++;
228            }
229        } else {
230            // Split the ancestor nodes after the target to decrease nesting.
231
232            do {
233                if ( !$target || !$parent ) {
234                    throw new \LogicException( 'Can not decrease nesting any more' );
235                }
236
237                // If target is the last child of its parent, no need to split it
238                if ( $target->nextSibling ) {
239                    // Create new identical node after the parent
240                    $newNode = $parent->cloneNode( false );
241                    // $parent->setAttribute( 'dt-modified', 'split' );
242                    $parent->parentNode->insertBefore( $newNode, $parent->nextSibling );
243
244                    // Move nodes following target to the new node
245                    while ( $target->nextSibling ) {
246                        $newNode->appendChild( $target->nextSibling );
247                    }
248                }
249
250                $target = $parent;
251                $parent = $parent->parentNode;
252
253                // Decrease nesting level if we escaped outside of a list
254                if ( isset( $listTypeMap[ strtolower( $target->tagName ) ] ) ) {
255                    $curLevel--;
256                }
257            } while ( $curLevel >= $desiredLevel );
258
259            // parent is now a list, target is a list item
260            if ( $itemType === strtolower( $target->tagName ) ) {
261                $item = $target->ownerDocument->createElement( $itemType );
262                // $item->setAttribute( 'dt-modified', 'new' );
263                $parent->insertBefore( $item, $target->nextSibling );
264
265            } else {
266                // This is the wrong type of list, split it one more time
267
268                // If target is the last child of its parent, no need to split it
269                if ( $target->nextSibling ) {
270                    // Create new identical node after the parent
271                    $newNode = $parent->cloneNode( false );
272                    // $parent->setAttribute( 'dt-modified', 'split' );
273                    $parent->parentNode->insertBefore( $newNode, $parent->nextSibling );
274
275                    // Move nodes following target to the new node
276                    while ( $target->nextSibling ) {
277                        $newNode->appendChild( $target->nextSibling );
278                    }
279                }
280
281                $target = $parent;
282                $parent = $parent->parentNode;
283
284                // Insert a list of the right type in the middle
285                $list = $target->ownerDocument->createElement( $listType );
286                // Setting modified would only be needed for removeAddedListItem,
287                // which isn't needed on the server
288                // $list->setAttribute( 'dt-modified', 'new' );
289                $item = $target->ownerDocument->createElement( $itemType );
290                // $item->setAttribute( 'dt-modified', 'new' );
291
292                $parent->insertBefore( $list, $target->nextSibling );
293                $list->appendChild( $item );
294            }
295        }
296
297        if ( $item === null ) {
298            throw new \LogicException( __METHOD__ . ' no item found' );
299        }
300
301        return $item;
302    }
303
304    /**
305     * Check all elements in a node list are of a given type
306     *
307     * Also returns false if there are no elements in the list
308     *
309     * @param iterable<Node> $nodes Node list
310     * @param string $type Element type
311     * @return bool
312     */
313    private static function allOfType( iterable $nodes, string $type ): bool {
314        $hasElements = false;
315        foreach ( $nodes as $node ) {
316            if ( $node instanceof Element ) {
317                if ( strtolower( $node->tagName ) !== strtolower( $type ) ) {
318                    return false;
319                }
320                $hasElements = true;
321            }
322        }
323        return $hasElements;
324    }
325
326    /**
327     * Remove unnecessary list wrappers from a comment fragment
328     *
329     * TODO: Implement this in JS if required
330     *
331     * @param DocumentFragment $fragment Fragment
332     */
333    public static function unwrapFragment( DocumentFragment $fragment ): void {
334        // Wrap orphaned list items
335        $list = null;
336        if ( static::allOfType( $fragment->childNodes, 'dd' ) ) {
337            $list = $fragment->ownerDocument->createElement( 'dl' );
338        } elseif ( static::allOfType( $fragment->childNodes, 'li' ) ) {
339            $list = $fragment->ownerDocument->createElement( 'ul' );
340        }
341        if ( $list ) {
342            while ( $fragment->firstChild ) {
343                $list->appendChild( $fragment->firstChild );
344            }
345            $fragment->appendChild( $list );
346        }
347
348        // If all child nodes are lists of the same type, unwrap them
349        while (
350            static::allOfType( $fragment->childNodes, 'dl' ) ||
351            static::allOfType( $fragment->childNodes, 'ul' ) ||
352            static::allOfType( $fragment->childNodes, 'ol' )
353        ) {
354            // Do not iterate over childNodes while we're modifying it
355            $childNodeList = iterator_to_array( $fragment->childNodes );
356            foreach ( $childNodeList as $node ) {
357                static::unwrapList( $node, $fragment );
358            }
359        }
360    }
361
362    // removeAddedListItem is only needed in the client
363
364    /**
365     * Unwrap a top level list, converting list item text to paragraphs
366     *
367     * Assumes that the list has a parent node, or is a root child in the provided
368     * document fragment.
369     *
370     * @param Node $list DOM node, will be wrapped if it is a list element (dl/ol/ul)
371     * @param DocumentFragment|null $fragment Containing document fragment if list has no parent
372     */
373    public static function unwrapList( Node $list, ?DocumentFragment $fragment = null ): void {
374        $doc = $list->ownerDocument;
375        $container = $fragment ?: $list->parentNode;
376        $referenceNode = $list;
377
378        if ( !(
379            $list instanceof Element && (
380                strtolower( $list->tagName ) === 'dl' ||
381                strtolower( $list->tagName ) === 'ol' ||
382                strtolower( $list->tagName ) === 'ul'
383            )
384        ) ) {
385            // Not a list, leave alone (e.g. auto-generated ref block)
386            return;
387        }
388
389        // If the whole list is a template return it unmodified (T253150)
390        if ( CommentUtils::getTranscludedFromElement( $list ) ) {
391            return;
392        }
393
394        while ( $list->firstChild ) {
395            if ( $list->firstChild instanceof Element ) {
396                // Move <dd> contents to <p>
397                $p = $doc->createElement( 'p' );
398                while ( $list->firstChild->firstChild ) {
399                    // If contents is a block element, place outside the paragraph
400                    // and start a new paragraph after
401                    if ( CommentUtils::isBlockElement( $list->firstChild->firstChild ) ) {
402                        if ( $p->firstChild ) {
403                            $insertBefore = $referenceNode->nextSibling;
404                            $referenceNode = $p;
405                            $container->insertBefore( $p, $insertBefore );
406                        }
407                        $insertBefore = $referenceNode->nextSibling;
408                        $referenceNode = $list->firstChild->firstChild;
409                        $container->insertBefore( $list->firstChild->firstChild, $insertBefore );
410                        $p = $doc->createElement( 'p' );
411                    } else {
412                        $p->appendChild( $list->firstChild->firstChild );
413                    }
414                }
415                if ( $p->firstChild ) {
416                    $insertBefore = $referenceNode->nextSibling;
417                    $referenceNode = $p;
418                    $container->insertBefore( $p, $insertBefore );
419                }
420                $list->removeChild( $list->firstChild );
421            } else {
422                // Text node / comment node, probably empty
423                $insertBefore = $referenceNode->nextSibling;
424                $referenceNode = $list->firstChild;
425                $container->insertBefore( $list->firstChild, $insertBefore );
426            }
427        }
428        $container->removeChild( $list );
429    }
430
431    /**
432     * Add another list item after the given one.
433     *
434     * @param Element $previousItem
435     * @return Element
436     */
437    public static function addSiblingListItem( Element $previousItem ): Element {
438        $listItem = $previousItem->ownerDocument->createElement( $previousItem->tagName );
439        $previousItem->parentNode->insertBefore( $listItem, $previousItem->nextSibling );
440        return $listItem;
441    }
442
443    /**
444     * Create an element that will convert to the provided wikitext
445     *
446     * @param Document $doc
447     * @param string $wikitext
448     * @return Element
449     */
450    public static function createWikitextNode( Document $doc, string $wikitext ): Element {
451        $span = $doc->createElement( 'span' );
452
453        $span->setAttribute( 'typeof', 'mw:Transclusion' );
454        $span->setAttribute( 'data-mw', json_encode( [ 'parts' => [ $wikitext ] ] ) );
455
456        return $span;
457    }
458
459    /**
460     * Check if an element created by ::createWikitextNode() starts with list item markup.
461     *
462     * @param Element $node
463     * @return bool
464     */
465    private static function isWikitextNodeListItem( Element $node ): bool {
466        $dataMw = json_decode( $node->getAttribute( 'data-mw' ) ?? '', true );
467        $wikitextLine = $dataMw['parts'][0] ?? null;
468        return $wikitextLine && is_string( $wikitextLine ) &&
469            in_array( $wikitextLine[0], [ '*', '#', ':', ';' ] );
470    }
471
472    /**
473     * Append a user signature to the comment in the container.
474     *
475     * @param DocumentFragment $container
476     * @param string $signature
477     */
478    public static function appendSignature( DocumentFragment $container, string $signature ): void {
479        $doc = $container->ownerDocument;
480
481        // If the last node isn't a paragraph (e.g. it's a list created in visual mode),
482        // or looks like a list item created in wikitext mode (T263217),
483        // then add another paragraph to contain the signature.
484        $wrapperNode = $container->lastChild;
485        if (
486            !( $wrapperNode instanceof Element ) ||
487            strtolower( $wrapperNode->tagName ) !== 'p' ||
488            (
489                // This would be easier to check in prepareWikitextReply(), but that would result
490                // in an empty list item being added at the end if we don't need to add a signature.
491                ( $wtNode = $wrapperNode->lastChild ) &&
492                $wtNode instanceof Element &&
493                static::isWikitextNodeListItem( $wtNode )
494            )
495        ) {
496            $container->appendChild( $doc->createElement( 'p' ) );
497        }
498        // If the last node is empty, trim the signature to prevent leading whitespace triggering
499        // preformatted text (T269188, T276612)
500        if ( !$container->lastChild->firstChild ) {
501            $signature = ltrim( $signature, ' ' );
502        }
503        // Sign the last line
504        $container->lastChild->appendChild(
505            static::createWikitextNode(
506                $doc,
507                $signature
508            )
509        );
510    }
511
512    /**
513     * Append a user signature to the comment in the provided wikitext.
514     *
515     * @param string $wikitext
516     * @param string $signature
517     * @return string
518     */
519    public static function appendSignatureWikitext( string $wikitext, string $signature ): string {
520        $wikitext = CommentUtils::htmlTrim( $wikitext );
521
522        $lines = explode( "\n", $wikitext );
523        $lastLine = end( $lines );
524
525        // If last line looks like a list item, add an empty line afterwards for the signature (T263217)
526        if ( $lastLine && in_array( $lastLine[0], [ '*', '#', ':', ';' ] ) ) {
527            $wikitext .= "\n";
528            // Trim the signature to prevent leading whitespace triggering preformatted text (T269188, T276612)
529            $signature = ltrim( $signature, ' ' );
530        }
531
532        return $wikitext . $signature;
533    }
534
535    /**
536     * Add a reply to a specific comment
537     *
538     * @param ContentThreadItem $comment Comment being replied to
539     * @param DocumentFragment $container Container of comment DOM nodes
540     */
541    public static function addReply( ContentThreadItem $comment, DocumentFragment $container ): void {
542        $services = MediaWikiServices::getInstance();
543        $dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' );
544        $replyIndentation = $dtConfig->get( 'DiscussionToolsReplyIndentation' );
545
546        $newParsoidItem = null;
547        // Transfer comment DOM to Parsoid DOM
548        // Wrap every root node of the document in a new list item (dd/li).
549        // In wikitext mode every root node is a paragraph.
550        // In visual mode the editor takes care of preventing problematic nodes
551        // like <table> or <h2> from ever occurring in the comment.
552        while ( $container->childNodes->length ) {
553            if ( !$newParsoidItem ) {
554                $newParsoidItem = static::addListItem( $comment, $replyIndentation );
555            } else {
556                $newParsoidItem = static::addSiblingListItem( $newParsoidItem );
557            }
558
559            // Suppress space after the indentation character to support nested lists (T238218).
560            // By request from the community, avoid this if possible after bullet indentation (T259864).
561            if ( !(
562                $replyIndentation === 'bullet' &&
563                ( $wtNode = $container->firstChild->lastChild ) &&
564                $wtNode instanceof Element &&
565                !static::isWikitextNodeListItem( $wtNode )
566            ) ) {
567                static::whitespaceParsoidHack( $newParsoidItem );
568            }
569
570            $newParsoidItem->appendChild( $container->firstChild );
571        }
572    }
573
574    /**
575     * Transfer comment DOM nodes into a list node, as if adding a reply, but without requiring a
576     * ThreadItem.
577     *
578     * @param DocumentFragment $container Container of comment DOM nodes
579     * @return Element $node List node
580     */
581    public static function transferReply( DocumentFragment $container ): Element {
582        $services = MediaWikiServices::getInstance();
583        $dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' );
584        $replyIndentation = $dtConfig->get( 'DiscussionToolsReplyIndentation' );
585
586        $doc = $container->ownerDocument;
587
588        // Like addReply(), but we make our own list
589        $list = $doc->createElement( $replyIndentation === 'invisible' ? 'dl' : 'ul' );
590        while ( $container->childNodes->length ) {
591            $item = $doc->createElement( $replyIndentation === 'invisible' ? 'dd' : 'li' );
592            // Suppress space after the indentation character to support nested lists (T238218).
593            // By request from the community, avoid this if possible after bullet indentation (T259864).
594            if ( !(
595                $replyIndentation === 'bullet' &&
596                ( $wtNode = $container->firstChild->lastChild ) &&
597                $wtNode instanceof Element &&
598                !static::isWikitextNodeListItem( $wtNode )
599            ) ) {
600                static::whitespaceParsoidHack( $item );
601            }
602            $item->appendChild( $container->firstChild );
603            $list->appendChild( $item );
604        }
605        return $list;
606    }
607
608    /**
609     * Create a container of comment DOM nodes from wikitext
610     *
611     * @param Document $doc Document where the DOM nodes will be inserted
612     * @param string $wikitext
613     * @return DocumentFragment DOM nodes
614     */
615    public static function prepareWikitextReply( Document $doc, string $wikitext ): DocumentFragment {
616        $container = $doc->createDocumentFragment();
617
618        $wikitext = static::sanitizeWikitextLinebreaks( $wikitext );
619
620        $lines = explode( "\n", $wikitext );
621        foreach ( $lines as $line ) {
622            $p = $doc->createElement( 'p' );
623            $p->appendChild( static::createWikitextNode( $doc, $line ) );
624            $container->appendChild( $p );
625        }
626
627        return $container;
628    }
629
630    /**
631     * Create a container of comment DOM nodes from HTML
632     *
633     * @param Document $doc Document where the DOM nodes will be inserted
634     * @param string $html
635     * @return DocumentFragment DOM nodes
636     */
637    public static function prepareHtmlReply( Document $doc, string $html ): DocumentFragment {
638        $container = DOMUtils::parseHTMLToFragment( $doc, $html );
639
640        // Remove empty lines
641        // This should really be anything that serializes to empty string in wikitext,
642        // (e.g. <h2></h2>) but this will catch most cases
643        // Create a non-live child node list, so we don't have to worry about it changing
644        // as nodes are removed.
645        $childNodeList = iterator_to_array( $container->childNodes );
646        foreach ( $childNodeList as $node ) {
647            if ( (
648                $node instanceof Text &&
649                CommentUtils::htmlTrim( $node->nodeValue ?? '' ) === ''
650            ) || (
651                $node instanceof Element &&
652                strtolower( $node->tagName ) === 'p' &&
653                CommentUtils::htmlTrim( DOMCompat::getInnerHTML( $node ) ) === ''
654            ) ) {
655                $container->removeChild( $node );
656            }
657        }
658
659        return $container;
660    }
661
662    /**
663     * Add a reply in the DOM to a comment using wikitext.
664     *
665     * @param ContentCommentItem $comment Comment being replied to
666     * @param string $wikitext
667     * @param string|null $signature
668     */
669    public static function addWikitextReply(
670        ContentCommentItem $comment, string $wikitext, string $signature = null
671    ): void {
672        $doc = $comment->getRange()->endContainer->ownerDocument;
673        $container = static::prepareWikitextReply( $doc, $wikitext );
674        if ( $signature !== null ) {
675            static::appendSignature( $container, $signature );
676        }
677        static::addReply( $comment, $container );
678    }
679
680    /**
681     * Add a reply in the DOM to a comment using HTML.
682     *
683     * @param ContentCommentItem $comment Comment being replied to
684     * @param string $html
685     * @param string|null $signature
686     */
687    public static function addHtmlReply(
688        ContentCommentItem $comment, string $html, string $signature = null
689    ): void {
690        $doc = $comment->getRange()->endContainer->ownerDocument;
691        $container = static::prepareHtmlReply( $doc, $html );
692        if ( $signature !== null ) {
693            static::appendSignature( $container, $signature );
694        }
695        static::addReply( $comment, $container );
696    }
697}