Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
69.52% covered (warning)
69.52%
130 / 187
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiDiscussionToolsPageInfo
69.52% covered (warning)
69.52%
130 / 187
50.00% covered (danger)
50.00%
5 / 10
140.67
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
56.25% covered (warning)
56.25%
9 / 16
0.00% covered (danger)
0.00%
0 / 1
5.34
 getThreadItemSet
29.73% covered (danger)
29.73%
11 / 37
0.00% covered (danger)
0.00%
0 / 1
52.99
 getTranscludedFrom
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
7.46
 getThreadItemsHtml
80.90% covered (warning)
80.90%
72 / 89
0.00% covered (danger)
0.00%
0 / 1
36.27
 formatItemTimestamp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedParams
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
1
 needsToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isWriteMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools;
4
5use MediaWiki\Api\ApiBase;
6use MediaWiki\Api\ApiMain;
7use MediaWiki\Api\ApiUsageException;
8use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
9use MediaWiki\Extension\DiscussionTools\ThreadItem\CommentItem;
10use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem;
11use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentHeadingItem;
12use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentThreadItem;
13use MediaWiki\Extension\VisualEditor\VisualEditorParsoidClientFactory;
14use MediaWiki\Revision\RevisionLookup;
15use MediaWiki\Revision\RevisionRecord;
16use MediaWiki\Title\Title;
17use Wikimedia\ParamValidator\ParamValidator;
18use Wikimedia\Parsoid\DOM\Element;
19use Wikimedia\Parsoid\DOM\Text;
20use Wikimedia\Parsoid\Ext\DOMUtils;
21
22class ApiDiscussionToolsPageInfo extends ApiBase {
23
24    public function __construct(
25        ApiMain $main,
26        string $name,
27        private readonly VisualEditorParsoidClientFactory $parsoidClientFactory,
28        private readonly CommentParser $commentParser,
29        private readonly RevisionLookup $revisionLookup,
30    ) {
31        parent::__construct( $main, $name );
32    }
33
34    /**
35     * @inheritDoc
36     * @throws ApiUsageException
37     */
38    public function execute() {
39        $params = $this->extractRequestParams();
40        $this->requireAtLeastOneParameter( $params, 'page', 'oldid' );
41        $threadItemSet = $this->getThreadItemSet( $params );
42
43        $result = [];
44        $prop = array_fill_keys( $params['prop'], true );
45
46        if ( isset( $prop['transcludedfrom'] ) ) {
47            $result['transcludedfrom'] = static::getTranscludedFrom( $threadItemSet );
48        }
49
50        if ( isset( $prop['threaditemshtml'] ) ) {
51            $flags = $params['threaditemsflags'] ?? [];
52            $excludeSignatures = $params['excludesignatures'] || in_array( 'excludesignatures', $flags );
53            $noReplies = in_array( 'noreplies', $flags );
54            $extraActivity = in_array( 'activity', $flags );
55            $result['threaditemshtml'] = static::getThreadItemsHtml(
56                $threadItemSet, $excludeSignatures, $noReplies, $extraActivity
57            );
58        }
59
60        $this->getResult()->addValue( null, $this->getModuleName(), $result );
61    }
62
63    /**
64     * Get the thread item set for the specified revision
65     *
66     * @throws ApiUsageException
67     * @param array $params
68     * @return ContentThreadItemSet
69     */
70    private function getThreadItemSet( $params ) {
71        if ( isset( $params['page'] ) ) {
72            $title = Title::newFromText( $params['page'] );
73            if ( !$title ) {
74                throw ApiUsageException::newWithMessage(
75                    $this,
76                    [ 'apierror-invalidtitle', wfEscapeWikiText( $params['page'] ) ]
77                );
78            }
79        }
80
81        if ( isset( $params['oldid'] ) ) {
82            $revision = $this->revisionLookup->getRevisionById( $params['oldid'] );
83            if ( !$revision ) {
84                throw ApiUsageException::newWithMessage(
85                    $this,
86                    [ 'apierror-nosuchrevid', $params['oldid'] ]
87                );
88            }
89        } else {
90            $title = Title::newFromText( $params['page'] );
91            if ( !$title ) {
92                throw ApiUsageException::newWithMessage(
93                    $this,
94                    [ 'apierror-invalidtitle', wfEscapeWikiText( $params['page'] ) ]
95                );
96            }
97            $revision = $this->revisionLookup->getRevisionByTitle( $title );
98            if ( !$revision ) {
99                throw ApiUsageException::newWithMessage(
100                    $this,
101                    [ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ],
102                    'nosuchrevid'
103                );
104            }
105        }
106        $title = Title::castFromPageIdentity( $revision->getPage() );
107
108        if ( !$title || !HookUtils::isAvailableForTitle( $title ) ) {
109            // T325477: don't parse non-discussion pages
110            return new ContentThreadItemSet;
111        }
112
113        $this->checkTitleUserPermissions( $title, 'read' );
114
115        if ( !$revision->audienceCan( RevisionRecord::DELETED_TEXT, RevisionRecord::FOR_PUBLIC ) ) {
116            $this->dieWithError( [ 'apierror-missingcontent-revid', $revision->getId() ], 'missingcontent' );
117        }
118
119        $status = HookUtils::parseRevisionParsoidHtml( $revision, __METHOD__ );
120        if ( !$status->isOK() ) {
121            $this->dieStatus( $status );
122        }
123        return $status->getValueOrThrow();
124    }
125
126    /**
127     * Get transcluded=from data for a ContentThreadItemSet
128     */
129    private static function getTranscludedFrom( ContentThreadItemSet $threadItemSet ): array {
130        $threadItems = $threadItemSet->getThreadItems();
131        $transcludedFrom = [];
132        foreach ( $threadItems as $threadItem ) {
133            $from = $threadItem->getTranscludedFrom();
134
135            // Key by IDs and names. This assumes that they can never conflict.
136
137            $transcludedFrom[ $threadItem->getId() ] = $from;
138
139            $name = $threadItem->getName();
140            if ( isset( $transcludedFrom[ $name ] ) && $transcludedFrom[ $name ] !== $from ) {
141                // Two or more items with the same name, transcluded from different pages.
142                // Consider them both to be transcluded from unknown source.
143                $transcludedFrom[ $name ] = true;
144            } else {
145                $transcludedFrom[ $name ] = $from;
146            }
147        }
148
149        return $transcludedFrom;
150    }
151
152    /**
153     * Get thread items HTML for a ContentThreadItemSet
154     */
155    private static function getThreadItemsHtml(
156        ContentThreadItemSet $threadItemSet,
157        bool $excludeSignatures, bool $noReplies, bool $extraActivity
158    ): array {
159        // This function assumes that the start of the ranges associated with
160        // HeadingItems are going to be at the start of their associated
161        // heading node (`<h2>^heading</h2>`), i.e. in the position generated
162        // by getHeadlineNode.
163        $threads = $threadItemSet->getThreads();
164        if ( count( $threads ) > 0 && !$threads[0]->isPlaceholderHeading() ) {
165            $firstHeading = $threads[0];
166            $firstRange = $firstHeading->getRange();
167            $rootNode = $firstHeading->getRootNode();
168            // We need a placeholder if there's content between the beginning
169            // of rootnode and the start of firstHeading. An ancestor of the
170            // first heading with a previousSibling is evidence that there's
171            // probably content. If this is giving false positives we could
172            // perhaps use linearWalkBackwards and DomUtils::isContentNode.
173            $closest = CommentUtils::closestElementWithSibling( $firstRange->startContainer, 'previous' );
174            if ( $closest && !$rootNode->isSameNode( $closest ) ) {
175                $range = new ImmutableRange( $rootNode, 0, $rootNode, 0 );
176                $fakeHeading = new ContentHeadingItem( $range, false, null );
177                $fakeHeading->setRootNode( $rootNode );
178                $fakeHeading->setName( 'h-' );
179                $fakeHeading->setId( 'h-' );
180                array_unshift( $threads, $fakeHeading );
181            }
182        }
183        $output = array_map( static function ( ContentThreadItem $item )
184            use ( $excludeSignatures, $noReplies, $extraActivity ) {
185            return $item->jsonSerialize( !$noReplies, static function ( array &$array, ContentThreadItem $item ) use (
186                $excludeSignatures, $noReplies, $extraActivity
187            ) {
188                if ( $item instanceof ContentCommentItem && $excludeSignatures ) {
189                    $array['html'] = $item->getBodyHTML( true );
190                } else {
191                    $array['html'] = $item->getHTML();
192                }
193
194                if ( $item instanceof ContentHeadingItem ) {
195                    $array['commentCount'] = $item->getCommentCount();
196                    $array['authorCount'] = count( $item->getAuthorsBelow() );
197                    $latestReply = $item->getLatestReply();
198                    if ( $latestReply ) {
199                        $array['latestReplyTimestamp'] = static::formatItemTimestamp( $latestReply );
200                        if ( $extraActivity ) {
201                            $array['latestReply'] = $latestReply->jsonSerialize( false );
202                            $array['latestReply']['timestamp'] = $array['latestReplyTimestamp'];
203                            unset( $array['latestReply']['replies'] );
204                        }
205                    } else {
206                        $array['latestReplyTimestamp'] = null;
207                        if ( $extraActivity ) {
208                            $array['latestReply'] = null;
209                        }
210                    }
211                    if ( $extraActivity ) {
212                        $oldestReply = $item->getOldestReply();
213                        if ( $oldestReply ) {
214                            $array['oldestReply'] = $oldestReply->jsonSerialize( false );
215                            $array['oldestReply']['timestamp'] = static::formatItemTimestamp( $oldestReply );
216                            unset( $array['oldestReply']['replies'] );
217                        } else {
218                            $array['oldestReply'] = null;
219                        }
220                    }
221                }
222
223                if ( $item instanceof CommentItem ) {
224                    $array['timestamp'] = static::formatItemTimestamp( $item );
225                }
226
227                if ( $noReplies ) {
228                    unset( $array['replies'] );
229                }
230            } );
231        }, $threads );
232        foreach ( $threads as $index => $item ) {
233            // need to loop over this to fix up empty sections, because we
234            // need context that's not available inside the array map
235            if ( $item instanceof ContentHeadingItem && count( $item->getReplies() ) === 0 ) {
236                // If there are no replies we want to include whatever's
237                // inside this section as "othercontent". We create a range
238                // that's between the end of this section's heading and the
239                // start of next section's heading. The main difficulty here
240                // is avoiding catching any of the heading's tags within the
241                // range.
242                $nextItem = $threads[ $index + 1 ] ?? false;
243                $startRange = $item->getRange();
244                if ( $item->isPlaceholderHeading() ) {
245                    // Placeholders don't have any heading to avoid
246                    $startNode = $startRange->startContainer;
247                    $startOffset = $startRange->startOffset;
248                } else {
249                    $startNode = CommentUtils::closestElementWithSibling( $startRange->endContainer, 'next' );
250                    if ( !$startNode ) {
251                        // If there's no siblings here this means we're on a
252                        // heading that is the final heading on a page and
253                        // which has no contents at all. We can skip the rest.
254                        continue;
255                    } else {
256                        $startNode = $startNode->nextSibling;
257                        $startOffset = 0;
258                    }
259                }
260
261                if ( !$startNode ) {
262                     $startNode = $startRange->endContainer;
263                     $startOffset = $startRange->endOffset;
264                }
265
266                if ( $nextItem ) {
267                    $nextStart = $nextItem->getRange()->startContainer;
268                    $endContainer = CommentUtils::closestElementWithSibling( $nextStart, 'previous' );
269                    $endContainer = $endContainer && $endContainer->previousSibling ?
270                        $endContainer->previousSibling : $nextStart;
271                    $endOffset = CommentUtils::childIndexOf( $endContainer );
272                    if ( $endContainer instanceof Text ) {
273                        // This probably means that there's a wrapping node
274                        // e.g. <div>foo\n==heading==\nbar</div>
275                        $endOffset += $endContainer->length;
276                    } elseif (
277                        $endContainer instanceof Element &&
278                        strtolower( $endContainer->tagName ) === 'section'
279                    ) {
280                        // if we're in sections, make sure we're selecting the
281                        // end of the previous section
282                        $endOffset = $endContainer->childNodes->length;
283                    } elseif ( $endContainer->parentNode ) {
284                        $endContainer = $endContainer->parentNode;
285                    }
286                    $betweenRange = new ImmutableRange(
287                        $startNode, $startOffset,
288                        $endContainer ?: $nextStart, $endOffset
289                    );
290                } else {
291                    // This is the last section, so we want to go to the end of the rootnode
292                    $betweenRange = new ImmutableRange(
293                        $startNode, $startOffset,
294                        $item->getRootNode(), $item->getRootNode()->childNodes->length
295                    );
296                }
297                $fragment = $betweenRange->cloneContents();
298                CommentModifier::unwrapFragment( $fragment );
299                $otherContent = trim( DOMUtils::getFragmentInnerHTML( $fragment ) );
300                if ( $otherContent ) {
301                    // A completely empty section will result in otherContent
302                    // being an empty string. In this case we should just not include it.
303                    $output[$index]['othercontent'] = $otherContent;
304                }
305
306            }
307        }
308        return $output;
309    }
310
311    /**
312     * We want timestamps to be consistently formatted in API output instead
313     * of varying based on comment time(T315400). The format used here is
314     * equivalent to 'Y-m-d\TH:i:s\Z'
315     */
316    private static function formatItemTimestamp( CommentItem $item ): string {
317        return wfTimestamp( TS_ISO_8601, $item->getTimestamp()->getTimestamp() );
318    }
319
320    /**
321     * @inheritDoc
322     */
323    public function getAllowedParams() {
324        return [
325            'page' => [
326                ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-page',
327            ],
328            'oldid' => [
329                ParamValidator::PARAM_TYPE => 'integer',
330            ],
331            'prop' => [
332                ParamValidator::PARAM_DEFAULT => 'transcludedfrom',
333                ParamValidator::PARAM_ISMULTI => true,
334                ParamValidator::PARAM_TYPE => [
335                    'transcludedfrom',
336                    'threaditemshtml'
337                ],
338                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
339            ],
340            'threaditemsflags' => [
341                ParamValidator::PARAM_REQUIRED => false,
342                ParamValidator::PARAM_ISMULTI => true,
343                ParamValidator::PARAM_TYPE => [
344                    'noreplies',
345                    'activity',
346                    'excludesignatures'
347                ],
348                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
349            ],
350            'excludesignatures' => [
351                ParamValidator::PARAM_DEPRECATED => true
352            ],
353        ];
354    }
355
356    /**
357     * @inheritDoc
358     */
359    public function needsToken() {
360        return false;
361    }
362
363    /**
364     * @inheritDoc
365     */
366    public function isInternal() {
367        return true;
368    }
369
370    /**
371     * @inheritDoc
372     */
373    public function isWriteMode() {
374        return false;
375    }
376}