Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
83.27% |
433 / 520 |
|
40.91% |
9 / 22 |
CRAP | |
0.00% |
0 / 1 |
CommentFormatter | |
83.27% |
433 / 520 |
|
40.91% |
9 / 22 |
150.72 | |
0.00% |
0 / 1 |
getParser | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getHookRunner | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
addDiscussionTools | |
65.00% |
13 / 20 |
|
0.00% |
0 / 1 |
3.39 | |||
handleHeading | |
100.00% |
33 / 33 |
|
100.00% |
1 / 1 |
9 | |||
addTopicContainer | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
3 | |||
addSubscribeLink | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
4 | |||
addDiscussionToolsInternal | |
95.74% |
90 / 94 |
|
0.00% |
0 / 1 |
26 | |||
addOverflowMenuButton | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
removeInteractiveTools | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
postprocessTopicSubscription | |
92.22% |
83 / 90 |
|
0.00% |
0 / 1 |
15.11 | |||
postprocessReplyTool | |
100.00% |
39 / 39 |
|
100.00% |
1 / 1 |
4 | |||
metaLabel | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getJsonArrayForCommentMarker | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
getJsonForHeadingMarker | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getSignatureRelativeTime | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
postprocessVisualEnhancements | |
97.96% |
96 / 98 |
|
0.00% |
0 / 1 |
8 | |||
postprocessVisualEnhancementsSubtitle | |
0.00% |
0 / 31 |
|
0.00% |
0 / 1 |
30 | |||
postprocessTableOfContents | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
30 | |||
isEmptyTalkPage | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
hasLedeContent | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
hasCommentsInLedeContent | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isLanguageRequiringReplyIcon | |
78.57% |
11 / 14 |
|
0.00% |
0 / 1 |
5.25 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\DiscussionTools; |
4 | |
5 | use Language; |
6 | use MediaWiki\Config\ConfigException; |
7 | use MediaWiki\Context\IContextSource; |
8 | use MediaWiki\Extension\DiscussionTools\Hooks\HookRunner; |
9 | use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils; |
10 | use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem; |
11 | use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentHeadingItem; |
12 | use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentThreadItem; |
13 | use MediaWiki\Extension\DiscussionTools\ThreadItem\ThreadItem; |
14 | use MediaWiki\Html\Html; |
15 | use MediaWiki\MediaWikiServices; |
16 | use MediaWiki\Parser\ParserOutput; |
17 | use MediaWiki\Parser\Sanitizer; |
18 | use MediaWiki\Request\WebRequest; |
19 | use MediaWiki\Title\Title; |
20 | use MediaWiki\User\UserIdentity; |
21 | use MediaWiki\Utils\MWTimestamp; |
22 | use MWExceptionHandler; |
23 | use Throwable; |
24 | use Wikimedia\Parsoid\DOM\Document; |
25 | use Wikimedia\Parsoid\DOM\Element; |
26 | use Wikimedia\Parsoid\Utils\DOMCompat; |
27 | use Wikimedia\Parsoid\Utils\DOMUtils; |
28 | use Wikimedia\Parsoid\Wt2Html\XMLSerializer; |
29 | use Wikimedia\Timestamp\TimestampException; |
30 | |
31 | class 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 ) { |