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