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