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 / 31
0.00% covered (danger)
0.00%
0 / 1
20
 getTargetsHtml
0.00% covered (danger)
0.00%
0 / 50
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 ApiMain;
6use ApiUsageException;
7use Content;
8use ContentHandler;
9use DerivativeContext;
10use FormatJson;
11use IContextSource;
12use JsonContentHandler;
13use Language;
14use MediaWiki\Content\Renderer\ContentParseParams;
15use MediaWiki\Html\Html;
16use MediaWiki\Linker\Linker;
17use MediaWiki\MassMessage\Lookup\DatabaseLookup;
18use MediaWiki\MassMessage\UrlHelper;
19use MediaWiki\MediaWikiServices;
20use MediaWiki\Output\OutputPage;
21use MediaWiki\Parser\ParserOutput;
22use MediaWiki\Request\DerivativeRequest;
23use MediaWiki\Status\Status;
24use MediaWiki\Title\Title;
25use MediaWiki\Widget\TitleInputWidget;
26use OOUI\ActionFieldLayout;
27use OOUI\ButtonInputWidget;
28use OOUI\ComboBoxInputWidget;
29use OOUI\FieldLayout;
30use OOUI\FormLayout;
31use RequestContext;
32
33class 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}