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