Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.79% covered (warning)
72.79%
107 / 147
44.44% covered (danger)
44.44%
4 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiDiscussionToolsPageInfo
72.79% covered (warning)
72.79%
107 / 147
44.44% covered (danger)
44.44%
4 / 9
88.63
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
30.56% covered (danger)
30.56%
11 / 36
0.00% covered (danger)
0.00%
0 / 1
51.52
 getTranscludedFrom
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
7.46
 getThreadItemsHtml
90.77% covered (success)
90.77%
59 / 65
0.00% covered (danger)
0.00%
0 / 1
23.42
 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        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( ContentThreadItemSet $threadItemSet, bool $excludeSignatures ): array {
156        // This function assumes that the start of the ranges associated with
157        // HeadingItems are going to be at the start of their associated
158        // heading node (`<h2>^heading</h2>`), i.e. in the position generated
159        // by getHeadlineNode.
160        $threads = $threadItemSet->getThreads();
161        if ( count( $threads ) > 0 && !$threads[0]->isPlaceholderHeading() ) {
162            $firstHeading = $threads[0];
163            $firstRange = $firstHeading->getRange();
164            $rootNode = $firstHeading->getRootNode();
165            // We need a placeholder if there's content between the beginning
166            // of rootnode and the start of firstHeading. An ancestor of the
167            // first heading with a previousSibling is evidence that there's
168            // probably content. If this is giving false positives we could
169            // perhaps use linearWalkBackwards and DomUtils::isContentNode.
170            $closest = CommentUtils::closestElementWithSibling( $firstRange->startContainer, 'previous' );
171            if ( $closest && !$rootNode->isSameNode( $closest ) ) {
172                $range = new ImmutableRange( $rootNode, 0, $rootNode, 0 );
173                $fakeHeading = new ContentHeadingItem( $range, false, null );
174                $fakeHeading->setRootNode( $rootNode );
175                $fakeHeading->setName( 'h-' );
176                $fakeHeading->setId( 'h-' );
177                array_unshift( $threads, $fakeHeading );
178            }
179        }
180        $output = array_map( static function ( ContentThreadItem $item ) use ( $excludeSignatures ) {
181            return $item->jsonSerialize( true, static function ( array &$array, ContentThreadItem $item ) use (
182                $excludeSignatures
183            ) {
184                if ( $item instanceof ContentCommentItem && $excludeSignatures ) {
185                    $array['html'] = $item->getBodyHTML( true );
186                } else {
187                    $array['html'] = $item->getHTML();
188                }
189
190                if ( $item instanceof CommentItem ) {
191                    // We want timestamps to be consistently formatted in API
192                    // output instead of varying based on comment time
193                    // (T315400). The format used here is equivalent to 'Y-m-d\TH:i:s\Z'
194                    $array['timestamp'] = wfTimestamp( TS_ISO_8601, $item->getTimestamp()->getTimestamp() );
195                }
196            } );
197        }, $threads );
198        foreach ( $threads as $index => $item ) {
199            // need to loop over this to fix up empty sections, because we
200            // need context that's not available inside the array map
201            if ( $item instanceof ContentHeadingItem && count( $item->getReplies() ) === 0 ) {
202                // If there are no replies we want to include whatever's
203                // inside this section as "othercontent". We create a range
204                // that's between the end of this section's heading and the
205                // start of next section's heading. The main difficulty here
206                // is avoiding catching any of the heading's tags within the
207                // range.
208                $nextItem = $threads[ $index + 1 ] ?? false;
209                $startRange = $item->getRange();
210                if ( $item->isPlaceholderHeading() ) {
211                    // Placeholders don't have any heading to avoid
212                    $startNode = $startRange->startContainer;
213                    $startOffset = $startRange->startOffset;
214                } else {
215                    $startNode = CommentUtils::closestElementWithSibling( $startRange->endContainer, 'next' );
216                    if ( !$startNode ) {
217                        // If there's no siblings here this means we're on a
218                        // heading that is the final heading on a page and
219                        // which has no contents at all. We can skip the rest.
220                        continue;
221                    } else {
222                        $startNode = $startNode->nextSibling;
223                        $startOffset = 0;
224                    }
225                }
226
227                if ( !$startNode ) {
228                     $startNode = $startRange->endContainer;
229                     $startOffset = $startRange->endOffset;
230                }
231
232                if ( $nextItem ) {
233                    $nextStart = $nextItem->getRange()->startContainer;
234                    $endContainer = CommentUtils::closestElementWithSibling( $nextStart, 'previous' );
235                    $endContainer = $endContainer && $endContainer->previousSibling ?
236                        $endContainer->previousSibling : $nextStart;
237                    $endOffset = CommentUtils::childIndexOf( $endContainer );
238                    if ( $endContainer instanceof Text ) {
239                        // This probably means that there's a wrapping node
240                        // e.g. <div>foo\n==heading==\nbar</div>
241                        $endOffset += $endContainer->length;
242                    } elseif ( $endContainer instanceof Element && $endContainer->tagName === 'section' ) {
243                        // if we're in sections, make sure we're selecting the
244                        // end of the previous section
245                        $endOffset = $endContainer->childNodes->length;
246                    } elseif ( $endContainer->parentNode ) {
247                        $endContainer = $endContainer->parentNode;
248                    }
249                    $betweenRange = new ImmutableRange(
250                        $startNode, $startOffset,
251                        $endContainer ?: $nextStart, $endOffset
252                    );
253                } else {
254                    // This is the last section, so we want to go to the end of the rootnode
255                    $betweenRange = new ImmutableRange(
256                        $startNode, $startOffset,
257                        $item->getRootNode(), $item->getRootNode()->childNodes->length
258                    );
259                }
260                $fragment = $betweenRange->cloneContents();
261                CommentModifier::unwrapFragment( $fragment );
262                $otherContent = trim( DOMUtils::getFragmentInnerHTML( $fragment ) );
263                if ( $otherContent ) {
264                    // A completely empty section will result in otherContent
265                    // being an empty string. In this case we should just not include it.
266                    $output[$index]['othercontent'] = $otherContent;
267                }
268
269            }
270        }
271        return $output;
272    }
273
274    /**
275     * @inheritDoc
276     */
277    public function getAllowedParams() {
278        return [
279            'page' => [
280                ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-page',
281            ],
282            'oldid' => [
283                ParamValidator::PARAM_TYPE => 'integer',
284            ],
285            'prop' => [
286                ParamValidator::PARAM_DEFAULT => 'transcludedfrom',
287                ParamValidator::PARAM_ISMULTI => true,
288                ParamValidator::PARAM_TYPE => [
289                    'transcludedfrom',
290                    'threaditemshtml'
291                ],
292                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
293            ],
294            'excludesignatures' => false,
295        ];
296    }
297
298    /**
299     * @inheritDoc
300     */
301    public function needsToken() {
302        return false;
303    }
304
305    /**
306     * @inheritDoc
307     */
308    public function isInternal() {
309        return true;
310    }
311
312    /**
313     * @inheritDoc
314     */
315    public function isWriteMode() {
316        return false;
317    }
318}