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