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