Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.98% covered (warning)
88.98%
113 / 127
73.33% covered (warning)
73.33%
22 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContentThreadItem
88.98% covered (warning)
88.98%
113 / 127
73.33% covered (warning)
73.33%
22 / 30
71.84
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 calculateThreadSummary
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
8
 getAuthorsBelow
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCommentCount
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getLatestReply
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getOldestReply
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getThreadItemsBelow
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getTranscludedFrom
87.50% covered (warning)
87.50%
35 / 40
0.00% covered (danger)
0.00%
0 / 1
21.86
 getTransclusionTitles
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getTransclusionRange
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 getHTML
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getText
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLevel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getParent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRange
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRootNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReplies
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWarnings
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setLevel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setParent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRange
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setRootNode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addWarning
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addWarnings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addReply
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools\ThreadItem;
4
5use JsonSerializable;
6use LogicException;
7use MediaWiki\Extension\DiscussionTools\CommentModifier;
8use MediaWiki\Extension\DiscussionTools\CommentUtils;
9use MediaWiki\Extension\DiscussionTools\ImmutableRange;
10use Sanitizer;
11use Title;
12use Wikimedia\Assert\Assert;
13use Wikimedia\Parsoid\DOM\Element;
14use Wikimedia\Parsoid\Utils\DOMUtils;
15
16/**
17 * A thread item, either a heading or a comment
18 */
19abstract class ContentThreadItem implements JsonSerializable, ThreadItem {
20    use ThreadItemTrait;
21
22    protected $type;
23    protected $range;
24    protected $rootNode;
25    protected $level;
26    protected $parent;
27    protected $warnings = [];
28
29    protected $name = null;
30    protected $id = null;
31    protected $replies = [];
32
33    protected $authors = null;
34    protected $commentCount;
35    protected $oldestReply;
36    protected $latestReply;
37
38    /**
39     * @param string $type `heading` or `comment`
40     * @param int $level Indentation level
41     * @param ImmutableRange $range Object describing the extent of the comment, including the
42     *  signature and timestamp.
43     */
44    public function __construct(
45        string $type, int $level, ImmutableRange $range
46    ) {
47        $this->type = $type;
48        $this->level = $level;
49        $this->range = $range;
50    }
51
52    /**
53     * Get summary metadata for a thread.
54     */
55    private function calculateThreadSummary(): void {
56        if ( $this->authors !== null ) {
57            return;
58        }
59        $authors = [];
60        $commentCount = 0;
61        $oldestReply = null;
62        $latestReply = null;
63        $threadScan = static function ( ContentThreadItem $comment ) use (
64            &$authors, &$commentCount, &$oldestReply, &$latestReply, &$threadScan
65        ) {
66            if ( $comment instanceof ContentCommentItem ) {
67                $author = $comment->getAuthor();
68                if ( $author ) {
69                    $authors[ $author ] = true;
70                }
71                if (
72                    !$oldestReply ||
73                    ( $comment->getTimestamp() < $oldestReply->getTimestamp() )
74                ) {
75                    $oldestReply = $comment;
76                }
77                if (
78                    !$latestReply ||
79                    ( $latestReply->getTimestamp() < $comment->getTimestamp() )
80                ) {
81                    $latestReply = $comment;
82                }
83                $commentCount++;
84            }
85            // Get the set of authors in the same format from each reply
86            $replies = $comment->getReplies();
87            array_walk( $replies, $threadScan );
88        };
89        $replies = $this->getReplies();
90        array_walk( $replies, $threadScan );
91
92        ksort( $authors );
93
94        $this->authors = array_keys( $authors );
95        $this->commentCount = $commentCount;
96        $this->oldestReply = $oldestReply;
97        $this->latestReply = $latestReply;
98    }
99
100    /**
101     * Get the list of authors in the tree below this thread item.
102     *
103     * Usually called on a HeadingItem to find all authors in a thread.
104     *
105     * @return string[] Author usernames
106     */
107    public function getAuthorsBelow(): array {
108        $this->calculateThreadSummary();
109        return $this->authors;
110    }
111
112    /**
113     * Get the number of comment items in the tree below this thread item.
114     *
115     * @return int
116     */
117    public function getCommentCount(): int {
118        $this->calculateThreadSummary();
119        return $this->commentCount;
120    }
121
122    /**
123     * Get the latest reply in the tree below this thread item, null if there are no replies
124     *
125     * @return ContentCommentItem|null
126     */
127    public function getLatestReply(): ?ContentCommentItem {
128        $this->calculateThreadSummary();
129        return $this->latestReply;
130    }
131
132    /**
133     * Get the oldest reply in the tree below this thread item, null if there are no replies
134     *
135     * @return ContentCommentItem|null
136     */
137    public function getOldestReply(): ?ContentCommentItem {
138        $this->calculateThreadSummary();
139        return $this->oldestReply;
140    }
141
142    /**
143     * Get a flat list of thread items in the comment tree below this thread item.
144     *
145     * @return ContentThreadItem[] Thread items
146     */
147    public function getThreadItemsBelow(): array {
148        $threadItems = [];
149        $getReplies = static function ( ContentThreadItem $threadItem ) use ( &$threadItems, &$getReplies ) {
150            $threadItems[] = $threadItem;
151            foreach ( $threadItem->getReplies() as $reply ) {
152                $getReplies( $reply );
153            }
154        };
155
156        foreach ( $this->getReplies() as $reply ) {
157            $getReplies( $reply );
158        }
159
160        return $threadItems;
161    }
162
163    /**
164     * Get the name of the page from which this thread item is transcluded (if any). Replies to
165     * transcluded items must be posted on that page, instead of the current one.
166     *
167     * This is tricky, because we don't want to mark items as trancluded when they're just using a
168     * template (e.g. {{ping|…}} or a non-substituted signature template). Sometimes the whole comment
169     * can be template-generated (e.g. when using some wrapper templates), but as long as a reply can
170     * be added outside of that template, we should not treat it as transcluded.
171     *
172     * The start/end boundary points of comment ranges and Parsoid transclusion ranges don't line up
173     * exactly, even when to a human it's obvious that they cover the same content, making this more
174     * complicated.
175     *
176     * @return string|bool `false` if this item is not transcluded. A string if it's transcluded
177     *   from a single page (the page title, in text form with spaces). `true` if it's transcluded, but
178     *   we can't determine the source.
179     */
180    public function getTranscludedFrom() {
181        // General approach:
182        //
183        // Compare the comment range to each transclusion range on the page, and if it overlaps any of
184        // them, examine the overlap. There are a few cases:
185        //
186        // * Comment and transclusion do not overlap:
187        //   → Not transcluded.
188        // * Comment contains the transclusion:
189        //   → Not transcluded (just a template).
190        // * Comment is contained within the transclusion:
191        //   → Transcluded, we can determine the source page (unless it's a complex transclusion).
192        // * Comment and transclusion overlap partially:
193        //   → Transcluded, but we can't determine the source page.
194        // * Comment (almost) exactly matches the transclusion:
195        //   → Maybe transcluded (it could be that the source page only contains that single comment),
196        //     maybe not transcluded (it could be a wrapper template that covers a single comment).
197        //     This is very sad, and we decide based on the namespace.
198        //
199        // Most transclusion ranges on the page trivially fall in the "do not overlap" or "contains"
200        // cases, and we only have to carefully examine the two transclusion ranges that contain the
201        // first and last node of the comment range.
202        //
203        // To check for almost exact matches, we walk between the relevant boundary points, and if we
204        // only find uninteresting nodes (that would be ignored when detecting comments), we treat them
205        // like exact matches.
206
207        $commentRange = $this->getRange();
208        $startTransclNode = CommentUtils::getTranscludedFromElement(
209            CommentUtils::getRangeFirstNode( $commentRange )
210        );
211        $endTransclNode = CommentUtils::getTranscludedFromElement(
212            CommentUtils::getRangeLastNode( $commentRange )
213        );
214
215        // We only have to examine the two transclusion ranges that contain the first/last node of the
216        // comment range (if they exist). Ignore ranges outside the comment or in the middle of it.
217        $transclNodes = [];
218        if ( $startTransclNode ) {
219            $transclNodes[] = $startTransclNode;
220        }
221        if ( $endTransclNode && $endTransclNode !== $startTransclNode ) {
222            $transclNodes[] = $endTransclNode;
223        }
224
225        foreach ( $transclNodes as $transclNode ) {
226            $transclRange = static::getTransclusionRange( $transclNode );
227            $compared = CommentUtils::compareRanges( $commentRange, $transclRange );
228            $transclTitles = $this->getTransclusionTitles( $transclNode );
229            $simpleTransclTitle = count( $transclTitles ) === 1 ? $transclTitles[0] : null;
230
231            switch ( $compared ) {
232                case 'equal':
233                    // Comment (almost) exactly matches the transclusion
234                    if ( $simpleTransclTitle === null ) {
235                        // Allow replying to some accidental complex transclusions consisting of only templates
236                        // and wikitext (T313093)
237                        if ( count( $transclTitles ) > 1 ) {
238                            foreach ( $transclTitles as $transclTitle ) {
239                                if ( $transclTitle && !$transclTitle->inNamespace( NS_TEMPLATE ) ) {
240                                    return true;
241                                }
242                            }
243                            // Continue examining the other ranges.
244                            break;
245                        }
246                        // Multi-template transclusion, or a parser function call, or template-affected wikitext outside
247                        // of a template call, or a mix of the above
248                        return true;
249
250                    } elseif ( $simpleTransclTitle->inNamespace( NS_TEMPLATE ) ) {
251                        // Is that a subpage transclusion with a single comment, or a wrapper template
252                        // transclusion on this page? We don't know, but let's guess based on the namespace.
253                        // (T289873)
254                        // Continue examining the other ranges.
255                        break;
256                    } else {
257                        return $simpleTransclTitle->getPrefixedText();
258                    }
259
260                case 'contains':
261                    // Comment contains the transclusion
262
263                    // If the entire transclusion is contained within the comment range, that's just a
264                    // template. This is the same as a transclusion in the middle of the comment, which we
265                    // ignored earlier, it just takes us longer to get here in this case.
266
267                    // Continue examining the other ranges.
268                    break;
269
270                case 'contained':
271                    // Comment is contained within the transclusion
272                    if ( $simpleTransclTitle === null ) {
273                        return true;
274                    } else {
275                        return $simpleTransclTitle->getPrefixedText();
276                    }
277
278                case 'after':
279                case 'before':
280                    // Comment and transclusion do not overlap
281
282                    // This should be impossible, because we ignored these ranges earlier.
283                    throw new LogicException( 'Unexpected transclusion or comment range' );
284
285                case 'overlapstart':
286                case 'overlapend':
287                    // Comment and transclusion overlap partially
288                    return true;
289
290                default:
291                    throw new LogicException( 'Unexpected return value from compareRanges()' );
292            }
293        }
294
295        // If we got here, the comment range was not contained by or overlapping any of the transclusion
296        // ranges. Comment is not transcluded.
297        return false;
298    }
299
300    /**
301     * Return the page titles for each part of the transclusion, or nulls for each part that isn't
302     * transcluded from another page.
303     *
304     * If the node represents a single-page transclusion, this will return an array containing a
305     * single Title object.
306     *
307     * @param Element $node
308     * @return (?Title)[]
309     */
310    private function getTransclusionTitles( Element $node ): array {
311        $dataMw = json_decode( $node->getAttribute( 'data-mw' ) ?? '', true );
312        $out = [];
313
314        foreach ( $dataMw['parts'] ?? [] as $part ) {
315            if (
316                !is_string( $part ) &&
317                // 'href' will be unset if this is a parser function rather than a template
318                isset( $part['template']['target']['href'] )
319            ) {
320                $parsoidHref = $part['template']['target']['href'];
321                Assert::precondition( substr( $parsoidHref, 0, 2 ) === './', "href has valid format" );
322                $out[] = Title::newFromText( urldecode( substr( $parsoidHref, 2 ) ) );
323            } else {
324                $out[] = null;
325            }
326        }
327
328        return $out;
329    }
330
331    /**
332     * Given a transclusion's first node (e.g. returned by CommentUtils::getTranscludedFromElement()),
333     * return a range starting before the node and ending after the transclusion's last node.
334     *
335     * @param Element $startNode
336     * @return ImmutableRange
337     */
338    private function getTransclusionRange( Element $startNode ): ImmutableRange {
339        $endNode = $startNode;
340        while (
341            // Phan doesn't realize that the conditions on $nextSibling can terminate the loop
342            // @phan-suppress-next-line PhanInfiniteLoop
343            $endNode &&
344            ( $nextSibling = $endNode->nextSibling ) &&
345            $nextSibling instanceof Element &&
346            $nextSibling->getAttribute( 'about' ) === $endNode->getAttribute( 'about' )
347        ) {
348            $endNode = $nextSibling;
349        }
350
351        $range = new ImmutableRange(
352            $startNode->parentNode,
353            CommentUtils::childIndexOf( $startNode ),
354            $endNode->parentNode,
355            CommentUtils::childIndexOf( $endNode ) + 1
356        );
357
358        return $range;
359    }
360
361    /**
362     * Get the HTML of this thread item
363     *
364     * @return string HTML
365     */
366    public function getHTML(): string {
367        $fragment = $this->getRange()->cloneContents();
368        CommentModifier::unwrapFragment( $fragment );
369        return DOMUtils::getFragmentInnerHTML( $fragment );
370    }
371
372    /**
373     * Get the text of this thread item
374     *
375     * @return string Text
376     */
377    public function getText(): string {
378        $html = $this->getHTML();
379        return Sanitizer::stripAllTags( $html );
380    }
381
382    /**
383     * @return string Thread item type
384     */
385    public function getType(): string {
386        return $this->type;
387    }
388
389    /**
390     * @return int Indentation level
391     */
392    public function getLevel(): int {
393        return $this->level;
394    }
395
396    /**
397     * @return ContentThreadItem|null Parent thread item
398     */
399    public function getParent(): ?ThreadItem {
400        return $this->parent;
401    }
402
403    /**
404     * @return ImmutableRange Range of the entire thread item
405     */
406    public function getRange(): ImmutableRange {
407        return $this->range;
408    }
409
410    /**
411     * @return Element Root node (level is relative to this node)
412     */
413    public function getRootNode(): Element {
414        return $this->rootNode;
415    }
416
417    /**
418     * @return string Thread item name
419     */
420    public function getName(): string {
421        return $this->name;
422    }
423
424    /**
425     * @return string Thread ID
426     */
427    public function getId(): string {
428        return $this->id;
429    }
430
431    /**
432     * @return ContentThreadItem[] Replies to this thread item
433     */
434    public function getReplies(): array {
435        return $this->replies;
436    }
437
438    /**
439     * @return string[] Warnings
440     */
441    public function getWarnings(): array {
442        return $this->warnings;
443    }
444
445    /**
446     * @param int $level Indentation level
447     */
448    public function setLevel( int $level ): void {
449        $this->level = $level;
450    }
451
452    /**
453     * @param ContentThreadItem $parent
454     */
455    public function setParent( ContentThreadItem $parent ): void {
456        $this->parent = $parent;
457    }
458
459    /**
460     * @param ImmutableRange $range Thread item range
461     */
462    public function setRange( ImmutableRange $range ): void {
463        $this->range = $range;
464    }
465
466    /**
467     * @param Element $rootNode Root node (level is relative to this node)
468     */
469    public function setRootNode( Element $rootNode ): void {
470        $this->rootNode = $rootNode;
471    }
472
473    /**
474     * @param string|null $name Thread item name
475     */
476    public function setName( ?string $name ): void {
477        $this->name = $name;
478    }
479
480    /**
481     * @param string|null $id Thread ID
482     */
483    public function setId( ?string $id ): void {
484        $this->id = $id;
485    }
486
487    /**
488     * @param string $warning
489     */
490    public function addWarning( string $warning ): void {
491        $this->warnings[] = $warning;
492    }
493
494    /**
495     * @param string[] $warnings
496     */
497    public function addWarnings( array $warnings ): void {
498        $this->warnings = array_merge( $this->warnings, $warnings );
499    }
500
501    /**
502     * @param ContentThreadItem $reply Reply comment
503     */
504    public function addReply( ContentThreadItem $reply ): void {
505        $this->replies[] = $reply;
506    }
507}