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