Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.54% covered (warning)
85.54%
71 / 83
66.67% covered (warning)
66.67%
20 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContentThreadItem
85.54% covered (warning)
85.54%
71 / 83
66.67% covered (warning)
66.67%
20 / 30
47.33
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 calculateThreadSummary
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
10
 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%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getTranscludedFrom
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHTML
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 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
 getLegacyId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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
 setLegacyId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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 MediaWiki\Extension\DiscussionTools\CommentModifier;
7use MediaWiki\Extension\DiscussionTools\ImmutableRange;
8use MediaWiki\Parser\Sanitizer;
9use Wikimedia\Parsoid\Core\DOMCompat;
10use Wikimedia\Parsoid\DOM\Element;
11use Wikimedia\Parsoid\Ext\DOMUtils;
12
13/**
14 * A thread item, either a heading or a comment
15 */
16abstract class ContentThreadItem implements JsonSerializable, ThreadItem {
17    use ThreadItemTrait;
18
19    protected Element $rootNode;
20    protected ?ContentThreadItem $parent = null;
21    /** @var string[] */
22    protected array $warnings = [];
23
24    protected string $name;
25    protected string $id;
26    protected ?string $legacyId = null;
27    /** @var ContentThreadItem[] */
28    protected array $replies = [];
29
30    /** @var ?array[] */
31    protected ?array $authors = null;
32    protected int $commentCount;
33    protected ?ContentCommentItem $oldestReply;
34    protected ?ContentCommentItem $latestReply;
35
36    /**
37     * @param string $type `heading` or `comment`
38     * @param int $level Indentation level
39     * @param ImmutableRange $range Object describing the extent of the comment, including the
40     *  signature and timestamp.
41     * @param bool|string $transcludedFrom
42     */
43    public function __construct(
44        protected readonly string $type,
45        protected int $level,
46        protected ImmutableRange $range,
47        protected readonly bool|string $transcludedFrom
48    ) {
49    }
50
51    /**
52     * Get summary metadata for a thread.
53     */
54    private function calculateThreadSummary(): void {
55        if ( $this->authors !== null ) {
56            return;
57        }
58        $authors = [];
59        $commentCount = 0;
60        $oldestReply = null;
61        $latestReply = null;
62        $threadScan = static function ( ContentThreadItem $comment ) use (
63            &$authors, &$commentCount, &$oldestReply, &$latestReply, &$threadScan
64        ) {
65            if ( $comment instanceof ContentCommentItem ) {
66                $author = $comment->getAuthor();
67                if ( !isset( $authors[ $author] ) ) {
68                    $authors[ $author ] = [
69                        'username' => $author,
70                        'displayNames' => [],
71                    ];
72                }
73                $displayName = $comment->getDisplayName();
74                if ( $displayName && !in_array( $displayName, $authors[ $author ][ 'displayNames' ], true ) ) {
75                    $authors[ $author ][ 'displayNames' ][] = $displayName;
76                }
77
78                if (
79                    !$oldestReply ||
80                    ( $comment->getTimestamp() < $oldestReply->getTimestamp() )
81                ) {
82                    $oldestReply = $comment;
83                }
84                if (
85                    !$latestReply ||
86                    ( $latestReply->getTimestamp() < $comment->getTimestamp() )
87                ) {
88                    $latestReply = $comment;
89                }
90                $commentCount++;
91            }
92            // Get the set of authors in the same format from each reply
93            $replies = $comment->getReplies();
94            array_walk( $replies, $threadScan );
95        };
96        $replies = $this->getReplies();
97        array_walk( $replies, $threadScan );
98
99        ksort( $authors );
100
101        $this->authors = array_values( $authors );
102        $this->commentCount = $commentCount;
103        $this->oldestReply = $oldestReply;
104        $this->latestReply = $latestReply;
105    }
106
107    /**
108     * Get the list of authors in the tree below this thread item.
109     *
110     * Usually called on a HeadingItem to find all authors in a thread.
111     *
112     * @return array[] Authors, with `username` and `displayNames` (list of display names) properties.
113     */
114    public function getAuthorsBelow(): array {
115        $this->calculateThreadSummary();
116        return $this->authors;
117    }
118
119    /**
120     * Get the number of comment items in the tree below this thread item.
121     */
122    public function getCommentCount(): int {
123        $this->calculateThreadSummary();
124        return $this->commentCount;
125    }
126
127    /**
128     * Get the latest reply in the tree below this thread item, null if there are no replies
129     */
130    public function getLatestReply(): ?ContentCommentItem {
131        $this->calculateThreadSummary();
132        return $this->latestReply;
133    }
134
135    /**
136     * Get the oldest reply in the tree below this thread item, null if there are no replies
137     */
138    public function getOldestReply(): ?ContentCommentItem {
139        $this->calculateThreadSummary();
140        return $this->oldestReply;
141    }
142
143    /**
144     * Get a flat list of thread items in the comment tree below this thread item.
145     *
146     * @return ContentThreadItem[] Thread items
147     */
148    public function getThreadItemsBelow(): array {
149        $threadItems = [];
150        $getReplies = static function ( ContentThreadItem $threadItem ) use ( &$threadItems, &$getReplies ) {
151            $threadItems[] = $threadItem;
152            foreach ( $threadItem->getReplies() as $reply ) {
153                $getReplies( $reply );
154            }
155        };
156
157        foreach ( $this->getReplies() as $reply ) {
158            $getReplies( $reply );
159        }
160
161        return $threadItems;
162    }
163
164    /**
165     * @inheritDoc
166     */
167    public function getTranscludedFrom() {
168        return $this->transcludedFrom;
169    }
170
171    /**
172     * Get the HTML of this thread item
173     *
174     * @return string HTML
175     */
176    public function getHTML(): string {
177        $fragment = $this->getRange()->cloneContents();
178        CommentModifier::unwrapFragment( $fragment );
179        $editsection = DOMCompat::querySelector( $fragment, 'mw\\:editsection' );
180        if ( $editsection ) {
181            $editsection->parentNode->removeChild( $editsection );
182        }
183        return DOMUtils::getFragmentInnerHTML( $fragment );
184    }
185
186    /**
187     * Get the text of this thread item
188     *
189     * @return string Text
190     */
191    public function getText(): string {
192        $html = $this->getHTML();
193        return Sanitizer::stripAllTags( $html );
194    }
195
196    /**
197     * @return string Thread item type
198     */
199    public function getType(): string {
200        return $this->type;
201    }
202
203    /**
204     * @return int Indentation level
205     */
206    public function getLevel(): int {
207        return $this->level;
208    }
209
210    /**
211     * @return ContentThreadItem|null Parent thread item
212     */
213    public function getParent(): ?ThreadItem {
214        return $this->parent;
215    }
216
217    /**
218     * @return ImmutableRange Range of the entire thread item
219     */
220    public function getRange(): ImmutableRange {
221        return $this->range;
222    }
223
224    /**
225     * @return Element Root node (level is relative to this node)
226     */
227    public function getRootNode(): Element {
228        return $this->rootNode;
229    }
230
231    /**
232     * @return string Thread item name
233     */
234    public function getName(): string {
235        return $this->name;
236    }
237
238    /**
239     * @return string Thread ID
240     */
241    public function getId(): string {
242        return $this->id;
243    }
244
245    /**
246     * @return string|null Thread ID, before most recent change to ID calculation
247     */
248    public function getLegacyId(): ?string {
249        return $this->legacyId;
250    }
251
252    /**
253     * @return ContentThreadItem[] Replies to this thread item
254     */
255    public function getReplies(): array {
256        return $this->replies;
257    }
258
259    /**
260     * @return string[] Warnings
261     */
262    public function getWarnings(): array {
263        return $this->warnings;
264    }
265
266    /**
267     * @param int $level Indentation level
268     */
269    public function setLevel( int $level ): void {
270        $this->level = $level;
271    }
272
273    public function setParent( ContentThreadItem $parent ): void {
274        $this->parent = $parent;
275    }
276
277    /**
278     * @param ImmutableRange $range Thread item range
279     */
280    public function setRange( ImmutableRange $range ): void {
281        $this->range = $range;
282    }
283
284    /**
285     * @param Element $rootNode Root node (level is relative to this node)
286     */
287    public function setRootNode( Element $rootNode ): void {
288        $this->rootNode = $rootNode;
289    }
290
291    /**
292     * @param string $name Thread item name
293     */
294    public function setName( string $name ): void {
295        $this->name = $name;
296    }
297
298    /**
299     * @param string $id Thread ID
300     */
301    public function setId( string $id ): void {
302        $this->id = $id;
303    }
304
305    /**
306     * @param string|null $legacyId Thread ID
307     */
308    public function setLegacyId( ?string $legacyId ): void {
309        $this->legacyId = $legacyId;
310    }
311
312    public function addWarning( string $warning ): void {
313        $this->warnings[] = $warning;
314    }
315
316    /**
317     * @param string[] $warnings
318     */
319    public function addWarnings( array $warnings ): void {
320        $this->warnings = array_merge( $this->warnings, $warnings );
321    }
322
323    /**
324     * @param ContentThreadItem $reply Reply comment
325     */
326    public function addReply( ContentThreadItem $reply ): void {
327        $this->replies[] = $reply;
328    }
329}