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