Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
74.31% |
107 / 144 |
|
44.44% |
4 / 9 |
CRAP | |
0.00% |
0 / 1 |
ApiDiscussionToolsPageInfo | |
74.31% |
107 / 144 |
|
44.44% |
4 / 9 |
79.35 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
execute | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
3.05 | |||
getThreadItemSet | |
33.33% |
11 / 33 |
|
0.00% |
0 / 1 |
39.63 | |||
getTranscludedFrom | |
40.00% |
4 / 10 |
|
0.00% |
0 / 1 |
7.46 | |||
getThreadItemsHtml | |
90.77% |
59 / 65 |
|
0.00% |
0 / 1 |
23.42 | |||
getAllowedParams | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
1 | |||
needsToken | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isInternal | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isWriteMode | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\DiscussionTools; |
4 | |
5 | use ApiBase; |
6 | use ApiMain; |
7 | use ApiUsageException; |
8 | use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils; |
9 | use MediaWiki\Extension\DiscussionTools\ThreadItem\CommentItem; |
10 | use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem; |
11 | use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentHeadingItem; |
12 | use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentThreadItem; |
13 | use MediaWiki\Extension\VisualEditor\VisualEditorParsoidClientFactory; |
14 | use MediaWiki\Revision\RevisionLookup; |
15 | use MediaWiki\Title\Title; |
16 | use Wikimedia\ParamValidator\ParamValidator; |
17 | use Wikimedia\Parsoid\Core\ResourceLimitExceededException; |
18 | use Wikimedia\Parsoid\DOM\Element; |
19 | use Wikimedia\Parsoid\DOM\Text; |
20 | use Wikimedia\Parsoid\Utils\DOMUtils; |
21 | |
22 | class 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 getHeadlineNode. |
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 | } |