Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
25.45% |
57 / 224 |
|
6.67% |
1 / 15 |
CRAP | |
0.00% |
0 / 1 |
MassMessageListContentHandler | |
25.45% |
57 / 224 |
|
6.67% |
1 / 15 |
1172.50 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
makeEmptyContent | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getContentClass | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getSlotDiffRendererWithOptions | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
isParserCacheSupported | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
edit | |
92.86% |
26 / 28 |
|
0.00% |
0 / 1 |
4.01 | |||
normalizeTargetArray | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
compareTargets | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
8.12 | |||
extractTarget | |
95.45% |
21 / 22 |
|
0.00% |
0 / 1 |
12 | |||
getPageLanguage | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getPageViewLanguage | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
fillParserOutput | |
0.00% |
0 / 31 |
|
0.00% |
0 / 1 |
20 | |||
getTargetsHtml | |
0.00% |
0 / 50 |
|
0.00% |
0 / 1 |
110 | |||
getTargetsBySite | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
getAddForm | |
0.00% |
0 / 65 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | |
3 | namespace MediaWiki\MassMessage\Content; |
4 | |
5 | use ApiMain; |
6 | use ApiUsageException; |
7 | use Content; |
8 | use ContentHandler; |
9 | use DerivativeContext; |
10 | use FormatJson; |
11 | use IContextSource; |
12 | use JsonContentHandler; |
13 | use Language; |
14 | use MediaWiki\Content\Renderer\ContentParseParams; |
15 | use MediaWiki\Html\Html; |
16 | use MediaWiki\Linker\Linker; |
17 | use MediaWiki\MassMessage\Lookup\DatabaseLookup; |
18 | use MediaWiki\MassMessage\UrlHelper; |
19 | use MediaWiki\MediaWikiServices; |
20 | use MediaWiki\Output\OutputPage; |
21 | use MediaWiki\Parser\ParserOutput; |
22 | use MediaWiki\Request\DerivativeRequest; |
23 | use MediaWiki\Status\Status; |
24 | use MediaWiki\Title\Title; |
25 | use MediaWiki\Widget\TitleInputWidget; |
26 | use OOUI\ActionFieldLayout; |
27 | use OOUI\ButtonInputWidget; |
28 | use OOUI\ComboBoxInputWidget; |
29 | use OOUI\FieldLayout; |
30 | use OOUI\FormLayout; |
31 | use RequestContext; |
32 | |
33 | class MassMessageListContentHandler extends JsonContentHandler { |
34 | |
35 | /** |
36 | * @param string $modelId |
37 | */ |
38 | public function __construct( $modelId = 'MassMessageListContent' ) { |
39 | parent::__construct( $modelId ); |
40 | } |
41 | |
42 | /** |
43 | * @return MassMessageListContent |
44 | */ |
45 | public function makeEmptyContent() { |
46 | return new MassMessageListContent( '{"description":"","targets":[]}' ); |
47 | } |
48 | |
49 | /** |
50 | * @return string |
51 | */ |
52 | protected function getContentClass() { |
53 | return MassMessageListContent::class; |
54 | } |
55 | |
56 | /** |
57 | * @param IContextSource $context |
58 | * @param array $options |
59 | * @return MassMessageListSlotDiffRenderer |
60 | */ |
61 | public function getSlotDiffRendererWithOptions( IContextSource $context, $options = [] ) { |
62 | return new MassMessageListSlotDiffRenderer( |
63 | $this->createTextSlotDiffRenderer( $options ), |
64 | $context |
65 | ); |
66 | } |
67 | |
68 | /** |
69 | * @return bool |
70 | */ |
71 | public function isParserCacheSupported() { |
72 | return true; |
73 | } |
74 | |
75 | /** |
76 | * Edit a delivery list via the edit API |
77 | * @param Title $title |
78 | * @param string $description |
79 | * @param array $targets |
80 | * @param string $summary Message key for edit summary |
81 | * @param bool $isMinor Is this a minor edit |
82 | * @param string $watchlist Value to pass to the edit API for the watchlist parameter. |
83 | * @param IContextSource $context The calling context |
84 | * @return Status |
85 | */ |
86 | public static function edit( |
87 | Title $title, $description, $targets, $summary, $isMinor, $watchlist, IContextSource $context |
88 | ) { |
89 | $jsonText = FormatJson::encode( |
90 | [ 'description' => $description, 'targets' => $targets ] |
91 | ); |
92 | if ( $jsonText === null ) { |
93 | return Status::newFatal( 'massmessage-ch-tojsonerror' ); |
94 | } |
95 | |
96 | // Ensure that a valid context is provided to the API in unit tests |
97 | $der = new DerivativeContext( $context ); |
98 | $requestParameters = [ |
99 | 'action' => 'edit', |
100 | 'title' => $title->getFullText(), |
101 | 'contentmodel' => 'MassMessageListContent', |
102 | 'text' => $jsonText, |
103 | 'watchlist' => $watchlist, |
104 | 'summary' => $summary, |
105 | 'token' => $context->getUser()->getEditToken(), |
106 | ]; |
107 | if ( $isMinor ) { |
108 | $requestParameters['minor'] = $isMinor; |
109 | } |
110 | $request = new DerivativeRequest( |
111 | $context->getRequest(), |
112 | $requestParameters, |
113 | // Treat data as POSTed |
114 | true |
115 | ); |
116 | $der->setRequest( $request ); |
117 | |
118 | try { |
119 | $api = new ApiMain( $der, true ); |
120 | $api->execute(); |
121 | } catch ( ApiUsageException $e ) { |
122 | return Status::wrap( $e->getStatusValue() ); |
123 | } |
124 | return Status::newGood(); |
125 | } |
126 | |
127 | /** |
128 | * Deduplicate and sort a target array |
129 | * @param array[] $targets |
130 | * @return array[] |
131 | */ |
132 | public static function normalizeTargetArray( $targets ) { |
133 | $targets = array_unique( $targets, SORT_REGULAR ); |
134 | usort( $targets, [ __CLASS__, 'compareTargets' ] ); |
135 | return $targets; |
136 | } |
137 | |
138 | /** |
139 | * Compare two targets for ordering |
140 | * @param array $a |
141 | * @param array $b |
142 | * @return int |
143 | */ |
144 | public static function compareTargets( $a, $b ) { |
145 | if ( !array_key_exists( 'site', $a ) && array_key_exists( 'site', $b ) ) { |
146 | return -1; |
147 | } elseif ( array_key_exists( 'site', $a ) && !array_key_exists( 'site', $b ) ) { |
148 | return 1; |
149 | } elseif ( array_key_exists( 'site', $a ) && array_key_exists( 'site', $b ) |
150 | && $a['site'] !== $b['site'] |
151 | ) { |
152 | return strcmp( $a['site'], $b['site'] ); |
153 | } else { |
154 | return strcmp( $a['title'], $b['title'] ); |
155 | } |
156 | } |
157 | |
158 | /** |
159 | * Helper function to extract and validate title and site (if specified) from a target string |
160 | * @param string $target |
161 | * @return array Contains an 'errors' key for an array of errors if the string is invalid |
162 | */ |
163 | public static function extractTarget( $target ) { |
164 | $config = MediaWikiServices::getInstance()->getMainConfig(); |
165 | |
166 | $target = trim( $target ); |
167 | $delimiterPos = strrpos( $target, '@' ); |
168 | if ( $delimiterPos !== false && $delimiterPos < strlen( $target ) ) { |
169 | $titleText = substr( $target, 0, $delimiterPos ); |
170 | $site = strtolower( substr( $target, $delimiterPos + 1 ) ); |
171 | } else { |
172 | $titleText = $target; |
173 | $site = null; |
174 | } |
175 | |
176 | $result = []; |
177 | |
178 | $title = Title::newFromText( $titleText ); |
179 | if ( !$title |
180 | || $title->getText() === '' |
181 | || !$title->canExist() |
182 | ) { |
183 | $result['errors'][] = 'invalidtitle'; |
184 | } else { |
185 | // Use the canonical form. |
186 | $result['title'] = $title->getPrefixedText(); |
187 | } |
188 | |
189 | if ( $site !== null && $site !== UrlHelper::getBaseUrl( $config->get( 'CanonicalServer' ) ) ) { |
190 | if ( !$config->get( 'AllowGlobalMessaging' ) || DatabaseLookup::getDBName( $site ) === null ) { |
191 | $result['errors'][] = 'invalidsite'; |
192 | } else { |
193 | $result['site'] = $site; |
194 | } |
195 | } elseif ( $title && $title->isExternal() ) { |
196 | // Target has site set to current wiki, but external title |
197 | // TODO: Provide better error message? |
198 | $result['errors'][] = 'invalidtitle'; |
199 | } |
200 | |
201 | return $result; |
202 | } |
203 | |
204 | /** |
205 | * @param Title $title |
206 | * @param Content|null $content |
207 | * @return Language |
208 | */ |
209 | public function getPageLanguage( Title $title, Content $content = null ) { |
210 | // This class inherits from JsonContentHandler, which hardcodes English. |
211 | // Use the default method from ContentHandler instead to get the page/site language. |
212 | return ContentHandler::getPageLanguage( $title, $content ); |
213 | } |
214 | |
215 | /** |
216 | * @param Title $title |
217 | * @param Content|null $content |
218 | * @return Language |
219 | */ |
220 | public function getPageViewLanguage( Title $title, Content $content = null ) { |
221 | // Most of the interface is rendered in user language |
222 | return RequestContext::getMain()->getLanguage(); |
223 | } |
224 | |
225 | /** |
226 | * @inheritDoc |
227 | */ |
228 | protected function fillParserOutput( |
229 | Content $content, |
230 | ContentParseParams $cpoParams, |
231 | ParserOutput &$output |
232 | ) { |
233 | '@phan-var MassMessageListContent $content'; |
234 | $services = MediaWikiServices::getInstance(); |
235 | |
236 | $page = $cpoParams->getPage(); |
237 | $revId = $cpoParams->getRevId(); |
238 | $parserOptions = $cpoParams->getParserOptions(); |
239 | // Parse the description text. |
240 | $output = $services->getParser() |
241 | ->parse( $content->getDescription(), $page, $parserOptions, true, true, $revId ); |
242 | $services->getTrackingCategories()->addTrackingCategory( $output, 'massmessage-list-category', $page ); |
243 | $lang = $parserOptions->getUserLangObj(); |
244 | |
245 | if ( $content->hasInvalidTargets() ) { |
246 | $warning = Html::element( 'p', [ 'class' => 'error' ], |
247 | wfMessage( 'massmessage-content-invalidtargets' )->inLanguage( $lang )->text() |
248 | ); |
249 | } else { |
250 | $warning = ''; |
251 | } |
252 | |
253 | // Mark the description language (may be different from user language used to render the rest of the page) |
254 | $description = $output->getRawText(); |
255 | $title = Title::castFromPageReference( $page ); |
256 | $pageLang = $title->getPageLanguage(); |
257 | $attribs = [ 'lang' => $pageLang->getHtmlCode(), 'dir' => $pageLang->getDir(), |
258 | 'class' => 'mw-content-' . $pageLang->getDir() ]; |
259 | |
260 | $output->setEnableOOUI( true ); |
261 | OutputPage::setupOOUI(); |
262 | $output->setText( $warning . Html::rawElement( 'div', $attribs, $description ) . self::getAddForm( $lang ) |
263 | . $this->getTargetsHtml( $content, $lang ) ); |
264 | |
265 | // Update the links table. |
266 | $targets = $content->getTargets(); |
267 | foreach ( $targets as $target ) { |
268 | if ( !array_key_exists( 'site', $target ) ) { |
269 | $output->addLink( Title::newFromText( $target['title'] ) ); |
270 | } else { |
271 | $output->addExternalLink( |
272 | '//' . $target['site'] . $services->getMainConfig()->get( 'Script' ) |
273 | . '?title=' . Title::newFromText( $target['title'] )->getPrefixedURL() ); |
274 | } |
275 | } |
276 | |
277 | $output->addModuleStyles( [ 'ext.MassMessage.styles' ] ); |
278 | $output->addModules( [ 'ext.MassMessage.content' ] ); |
279 | } |
280 | |
281 | /** |
282 | * Helper function for fillParserOutput; return HTML for displaying the list of pages. |
283 | * Note that the function assumes that the contents are valid. |
284 | * |
285 | * @param MassMessageListContent $content |
286 | * @param Language $lang |
287 | * @return string |
288 | */ |
289 | private function getTargetsHtml( MassMessageListContent $content, Language $lang ) { |
290 | $services = MediaWikiServices::getInstance(); |
291 | |
292 | $html = Html::element( 'h2', [], |
293 | wfMessage( 'massmessage-content-pages' )->inLanguage( $lang )->text() ); |
294 | |
295 | $sites = $this->getTargetsBySite( $content ); |
296 | |
297 | // If the list is empty |
298 | if ( count( $sites ) === 0 ) { |
299 | $html .= Html::element( 'p', [], |
300 | wfMessage( 'massmessage-content-empty' )->inLanguage( $lang )->text() ); |
301 | return $html; |
302 | } |
303 | |
304 | // Use LinkBatch to cache existence for all local targets for later use by Linker. |
305 | if ( array_key_exists( 'local', $sites ) ) { |
306 | $lb = $services->getLinkBatchFactory()->newLinkBatch(); |
307 | foreach ( $sites['local'] as $target ) { |
308 | $lb->addObj( Title::newFromText( $target ) ); |
309 | } |
310 | $lb->execute(); |
311 | } |
312 | |
313 | // Determine whether there are targets on external wikis. |
314 | $printSites = count( $sites ) !== 1 || !array_key_exists( 'local', $sites ); |
315 | $linkRenderer = $services->getLinkRenderer(); |
316 | foreach ( $sites as $site => $targets ) { |
317 | if ( $printSites ) { |
318 | if ( $site === 'local' ) { |
319 | $html .= Html::element( 'p', [], |
320 | wfMessage( 'massmessage-content-localpages' )->inLanguage( $lang )->text() |
321 | ); |
322 | } else { |
323 | $html .= Html::element( 'p', [], |
324 | wfMessage( 'massmessage-content-pagesonsite', $site )->inLanguage( $lang ) |
325 | ->text() |
326 | ); |
327 | } |
328 | } |
329 | |
330 | $html .= Html::openElement( 'ul' ); |
331 | foreach ( $targets as $target ) { |
332 | $title = Title::newFromText( $target ); |
333 | |
334 | // Generate the HTML for the link to the target. |
335 | if ( $site === 'local' ) { |
336 | $targetLink = $linkRenderer->makeLink( $title ); |
337 | } else { |
338 | $targetLink = Linker::makeExternalLink( |
339 | "//$site" . $services->getMainConfig()->get( 'Script' ) . '?title=' . $title->getPrefixedURL(), |
340 | $title->getPrefixedText() |
341 | ); |
342 | } |
343 | |
344 | // Generate the HTML for the remove link. |
345 | $removeLink = Html::element( 'a', |
346 | [ |
347 | 'data-title' => $title->getPrefixedText(), |
348 | 'data-site' => $site, |
349 | 'href' => '#', |
350 | ], |
351 | wfMessage( 'massmessage-content-remove' )->inLanguage( $lang )->text() |
352 | ); |
353 | |
354 | $html .= Html::openElement( 'li' ); |
355 | $html .= Html::rawElement( 'span', [ 'class' => 'mw-massmessage-targetlink' ], |
356 | $targetLink ); |
357 | $html .= Html::rawElement( 'span', [ 'class' => 'mw-massmessage-removelink' ], |
358 | '(' . $removeLink . ')' ); |
359 | $html .= Html::closeElement( 'li' ); |
360 | } |
361 | $html .= Html::closeElement( 'ul' ); |
362 | } |
363 | |
364 | return $html; |
365 | } |
366 | |
367 | /** |
368 | * Helper function for getTargetsHtml; return the array of targets sorted by site. |
369 | * Note that the function assumes that the contents are valid. |
370 | * |
371 | * @param MassMessageListContent $content |
372 | * @return array |
373 | */ |
374 | private function getTargetsBySite( MassMessageListContent $content ) { |
375 | $targets = $content->getTargets(); |
376 | $results = []; |
377 | foreach ( $targets as $target ) { |
378 | if ( array_key_exists( 'site', $target ) ) { |
379 | $results[$target['site']][] = $target['title']; |
380 | } else { |
381 | $results['local'][] = $target['title']; |
382 | } |
383 | } |
384 | return $results; |
385 | } |
386 | |
387 | /** |
388 | * Helper function for fillParserOutput; return HTML for page-adding form and |
389 | * (initially empty and hidden) list of added pages. |
390 | * |
391 | * @param Language $lang |
392 | * @return string |
393 | */ |
394 | private static function getAddForm( Language $lang ) { |
395 | $config = MediaWikiServices::getInstance()->getMainConfig(); |
396 | |
397 | $html = Html::openElement( 'div', [ 'id' => 'mw-massmessage-addpages' ] ); |
398 | $html .= Html::element( 'h2', [], |
399 | wfMessage( 'massmessage-content-addheading' )->inLanguage( $lang )->text() ); |
400 | |
401 | $titleWidget = new TitleInputWidget( [] ); |
402 | $titleLabel = wfMessage( 'massmessage-content-addtitle' )->inLanguage( $lang )->text(); |
403 | $submitWidget = new ButtonInputWidget( [ |
404 | 'type' => 'submit', |
405 | 'label' => wfMessage( 'massmessage-content-addsubmit' )->inLanguage( $lang )->text(), |
406 | ] ); |
407 | $sites = DatabaseLookup::getDatabases(); |
408 | if ( $config->get( 'AllowGlobalMessaging' ) && count( $sites ) > 1 ) { |
409 | // Treat all 3 widgets as distinct items in the layout |
410 | $items = [ |
411 | new FieldLayout( |
412 | $titleWidget, |
413 | [ |
414 | 'id' => 'mw-massmessage-addtitle', |
415 | 'label' => $titleLabel, |
416 | 'align' => 'top', |
417 | ], |
418 | ), |
419 | new FieldLayout( |
420 | new ComboBoxInputWidget( [ |
421 | 'name' => 'site', |
422 | 'placeholder' => UrlHelper::getBaseUrl( $config->get( 'CanonicalServer' ) ), |
423 | 'autocomplete' => true, |
424 | 'options' => array_map( |
425 | static function ( $domain ) { |
426 | return [ 'data' => $domain, 'label' => $domain ]; |
427 | }, |
428 | array_keys( $sites ) |
429 | ), |
430 | ] ), |
431 | [ |
432 | 'id' => 'mw-massmessage-addsite', |
433 | 'label' => wfMessage( 'massmessage-content-addsite' )->inLanguage( $lang )->text(), |
434 | 'align' => 'top', |
435 | ] |
436 | ), |
437 | new FieldLayout( $submitWidget ) |
438 | ]; |
439 | } else { |
440 | // Use a joined layout |
441 | $items = [ |
442 | new ActionFieldLayout( |
443 | $titleWidget, |
444 | $submitWidget, |
445 | [ |
446 | 'id' => 'mw-massmessage-addtitle', |
447 | 'label' => $titleLabel, |
448 | 'align' => 'top', |
449 | ] |
450 | ) |
451 | ]; |
452 | } |
453 | $html .= new FormLayout( [ |
454 | 'id' => 'mw-massmessage-addform', |
455 | 'items' => $items, |
456 | 'infusable' => true, |
457 | ] ); |
458 | |
459 | // List of added pages |
460 | $html .= Html::rawElement( |
461 | 'div', |
462 | [ 'id' => 'mw-massmessage-addedlist' ], |
463 | Html::element( 'p', [], wfMessage( 'massmessage-content-addedlistheading' )->inLanguage( $lang )->text() ) . |
464 | Html::element( 'ul', [], '' ) |
465 | ); |
466 | |
467 | $html .= Html::closeElement( 'div' ); |
468 | return $html; |
469 | } |
470 | } |