Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
35.17% covered (danger)
35.17%
83 / 236
26.67% covered (danger)
26.67%
4 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
NewsletterContentHandler
35.17% covered (danger)
35.17%
83 / 236
26.67% covered (danger)
26.67%
4 / 15
368.79
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeEmptyContent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 unserializeContent
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getContentClass
0.00% covered (danger)
0.00%
0 / 1
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
 getSecondaryDataUpdates
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 fillParserOutput
67.05% covered (warning)
67.05%
59 / 88
0.00% covered (danger)
0.00%
0 / 1
11.90
 edit
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
42
 getSlotDiffRendererWithOptions
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getPublishersFromJSONData
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getNewsletterActionButtons
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
20
 getHTMLForm
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 doLinkCacheQuery
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 buildUserList
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 setupNavigationLinks
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\Newsletter\Content;
4
5use ApiMain;
6use ApiUsageException;
7use Content;
8use DerivativeContext;
9use FormatJson;
10use HTMLForm;
11use IContextSource;
12use Iterator;
13use JsonContentHandler;
14use LogEventsList;
15use MediaWiki\Content\Renderer\ContentParseParams;
16use MediaWiki\Deferred\DeferrableUpdate;
17use MediaWiki\Extension\Newsletter\Newsletter;
18use MediaWiki\Html\Html;
19use MediaWiki\Linker\Linker;
20use MediaWiki\MediaWikiServices;
21use MediaWiki\Output\OutputPage;
22use MediaWiki\Parser\ParserOutput;
23use MediaWiki\Request\DerivativeRequest;
24use MediaWiki\Revision\SlotRenderingProvider;
25use MediaWiki\SpecialPage\SpecialPage;
26use MediaWiki\Status\Status;
27use MediaWiki\Title\Title;
28use MediaWiki\User\UserArray;
29use MediaWiki\User\UserArrayFromResult;
30use MWContentSerializationException;
31use OOUI\ButtonGroupWidget;
32use OOUI\ButtonWidget;
33use ParserOptions;
34use RequestContext;
35
36/**
37 * @license GPL-2.0-or-later
38 * @author tonythomas
39 */
40class NewsletterContentHandler extends JsonContentHandler {
41
42    /** Subpage actions */
43    private const NEWSLETTER_ANNOUNCE = 'announce';
44    private const NEWSLETTER_SUBSCRIBE = 'subscribe';
45    private const NEWSLETTER_UNSUBSCRIBE = 'unsubscribe';
46    private const NEWSLETTER_SUBSCRIBERS = 'subscribers';
47
48    /**
49     * @param string $modelId
50     */
51    public function __construct( $modelId = 'NewsletterContent' ) {
52        parent::__construct( $modelId );
53    }
54
55    /**
56     * @return NewsletterContent
57     */
58    public function makeEmptyContent() {
59        return new NewsletterContent( '{"description":"","mainpage":"","publishers":[]}' );
60    }
61
62    /**
63     * @param string $text
64     * @param string|null $format
65     * @return NewsletterContent
66     * @throws MWContentSerializationException
67     */
68    public function unserializeContent( $text, $format = null ) {
69        $this->checkFormat( $format );
70        $content = new NewsletterContent( $text );
71        if ( !$content->isValid() ) {
72            throw new MWContentSerializationException( 'The Newsletter content is invalid.' );
73        }
74        return $content;
75    }
76
77    /**
78     * @return string
79     */
80    protected function getContentClass() {
81        return NewsletterContent::class;
82    }
83
84    /**
85     * @return bool
86     */
87    public function isParserCacheSupported() {
88        return false;
89    }
90
91    /**
92     * @param Title $title The title of the page to supply the updates for.
93     * @param Content $content The content to generate data updates for.
94     * @param string $role The role (slot) in which the content is being used.
95     * @param SlotRenderingProvider $slotOutput A provider that can be used to gain access to
96     *        a ParserOutput of $content by calling $slotOutput->getSlotParserOutput( $role, false ).
97     * @return DeferrableUpdate[] A list of DeferrableUpdate objects for putting information
98     *        about this content object somewhere.
99     */
100    public function getSecondaryDataUpdates(
101        Title $title,
102        Content $content,
103        $role,
104        SlotRenderingProvider $slotOutput
105    ) {
106        $user = RequestContext::getMain()->getUser();
107        // @todo This user object might not be the right one in some cases.
108        // but that should be pretty rare in the context of newsletters.
109        /** @var NewsletterContent $content */
110        '@phan-var NewsletterContent $content';
111        $newsletterUpdate = new NewsletterDataUpdate( $content, $title, $user );
112        return array_merge(
113            parent::getSecondaryDataUpdates( $title, $content, $role, $slotOutput ),
114            [ $newsletterUpdate ]
115        );
116    }
117
118    /**
119     * @inheritDoc
120     */
121    protected function fillParserOutput(
122        Content $content,
123        ContentParseParams $cpoParams,
124        ParserOutput &$parserOutput
125    ) {
126        '@phan-var NewsletterContent $content';
127        $title = Title::castFromPageReference( $cpoParams->getPage() );
128        $parserOptions = $cpoParams->getParserOptions();
129        $generateHtml = $cpoParams->getGenerateHtml();
130
131        $parserOutput->addModuleStyles( [ 'ext.newsletter.newsletter.styles' ] );
132
133        if ( $generateHtml ) {
134            $text = $title->getText();
135            $newsletter = Newsletter::newFromName( $text );
136
137            $newsletterActionButtons = !$newsletter
138                ? ''
139                : $this->getNewsletterActionButtons( $newsletter, $parserOptions, $parserOutput );
140            $mainTitle = $content->getMainPage();
141
142            $fields = [
143                'description' => [
144                    'type' => 'info',
145                    'label-message' => 'newsletter-view-description',
146                    'default' => $content->getDescription(),
147                    'cssclass' => 'newsletter-headered-element',
148                    'rows' => 6,
149                    'readonly' => true,
150                ],
151                'mainpage' => [
152                    'type' => 'info',
153                    'label-message' => 'newsletter-view-mainpage',
154                    'default' => MediaWikiServices::getInstance()->getLinkRenderer()->makeLink( $mainTitle ),
155                    'cssclass' => 'newsletter-headered-element',
156                    'raw' => true,
157                ],
158                'publishers' => [
159                    'type' => 'info',
160                    'label' => wfMessage( 'newsletter-view-publishers' )->inLanguage(
161                        $parserOptions->getUserLangObj() )
162                        ->numParams( count( $content->getPublishers() ) )
163                        ->text(),
164                    'cssclass' => 'newsletter-headered-element',
165                ],
166                'subscribers' => [
167                    'type' => 'info',
168                    'label-message' => 'newsletter-view-subscriber-count',
169                    'default' => !$newsletter ? 0 : $parserOptions->getUserLangObj()->formatNum(
170                        $newsletter->getSubscribersCount() ),
171                    'cssclass' => 'newsletter-headered-element',
172                ],
173            ];
174            $publishersArray = $this->getPublishersFromJSONData( $content->getPublishers() );
175            if ( $publishersArray && count( $publishersArray ) > 0 ) {
176                // Have this here to avoid calling unneeded functions
177                $this->doLinkCacheQuery( $publishersArray );
178                $fields['publishers']['default'] = $this->buildUserList( $publishersArray );
179                $fields['publishers']['raw'] = true;
180            } else {
181                // Show a message if there are no publishers instead of nothing
182                $fields['publishers']['default'] = wfMessage( 'newsletter-view-no-publishers' )
183                    ->inLanguage( $parserOptions->getUserLangObj() )
184                    ->escaped();
185            }
186            if ( $newsletter ) {
187                // Show the 10 most recent issues if there have been announcements
188                $logs = '';
189                $logCount = LogEventsList::showLogExtract(
190                    $logs, // by reference
191                    'newsletter',
192                    SpecialPage::getTitleFor( 'Newsletter', (string)$newsletter->getId() ), '',
193                    [
194                        'lim' => 10,
195                        'showIfEmpty' => false,
196                        'conds' => [ 'log_action' => 'issue-added' ],
197                        'extraUrlParams' => [ 'subtype' => 'issue-added' ],
198                    ]
199                );
200                if ( $logCount !== 0 ) {
201                    $fields['issues'] = [
202                        'type' => 'info',
203                        'raw' => true,
204                        'default' => $logs,
205                        'label' => wfMessage( 'newsletter-view-issues-log' )
206                            ->inLanguage( $parserOptions->getUserLangObj() )
207                            ->numParams( $logCount )
208                            ->text(),
209                        'cssclass' => 'newsletter-headered-element',
210                    ];
211                }
212            }
213            $form = $this->getHTMLForm(
214                $fields,
215                static function () {
216                    return false;
217                } // nothing to submit - the buttons on this page are just links
218            );
219
220            $form->suppressDefaultSubmit();
221            $form->prepareForm();
222
223            if ( !$newsletter ) {
224                $parserOutput->setText( $form->getBody() );
225            } else {
226                $this->setupNavigationLinks( $newsletter, $parserOptions );
227                $parserOutput->setText( $newsletterActionButtons . "<br><br>" . $form->getBody() );
228            }
229
230        } else {
231            $parserOutput->setText( '' );
232        }
233    }
234
235    /**
236     * @param Title $title
237     * @param string $description
238     * @param string $mainPage
239     * @param array $publishers
240     * @param string $summary
241     * @param IContextSource $context
242     * @return Status
243     */
244    public static function edit( Title $title, $description, $mainPage, $publishers, $summary,
245        IContextSource $context
246    ) {
247        $jsonText = FormatJson::encode(
248            [ 'description' => $description, 'mainpage' => $mainPage, 'publishers' => $publishers ]
249        );
250        if ( $jsonText === null ) {
251            return Status::newFatal( 'newsletter-ch-tojsonerror' );
252        }
253
254        // FIXME It would be better if this editing directly, instead of
255        // invoking the api.
256        // Ensure that a valid context is provided to the API in unit tests
257        $der = new DerivativeContext( $context );
258        $request = new DerivativeRequest(
259            $context->getRequest(),
260            [
261                'action' => 'edit',
262                'title' => $title->getFullText(),
263                'contentmodel' => 'NewsletterContent',
264                'text' => $jsonText,
265                'summary' => $summary,
266                'token' => $context->getUser()->getEditToken(),
267            ],
268            true // Treat data as POSTed
269        );
270        $der->setRequest( $request );
271
272        $status = Status::newGood();
273        try {
274            $api = new ApiMain( $der, true );
275            $api->execute();
276            $res = $api->getResult()->getResultData();
277            if (
278                !isset( $res['edit']['result'] )
279                || $res['edit']['result'] !== 'Success'
280            ) {
281                if ( isset( $res['edit']['message'] ) ) {
282                    $status->fatal(
283                        $context->msg(
284                            $res['edit']['message']['key'],
285                            $res['edit']['message']['params']
286                        )
287                    );
288                } else {
289                    $status->fatal( $context->msg(
290                        'newsletter-ch-apierror',
291                        $res['edit']['code'] ?? ''
292                    ) );
293                }
294            }
295        } catch ( ApiUsageException $e ) {
296            return Status::wrap( $e->getStatusValue() );
297        }
298        return $status;
299    }
300
301    public function getSlotDiffRendererWithOptions( IContextSource $context, $options = [] ) {
302        return new NewsletterSlotDiffRenderer(
303            $this->createTextSlotDiffRenderer( $options ),
304            $context
305        );
306    }
307
308    /**
309     * @param array $publishersList
310     * @return bool|UserArrayFromResult
311     */
312    private function getPublishersFromJSONData( $publishersList ) {
313        if ( count( $publishersList ) === 0 ) {
314            return false;
315        }
316
317        return UserArray::newFromNames( $publishersList );
318    }
319
320    /**
321     * Build a group of buttons: Manage, Subscribe|Unsubscribe
322     * Buttons will be showed to the user only if they are relevant to the current user.
323     *
324     * @param Newsletter $newsletter
325     * @param ParserOptions &$options
326     * @param ParserOutput $parserOutput
327     * @return string HTML for the button group
328     */
329    private function getNewsletterActionButtons(
330        Newsletter $newsletter,
331        ParserOptions &$options,
332        ParserOutput $parserOutput
333    ) {
334        // We are building the 'Subscribe' action button for anonymous users as well
335        $user = $options->getUserIdentity();
336        $id = $newsletter->getId();
337        $buttons = [];
338
339        OutputPage::setupOOUI();
340        $parserOutput->setEnableOOUI( true );
341        $parserOutput->addModuleStyles( [ 'oojs-ui.styles.icons-interactions' ] );
342
343        if ( !$newsletter->isSubscribed( $user ) ) {
344            $buttons[] = new ButtonWidget(
345                [
346                    'label' => wfMessage( 'newsletter-subscribe-button' )->text(),
347                    'flags' => [ 'progressive' ],
348                    'href' => SpecialPage::getTitleFor( 'Newsletter', $id . '/' .
349                        self::NEWSLETTER_SUBSCRIBE )->getFullURL()
350
351                ]
352            );
353        } else {
354            $buttons[] = new ButtonWidget(
355                [
356                    'label' => wfMessage( 'newsletter-unsubscribe-button' )->text(),
357                    'flags' => [ 'destructive' ],
358                    'href' => SpecialPage::getTitleFor( 'Newsletter', $id . '/' .
359                        self::NEWSLETTER_UNSUBSCRIBE )->getFullURL()
360
361                ]
362            );
363        }
364        $userFactory = MediaWikiServices::getInstance()->getUserFactory();
365        if ( $newsletter->canManage( $userFactory->newFromUserIdentity( $user ) ) ) {
366            $buttons[] = new ButtonWidget(
367                [
368                    'label' => wfMessage( 'newsletter-manage-button' )->text(),
369                    'icon' => 'settings',
370                    'href' => Title::makeTitleSafe( NS_NEWSLETTER, $newsletter->getName() )->getEditURL(),
371
372                ]
373            );
374            $buttons[] = new ButtonWidget(
375                [
376                    'label' => wfMessage( 'newsletter-subscribers-button' )->text(),
377                    'icon' => 'info',
378                    'href' => SpecialPage::getTitleFor( 'Newsletter', $id . '/' .
379                        self::NEWSLETTER_SUBSCRIBERS )->getFullURL()
380
381                ]
382            );
383        }
384        if ( $newsletter->isPublisher( $user ) ) {
385            $buttons[] = new ButtonWidget(
386                [
387                    'label' => wfMessage( 'newsletter-announce-button' )->text(),
388                    'icon' => 'speechBubble',
389                    'href' => SpecialPage::getTitleFor( 'Newsletter', $id . '/' .
390                        self::NEWSLETTER_ANNOUNCE )->getFullURL()
391                ]
392            );
393        }
394
395        $widget = new ButtonGroupWidget( [ 'items' => $buttons ] );
396        return $widget->toString();
397    }
398
399    /**
400     * Create a common HTMLForm which can be used by specific page actions
401     *
402     * @param array $fields array of form fields
403     * @param callback $submit submit callback
404     *
405     * @return HTMLForm
406     */
407    private function getHTMLForm( array $fields, /* callable */ $submit ) {
408        $form = HTMLForm::factory(
409            'ooui',
410            $fields,
411            RequestContext::getMain()
412        );
413        $form->setSubmitCallback( $submit );
414        return $form;
415    }
416
417    /**
418     * Batch query to determine whether user pages and user talk pages exist
419     * or not and add them to LinkCache
420     *
421     * @param Iterator $users
422     */
423    private function doLinkCacheQuery( Iterator $users ) {
424        $batch = MediaWikiServices::getInstance()->getLinkBatchFactory()->newLinkBatch();
425        foreach ( $users as $user ) {
426            $batch->addObj( $user->getUserPage() );
427            $batch->addObj( $user->getTalkPage() );
428        }
429        $batch->execute();
430    }
431
432    /**
433     * Get a list of users with user-related links next to each username
434     *
435     * @param Iterator $users
436     *
437     * @return string
438     */
439    private function buildUserList( Iterator $users ) {
440        $str = '';
441        foreach ( $users as $user ) {
442            $str .= Html::rawElement(
443                'li',
444                [],
445                Linker::userLink( $user->getId(), $user->getName() ) .
446                Linker::userToolLinks( $user->getId(), $user->getName() )
447            );
448        }
449        return Html::rawElement( 'ul', [], $str );
450    }
451
452    /**
453     * @param Newsletter $newsletter
454     * @param ParserOptions $options
455     */
456    private function setupNavigationLinks( Newsletter $newsletter, ParserOptions $options ) {
457        $context = RequestContext::getMain();
458        $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
459        $listLink = $linkRenderer->makeKnownLink(
460            SpecialPage::getTitleFor( 'Newsletters' ),
461            $context->msg( 'backlinksubtitle',
462                $context->msg( 'newsletter-subtitlelinks-list' )->text()
463            )->text()
464        );
465
466        $newsletterLink = Linker::makeSelfLinkObj(
467            SpecialPage::getTitleFor( 'Newsletter', (string)$newsletter->getId() ),
468            htmlspecialchars( $newsletter->getName() )
469        );
470
471        $context->getOutput()->setSubtitle(
472            $options->getUserLangObj()->pipeList( [ $listLink, $newsletterLink ] )
473        );
474    }
475}