Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
72.79% |
107 / 147 |
|
44.44% |
4 / 9 |
CRAP | |
0.00% |
0 / 1 |
ApiDiscussionToolsPageInfo | |
72.79% |
107 / 147 |
|
44.44% |
4 / 9 |
88.63 | |
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 | |
30.56% |
11 / 36 |
|
0.00% |
0 / 1 |
51.52 | |||
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 MediaWiki\Api\ApiBase; |
6 | use MediaWiki\Api\ApiMain; |
7 | use MediaWiki\Api\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\Revision\RevisionRecord; |
16 | use MediaWiki\Title\Title; |
17 | use Wikimedia\ParamValidator\ParamValidator; |
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 | 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 | } |