Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.34% covered (warning)
82.34%
429 / 521
39.13% covered (danger)
39.13%
9 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentFormatter
82.34% covered (warning)
82.34%
429 / 521
39.13% covered (danger)
39.13%
9 / 23
163.55
0.00% covered (danger)
0.00%
0 / 1
 getParser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHookRunner
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addDiscussionTools
61.11% covered (warning)
61.11%
11 / 18
0.00% covered (danger)
0.00%
0 / 1
3.53
 handleHeading
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
9
 addTopicContainer
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
3
 addSubscribeLink
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 addDiscussionToolsInternal
94.62% covered (success)
94.62%
88 / 93
0.00% covered (danger)
0.00%
0 / 1
27.11
 addOverflowMenuButton
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 removeInteractiveTools
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 postprocessTopicSubscription
92.31% covered (success)
92.31%
84 / 91
0.00% covered (danger)
0.00%
0 / 1
15.10
 postprocessReplyTool
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
4
 metaLabel
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getJsonArrayForCommentMarker
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getJsonForHeadingMarker
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getSignatureRelativeTime
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 postprocessVisualEnhancements
97.96% covered (success)
97.96%
96 / 98
0.00% covered (danger)
0.00%
0 / 1
8
 postprocessVisualEnhancementsSubtitle
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
30
 postprocessTableOfContents
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 isEmptyTalkPage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 appendToEmptyTalkPage
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 hasLedeContent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasCommentsInLedeContent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isLanguageRequiringReplyIcon
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
5.25
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools;
4
5use IContextSource;
6use Language;
7use MediaWiki\Config\ConfigException;
8use MediaWiki\Extension\DiscussionTools\Hooks\HookRunner;
9use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
10use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem;
11use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentHeadingItem;
12use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentThreadItem;
13use MediaWiki\Extension\DiscussionTools\ThreadItem\ThreadItem;
14use MediaWiki\Html\Html;
15use MediaWiki\MediaWikiServices;
16use MediaWiki\Parser\Sanitizer;
17use MediaWiki\Request\WebRequest;
18use MediaWiki\Title\Title;
19use MediaWiki\User\UserIdentity;
20use MediaWiki\Utils\MWTimestamp;
21use MWExceptionHandler;
22use ParserOutput;
23use Throwable;
24use Wikimedia\Assert\Assert;
25use Wikimedia\Parsoid\DOM\Document;
26use Wikimedia\Parsoid\DOM\Element;
27use Wikimedia\Parsoid\Utils\DOMCompat;
28use Wikimedia\Parsoid\Utils\DOMUtils;
29use Wikimedia\Parsoid\Wt2Html\XMLSerializer;
30use Wikimedia\Timestamp\TimestampException;
31
32class CommentFormatter {
33    // List of features which, when enabled, cause the comment formatter to run
34    public const USE_WITH_FEATURES = [
35        HookUtils::REPLYTOOL,
36        HookUtils::TOPICSUBSCRIPTION,
37        HookUtils::VISUALENHANCEMENTS
38    ];
39
40    /**
41     * Get a comment parser object for a DOM element
42     *
43     * This method exists so it can mocked in tests.
44     */
45    protected static function getParser(): CommentParser {
46        return MediaWikiServices::getInstance()->getService( 'DiscussionTools.CommentParser' );
47    }
48
49    protected static function getHookRunner(): HookRunner {
50        return new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
51    }
52
53    /**
54     * Add discussion tools to some HTML
55     *
56     * @param string &$text Parser text output (modified by reference)
57     * @param ParserOutput $pout ParserOutput object for metadata, e.g. parser limit report
58     * @param Title $title
59     */
60    public static function addDiscussionTools( string &$text, ParserOutput $pout, Title $title ): void {
61        $start = microtime( true );
62        $requestId = null;
63
64        try {
65            $text = static::addDiscussionToolsInternal( $text, $pout, $title );
66
67        } catch ( Throwable $e ) {
68            // Catch errors, so that they don't cause the entire page to not display.
69            // Log it and report the request ID to make it easier to find in the logs.
70            MWExceptionHandler::logException( $e );
71            $requestId = WebRequest::getRequestId();
72        }
73
74        $duration = microtime( true ) - $start;
75
76        $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
77        $stats->timing( 'discussiontools.addReplyLinks', $duration * 1000 );
78
79        // How long this method took, in seconds
80        $pout->setLimitReportData(
81            // The following messages can be generated upstream
82            // * discussiontools-limitreport-timeusage-value
83            // * discussiontools-limitreport-timeusage-value-text
84            // * discussiontools-limitreport-timeusage-value-html
85            'discussiontools-limitreport-timeusage',
86            sprintf( '%.3f', $duration )
87        );
88        if ( $requestId ) {
89            // Request ID where errors were logged (only if an error occurred)
90            $pout->setLimitReportData(
91                'discussiontools-limitreport-errorreqid',
92                $requestId
93            );
94        }
95    }
96
97    /**
98     * Add a wrapper, topic container, and subscribe link around a heading element
99     *
100     * @param Element $headingElement Heading element
101     * @param ContentHeadingItem|null $headingItem Heading item
102     * @param array|null &$tocInfo TOC info
103     * @return Element Wrapper element (either found or newly added)
104     */
105    protected static function handleHeading(
106        Element $headingElement,
107        ?ContentHeadingItem $headingItem = null,
108        ?array &$tocInfo = null
109    ): Element {
110        $doc = $headingElement->ownerDocument;
111        $wrapperNode = $headingElement->parentNode;
112        if ( !(
113            $wrapperNode instanceof Element &&
114            DOMCompat::getClassList( $wrapperNode )->contains( 'mw-heading' )
115        ) ) {
116            // Do not add the wrapper if the heading has attributes generated from wikitext (T353489).
117            // Only allow reserved attributes (e.g. 'data-mw', which can't be used in wikitext, but which
118            // are used internally by our own code and by Parsoid) and the 'id' attribute used by Parsoid.
119            foreach ( $headingElement->attributes as $attr ) {
120                if ( $attr->name !== 'id' && !Sanitizer::isReservedDataAttribute( $attr->name ) ) {
121                    return $headingElement;
122                }
123            }
124
125            $wrapperNode = $doc->createElement( 'div' );
126            $headingElement->parentNode->insertBefore( $wrapperNode, $headingElement );
127            $wrapperNode->appendChild( $headingElement );
128        }
129
130        if ( !$headingItem ) {
131            return $wrapperNode;
132        }
133
134        $uneditable = DOMCompat::querySelector( $wrapperNode, 'mw\\:editsection' ) === null;
135        $headingItem->setUneditableSection( $uneditable );
136        self::addOverflowMenuButton( $headingItem, $doc, $wrapperNode );
137
138        $latestReplyItem = $headingItem->getLatestReply();
139
140        $bar = null;
141        if ( $latestReplyItem ) {
142            $bar = $doc->createElement( 'div' );
143            $bar->setAttribute(
144                'class',
145                'ext-discussiontools-init-section-bar'
146            );
147        }
148
149        self::addTopicContainer(
150            $wrapperNode, $latestReplyItem, $doc, $headingItem, $bar, $tocInfo
151        );
152
153        self::addSubscribeLink(
154            $headingItem, $doc, $wrapperNode, $latestReplyItem, $bar
155        );
156
157        if ( $latestReplyItem ) {
158            // The check for if ( $latestReplyItem ) prevents $bar from being null
159            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
160            $wrapperNode->appendChild( $bar );
161        }
162
163        return $wrapperNode;
164    }
165
166    /**
167     * Add a topic container around a heading element.
168     *
169     * A topic container is the information displayed when the "Show discusion activity" user
170     * preference is selected. This displays information such as the latest comment time, number
171     * of comments, and number of editors in the discussion.
172     */
173    protected static function addTopicContainer(
174        Element $wrapperNode,
175        ?ContentCommentItem $latestReplyItem,
176        Document $doc,
177        ContentHeadingItem $headingItem,
178        ?Element $bar,
179        array &$tocInfo
180    ) {
181        if ( !DOMCompat::getClassList( $wrapperNode )->contains( 'mw-heading' ) ) {
182            DOMCompat::getClassList( $wrapperNode )->add( 'mw-heading' );
183            DOMCompat::getClassList( $wrapperNode )->add( 'mw-heading2' );
184        }
185        DOMCompat::getClassList( $wrapperNode )->add( 'ext-discussiontools-init-section' );
186
187        if ( !$latestReplyItem ) {
188            return;
189        }
190
191        $latestReplyJSON = json_encode( static::getJsonArrayForCommentMarker( $latestReplyItem ) );
192        $latestReply = $doc->createComment(
193            // Timestamp output varies by user timezone, so is formatted later
194            '__DTLATESTCOMMENTTHREAD__' . htmlspecialchars( $latestReplyJSON, ENT_NOQUOTES ) . '__'
195        );
196
197        $commentCount = $doc->createComment(
198            '__DTCOMMENTCOUNT__' . $headingItem->getCommentCount() . '__'
199        );
200
201        $authorCount = $doc->createComment(
202            '__DTAUTHORCOUNT__' . count( $headingItem->getAuthorsBelow() ) . '__'
203        );
204
205        $metadata = $doc->createElement( 'div' );
206        $metadata->setAttribute(
207            'class',
208            'ext-discussiontools-init-section-metadata'
209        );
210        $metadata->appendChild( $latestReply );
211        $metadata->appendChild( $commentCount );
212        $metadata->appendChild( $authorCount );
213        $bar->appendChild( $metadata );
214
215        $tocInfo[ $headingItem->getLinkableTitle() ] = [
216            'commentCount' => $headingItem->getCommentCount(),
217        ];
218    }
219
220    /**
221     * Add a subscribe/unsubscribe link to the right of a heading element
222     */
223    protected static function addSubscribeLink(
224        ContentHeadingItem $headingItem,
225        Document $doc,
226        Element $wrapperNode,
227        ?ContentCommentItem $latestReplyItem,
228        ?Element $bar
229    ) {
230        $headingJSONEscaped = htmlspecialchars(
231            json_encode( static::getJsonForHeadingMarker( $headingItem ) )
232        );
233
234        // Replaced in ::postprocessTopicSubscription() as the text depends on user state
235        if ( $headingItem->isSubscribable() ) {
236            $subscribeButton = $doc->createComment( '__DTSUBSCRIBEBUTTONDESKTOP__' . $headingJSONEscaped );
237            $wrapperNode->insertBefore( $subscribeButton, $wrapperNode->firstChild );
238        }
239
240        if ( !$latestReplyItem ) {
241            return;
242        }
243
244        $actions = $doc->createElement( 'div' );
245        $actions->setAttribute(
246            'class',
247            'ext-discussiontools-init-section-actions'
248        );
249        if ( $headingItem->isSubscribable() ) {
250            $subscribeButton = $doc->createComment( '__DTSUBSCRIBEBUTTONMOBILE__' . $headingJSONEscaped );
251            $actions->appendChild( $subscribeButton );
252        }
253        $bar->appendChild( $actions );
254    }
255
256    /**
257     * Add discussion tools to some HTML
258     *
259     * @param string $html HTML
260     * @param ParserOutput $pout
261     * @param Title $title
262     * @return string HTML with discussion tools
263     */
264    protected static function addDiscussionToolsInternal( string $html, ParserOutput $pout, Title $title ): string {
265        // The output of this method can end up in the HTTP cache (Varnish). Avoid changing it;
266        // and when doing so, ensure that frontend code can handle both the old and new outputs.
267        // See controller#init in JS.
268
269        $doc = DOMUtils::parseHTML( $html );
270        $container = DOMCompat::getBody( $doc );
271
272        $threadItemSet = static::getParser()->parse( $container, $title->getTitleValue() );
273        $threadItems = $threadItemSet->getThreadItems();
274
275        $tocInfo = [];
276
277        $newestComment = null;
278        $newestCommentData = null;
279
280        $url = $title->getCanonicalURL();
281        $dtConfig = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'discussiontools' );
282        $enablePermalinksFrontend = $dtConfig->get( 'DiscussionToolsEnablePermalinksFrontend' );
283
284        // Iterate in reverse order, because adding the range markers for a thread item
285        // can invalidate the ranges of subsequent thread items (T298096)
286        foreach ( array_reverse( $threadItems ) as $threadItem ) {
287            // Create a dummy node to attach data to.
288            if ( $threadItem instanceof ContentHeadingItem && $threadItem->isPlaceholderHeading() ) {
289                $node = $doc->createElement( 'span' );
290                $container->insertBefore( $node, $container->firstChild );
291                $threadItem->setRange( new ImmutableRange( $node, 0, $node, 0 ) );
292            }
293
294            // Add start and end markers to range
295            $id = $threadItem->getId();
296            $range = $threadItem->getRange();
297            $startMarker = $doc->createElement( 'span' );
298            $startMarker->setAttribute( 'data-mw-comment-start', '' );
299            $startMarker->setAttribute( 'id', $id );
300            $endMarker = $doc->createElement( 'span' );
301            $endMarker->setAttribute( 'data-mw-comment-end', $id );
302
303            // Extend the range if the start or end is inside an element which can't have element children.
304            // (There may be other problematic elements... but this seems like a good start.)
305            while ( CommentUtils::cantHaveElementChildren( $range->startContainer ) ) {
306                $range = $range->setStart(
307                    $range->startContainer->parentNode,
308                    CommentUtils::childIndexOf( $range->startContainer )
309                );
310            }
311            while ( CommentUtils::cantHaveElementChildren( $range->endContainer ) ) {
312                $range = $range->setEnd(
313                    $range->endContainer->parentNode,
314                    CommentUtils::childIndexOf( $range->endContainer ) + 1
315                );
316            }
317
318            $range->setStart( $range->endContainer, $range->endOffset )->insertNode( $endMarker );
319            // Start marker is added after reply link to keep reverse DOM order
320
321            if ( $threadItem instanceof ContentHeadingItem ) {
322                // <span class="mw-headline" …>, or <hN …> in Parsoid HTML
323                $headline = $threadItem->getRange()->endContainer;
324                Assert::precondition( $headline instanceof Element, 'HeadingItem refers to an element node' );
325                $headline->setAttribute( 'data-mw-thread-id', $threadItem->getId() );