Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 692
0.00% covered (danger)
0.00%
0 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialCentralNoticeBanners
0.00% covered (danger)
0.00%
0 / 692
0.00% covered (danger)
0.00%
0 / 16
9702
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
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 ensureBanner
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 showBannerList
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 generateBannerListForm
0.00% covered (danger)
0.00%
0 / 89
0.00% covered (danger)
0.00%
0 / 1
12
 processBannerList
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 1
380
 setFilterFromUrl
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getFilterUrlParamAsArray
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getBannerPreviewEditLinks
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
6
 showBannerEditor
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
20
 generateBannerEditForm
0.00% covered (danger)
0.00%
0 / 274
0.00% covered (danger)
0.00%
0 / 1
552
 generateCdnPurgeSection
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
12
 processEditBanner
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
240
 processSaveBannerAction
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
56
 getTemplateBannerDropdownItems
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3use MediaWiki\Deferred\DeferredUpdates;
4use MediaWiki\Exception\ErrorPageError;
5use MediaWiki\Html\Html;
6use MediaWiki\HTMLForm\Field\HTMLButtonField;
7use MediaWiki\HTMLForm\Field\HTMLCheckField;
8use MediaWiki\HTMLForm\Field\HTMLInfoField;
9use MediaWiki\HTMLForm\Field\HTMLSelectLimitField;
10use MediaWiki\HTMLForm\Field\HTMLSubmitField;
11use MediaWiki\HTMLForm\Field\HTMLTextField;
12use MediaWiki\Language\LanguageNameUtils;
13use MediaWiki\Parser\ParserOptions;
14use MediaWiki\SpecialPage\SpecialPage;
15
16/**
17 * Special page for management of CentralNotice banners
18 */
19class SpecialCentralNoticeBanners extends CentralNotice {
20    /** @var string Name of the banner we're currently editing */
21    private $bannerName = '';
22
23    /** @var Banner|null Banner object we're currently editing */
24    private $banner = null;
25
26    /** @var string Filter to apply to the banner search when generating the list */
27    private $bannerFilterString = '';
28
29    /** @var string Language code to render preview materials in */
30    private $bannerLanguagePreview;
31
32    /** @var bool If true, form execution must stop and the page will be redirected */
33    private $bannerFormRedirectRequired = false;
34
35    /** @var array|null Names of the banners that are marked as templates */
36    private $templateBannerNames = null;
37
38    public function __construct(
39        private readonly LanguageNameUtils $languageNameUtils,
40    ) {
41        parent::__construct( 'CentralNoticeBanners' );
42    }
43
44    /** @inheritDoc */
45    public function doesWrites() {
46        return true;
47    }
48
49    /**
50     * Handle all the different types of page requests determined by the first subpage
51     * level after the special page title. If needed, the second subpage level is the
52     * banner name.
53     *
54     * Valid actions are:
55     *    (none)    - Display a list of banners
56     *    edit      - Edits an existing banner
57     *
58     * TODO: Use the "?action=" convention rather than parsing the URL subpath.
59     *
60     * @param string|null $subPage
61     */
62    public function execute( $subPage ) {
63        // Do all the common setup
64        $this->setHeaders();
65        $this->editable = $this->getUser()->isAllowed( 'centralnotice-admin' );
66
67        // Make sure we have a session
68        $this->getRequest()->getSession()->persist();
69
70        // Load things that may have been serialized into the session
71        $this->bannerLanguagePreview = $this->getCNSessionVar(
72            'bannerLanguagePreview',
73            $this->getLanguage()->getCode()
74        );
75
76        // User settable text for some custom message, like usage instructions
77        $this->getOutput()->setPageTitleMsg( $this->msg( 'noticetemplate' ) );
78
79        // Allow users to add a custom nav bar (T138284)
80        $navBar = $this->msg( 'centralnotice-navbar' )->inContentLanguage();
81        if ( !$navBar->isDisabled() ) {
82            $this->getOutput()->addHTML( $navBar->parseAsBlock() );
83        }
84        $this->getOutput()->addWikiMsg( 'centralnotice-summary' );
85
86        // Now figure out what to display
87        // TODO Use only params instead of subpage to indicate action
88        $parts = explode( '/', $subPage ?? '' );
89        $action = $parts[0] ?: 'list';
90        $this->bannerName = $parts[1] ?? '';
91
92        switch ( strtolower( $action ) ) {
93            case 'list':
94                // Display the list of banners
95                $this->showBannerList();
96                break;
97
98            case 'edit':
99                if ( $this->bannerName ) {
100                    $this->ensureBanner( $this->bannerName );
101                    $this->showBannerEditor();
102                } else {
103                    throw new ErrorPageError( 'noticetemplate', 'centralnotice-banner-name-error' );
104                }
105                break;
106
107            default:
108                // Something went wrong; display error page
109                throw new ErrorPageError( 'noticetemplate', 'centralnotice-generic-error' );
110        }
111    }
112
113    /**
114     * Ensure that $this->banner is assigned.
115     *
116     * @param string $bannerName
117     * @throws ErrorPageError
118     */
119    private function ensureBanner( $bannerName ) {
120        if ( !Banner::isValidBannerName( $bannerName ) ) {
121            throw new ErrorPageError( 'noticetemplate', 'centralnotice-generic-error' );
122        }
123
124        if ( $this->banner ) {
125            return;
126        }
127
128        $this->banner = Banner::fromName( $this->bannerName );
129
130        if ( !$this->banner->exists() ) {
131            throw new ErrorPageError( 'centralnotice-banner-not-found-title',
132                'centralnotice-banner-not-found-contents' );
133        }
134    }
135
136    /**
137     * Process the 'banner list' form and display a new one.
138     */
139    private function showBannerList() {
140        $out = $this->getOutput();
141        $out->setPageTitleMsg( $this->msg( 'centralnotice-manage-templates' ) );
142        $out->addModules( 'ext.centralNotice.adminUi.bannerManager' );
143
144        // Process the form that we sent out
145        $formDescriptor = $this->generateBannerListForm( $this->bannerFilterString );
146        $htmlForm = new CentralNoticeHtmlForm( $formDescriptor, $this->getContext() );
147        $htmlForm->setSubmitCallback( [ $this, 'processBannerList' ] )
148            ->prepareForm();
149        $formResult = $htmlForm->trySubmit();
150
151        if ( $this->bannerFormRedirectRequired ) {
152            return;
153        }
154
155        // Re-generate the form in case they changed the filter string, archived something,
156        // deleted something, etc...
157        $formDescriptor = $this->generateBannerListForm( $this->bannerFilterString );
158        $htmlForm = new CentralNoticeHtmlForm( $formDescriptor, $this->getContext() );
159
160        $htmlForm->setId( 'cn-banner-manager' )
161            ->suppressDefaultSubmit()
162            ->setDisplayFormat( 'div' )
163            ->prepareForm()
164            ->displayForm( $formResult );
165    }
166
167    /**
168     * Generates the HTMLForm entities for the 'banner list' form.
169     *
170     * @param string $filter Filter to use for the banner list
171     *
172     * @return array of HTMLForm entities
173     */
174    private function generateBannerListForm( $filter = '' ) {
175        // --- Create the banner search form --- //
176
177        // Note: filter is normally set via JS, not form submission. But we
178        // leave the info in the submitted form, in any case.
179        $formDescriptor = [
180            'bannerNameFilter' => [
181                'section' => 'header/banner-search',
182                'class' => HTMLTextField::class,
183                'placeholder-message' => 'centralnotice-filter-template-prompt',
184                'filter-callback' => [ $this, 'sanitizeSearchTerms' ],
185                'default' => $filter,
186            ],
187            'filterApply' => [
188                'section' => 'header/banner-search',
189                'class' => HTMLButtonField::class,
190                'default' => $this->msg( 'centralnotice-filter-template-submit' )->text(),
191            ]
192        ];
193
194        // --- Create the management options --- //
195        $formDescriptor += [
196            'selectAllBanners' => [
197                'section' => 'header/banner-bulk-manage',
198                'class' => HTMLCheckField::class,
199                'disabled' => !$this->editable,
200            ],
201            /* TODO: Actually enable this feature
202            'archiveSelectedBanners' => array(
203                'section' => 'header/banner-bulk-manage',
204                'class' => HTMLButtonField::class,
205                'default' => 'Archive',
206                'disabled' => !$this->editable,
207            ),
208            */
209            'deleteSelectedBanners' => [
210                'section' => 'header/banner-bulk-manage',
211                'class' => HTMLButtonField::class,
212                'default' => $this->msg( 'centralnotice-remove' )->text(),
213                'disabled' => !$this->editable,
214            ],
215            'addNewBanner' => [
216                'section' => 'header/one-off',
217                'class' => HTMLButtonField::class,
218                'default' => $this->msg( 'centralnotice-add-template' )->text(),
219                'disabled' => !$this->editable,
220            ],
221            'newBannerName' => [
222                'section' => 'addBanner',
223                'class' => HTMLTextField::class,
224                'disabled' => !$this->editable,
225                'label' => $this->msg( 'centralnotice-banner-name' )->text(),
226            ],
227            'createFromTemplateCheckbox' => [
228                'section' => 'addBanner',
229                'class' => HTMLCheckField::class,
230                'label' => $this->msg( 'centralnotice-create-from-template-checkbox-label' )->text(),
231                'disabled' => !$this->editable || !$this->getTemplateBannerDropdownItems(),
232            ],
233            'newBannerTemplate' => [
234                'section' => 'addBanner',
235                'class' => HTMLSelectLimitField::class,
236                'cssclass' => 'banner-template-dropdown-hidden',
237                'disabled' => !$this->editable || !$this->getTemplateBannerDropdownItems(),
238                'options' => $this->getTemplateBannerDropdownItems()
239            ],
240            'newBannerEditSummary' => [
241                'section' => 'addBanner',
242                'class' => HTMLTextField::class,
243                'label-message' => 'centralnotice-change-summary-label',
244                'placeholder-message' => 'centralnotice-change-summary-action-prompt',
245                'disabled' => !$this->editable,
246                'filter-callback' => [ $this, 'truncateSummaryField' ]
247            ],
248            'removeBannerEditSummary' => [
249                'section' => 'removeBanner',
250                'class' => HTMLTextField::class,
251                'label-message' => 'centralnotice-change-summary-label',
252                'placeholder-message' => 'centralnotice-change-summary-action-prompt',
253                'disabled' => !$this->editable,
254                'filter-callback' => [ $this, 'truncateSummaryField' ]
255            ],
256            'action' => [
257                'type' => 'hidden',
258            ]
259        ];
260
261        // --- Add all the banners via the fancy pager object ---
262        $pager = new CNBannerPager(
263            $this,
264            'banner-list',
265            [
266                'applyTo' => [
267                    'section' => 'banner-list',
268                    'class' => HTMLCheckField::class,
269                    'cssclass' => 'cn-bannerlist-check-applyto',
270                ]
271            ],
272            [],
273            $filter,
274            $this->editable
275        );
276        $formDescriptor[ 'topPagerNav' ] = $pager->getNavigationBar();
277        $formDescriptor += $pager->getBody();
278        $formDescriptor[ 'bottomPagerNav' ] = $pager->getNavigationBar();
279
280        return $formDescriptor;
281    }
282
283    /**
284     * Callback function from the showBannerList() form that actually processes the
285     * response data.
286     *
287     * @param array $formData
288     *
289     * @return null|string|array
290     */
291    public function processBannerList( $formData ) {
292        $this->setFilterFromUrl();
293
294        if ( $formData[ 'action' ] && $this->editable ) {
295            switch ( strtolower( $formData[ 'action' ] ) ) {
296                case 'create':
297                    // Attempt to create a new banner and redirect; we validate here because it's
298                    // a hidden field and that doesn't work so well with the form
299                    if ( !Banner::isValidBannerName( $formData[ 'newBannerName' ] ) ) {
300                        return $this->msg( 'centralnotice-banner-name-error' )->parse();
301                    } else {
302                        $this->bannerName = $formData[ 'newBannerName' ];
303                    }
304
305                    if ( Banner::fromName( $this->bannerName )->exists() ) {
306                        return $this->msg( 'centralnotice-template-exists' )->parse();
307                    } else {
308                        if ( !empty( $formData['newBannerTemplate'] ) ) {
309                            $isError = false;
310                            try {
311                                $bannerTemplate = Banner::fromName( $formData['newBannerTemplate'] );
312                                // This will do data-load for the banner, confirming it actually exists in the DB
313                                // without calling Banner::exists()
314                                if ( !$bannerTemplate->isTemplate() ) {
315                                    $isError = true;
316                                    wfDebugLog(
317                                        'CentralNotice',
318                                        'Attempted to create a banner based on invalid template'
319                                    );
320                                }
321                            } catch ( BannerDataException $exception ) {
322                                $isError = true;
323                                wfDebugLog( 'CentralNotice', $exception->getMessage() );
324                            } finally {
325                                if ( $isError ) {
326                                    // We do not want to show the actual exception to the user here,
327                                    // since the message does not actually refer to the template being created,
328                                    // but to the template it is being created from
329                                    return $this->msg( 'centralnotice-banner-template-error' )->plain();
330                                }
331                            }
332                            $retval = Banner::addFromBannerTemplate(
333                                $this->bannerName,
334                                $this->getUser(),
335                                // @phan-suppress-next-line PhanPossiblyUndeclaredVariable
336                                $bannerTemplate,
337                                $formData['newBannerEditSummary']
338                            );
339                        } else {
340                            $retval = Banner::addBanner(
341                                $this->bannerName,
342                                "<!-- Empty banner -->",
343                                $this->getUser(),
344                                false,
345                                false,
346                                // Default values of a zillion parameters...
347                                [], [], null,
348                                $formData['newBannerEditSummary']
349                            );
350                        }
351
352                        if ( $retval ) {
353                            // Something failed; display error to user
354                            return $this->msg( $retval )->parse();
355                        } else {
356                            $this->getOutput()->redirect(
357                                SpecialPage::getTitleFor(
358                                    'CentralNoticeBanners', "edit/{$this->bannerName}"
359                                )->getFullURL()
360                            );
361                            $this->bannerFormRedirectRequired = true;
362                        }
363                    }
364                    break;
365
366                case 'archive':
367                    return 'Archiving not yet implemented!';
368
369                case 'remove':
370                    $summary = $formData['removeBannerEditSummary'];
371                    $failed = [];
372                    foreach ( $formData as $element => $value ) {
373                        $parts = explode( '-', $element, 2 );
374                        if ( ( $parts[0] === 'applyTo' ) && ( $value === true ) ) {
375                            try {
376
377                                Banner::removeBanner(
378                                    $parts[1], $this->getUser(), $summary );
379
380                            } catch ( Exception ) {
381                                $failed[] = $parts[1];
382                            }
383                        }
384                    }
385                    if ( $failed ) {
386                        return 'some banners were not deleted';
387
388                    } else {
389
390                        // Go back to the special banners page, including the
391                        // same filter that was already set in the URL.
392                        $this->getOutput()->redirect(
393                            SpecialPage::getTitleFor( 'CentralNoticeBanners' )
394                            ->getFullURL( $this->getFilterUrlParamAsArray() ) );
395
396                        $this->bannerFormRedirectRequired = true;
397                    }
398                    break;
399            }
400        } elseif ( $formData[ 'action' ] ) {
401            // Oh noes! The l33t hakorz are here...
402            return $this->msg( 'centralnotice-generic-error' )->parse();
403        }
404
405        return null;
406    }
407
408    /**
409     * Use a URL parameter to set the filter string for the banner list.
410     */
411    private function setFilterFromUrl() {
412        // This is the normal param on visible URLs.
413        $filterParam = $this->getRequest()->getVal( 'filter', null ) ??
414            // If the form was posted the filter parameter'll have a different name.
415            $this->getRequest()->getVal( 'wpbannerNameFilter', null );
416
417        // Clean, clean...
418        if ( $filterParam !== null ) {
419            $this->bannerFilterString = $this->sanitizeSearchTerms( $filterParam );
420        }
421    }
422
423    /**
424     * Return an array for use in constructing a URL query part with or without
425     * a filter parameter, as required.
426     *
427     * @return array
428     */
429    public function getFilterUrlParamAsArray() {
430        return $this->bannerFilterString ?
431            [ 'filter' => $this->bannerFilterString ] : [];
432    }
433
434    /**
435     * Returns array of navigation links to banner preview URL and
436     * edit link to the banner's wikipage if the user is allowed.
437     *
438     * FIXME Some of this code is repeated in BannerRenderer, but probably
439     * should be elsewhere.
440     *
441     * @return array
442     */
443    private function getBannerPreviewEditLinks() {
444        $linkRenderer = $this->getLinkRenderer();
445        $links = [
446            $linkRenderer->makeKnownLink(
447                SpecialPage::getTitleFor( 'Randompage' ),
448                $this->msg( 'centralnotice-live-preview' )->text(),
449                [ 'class' => 'cn-banner-list-element-label-text' ],
450                [
451                    'banner' => $this->bannerName,
452                    'uselang' => $this->bannerLanguagePreview,
453                    'force' => '1',
454                ]
455            )
456        ];
457
458        $bannerTitle = $this->banner->getTitle();
459        if ( $this->getUser()->isAllowed( 'editinterface' ) ) {
460            $links[] = $linkRenderer->makeLink(
461                $bannerTitle,
462                $this->msg( 'centralnotice-banner-edit-onwiki' )->text(),
463                [ 'class' => 'cn-banner-list-element-label-text' ],
464                [ 'action' => 'edit' ]
465            );
466        }
467        $links[] = $linkRenderer->makeLink(
468            $bannerTitle,
469            $this->msg( 'centralnotice-banner-history' )->text(),
470            [ 'class' => 'cn-banner-list-element-label-text' ],
471            [ 'action' => 'history' ]
472        );
473        return $links;
474    }
475
476    /**
477     * Display the banner editor and process edits
478     */
479    private function showBannerEditor() {
480        global $wgUseCdn;
481
482        $out = $this->getOutput();
483        $out->addModules( 'ext.centralNotice.adminUi.bannerEditor' );
484        $out->addJsConfigVars( [ 'CentralNoticeEditable' => $this->editable ] );
485
486        $this->addHelpLink(
487            '//meta.wikimedia.org/wiki/Special:MyLanguage/Help:CentralNotice',
488            true
489        );
490
491        $out->setPageTitle( $this->bannerName );
492        $out->setSubtitle(
493            $this->getLanguage()->pipeList( $this->getBannerPreviewEditLinks() )
494        );
495
496        // Generate the form
497        $formDescriptor = $this->generateBannerEditForm();
498
499        // Now begin form processing
500        $htmlForm = new CentralNoticeHtmlForm(
501            $formDescriptor, $this->getContext(), 'centralnotice' );
502        $htmlForm->setSubmitCallback( [ $this, 'processEditBanner' ] )
503            ->prepareForm();
504
505        $formResult = $htmlForm->tryAuthorizedSubmit();
506
507        if ( $this->bannerFormRedirectRequired ) {
508            return;
509        }
510
511        // Recreate the form because something could have changed
512        $formDescriptor = $this->generateBannerEditForm();
513
514        $htmlForm = new CentralNoticeHtmlForm(
515            $formDescriptor, $this->getContext(), 'centralnotice' );
516        $htmlForm->setSubmitCallback( [ $this, 'processEditBanner' ] )
517            ->setId( 'cn-banner-editor' );
518
519        // Push the form back to the user
520        $htmlForm->suppressDefaultSubmit()
521            ->setId( 'cn-banner-editor' )
522            ->setDisplayFormat( 'div' )
523            ->prepareForm()
524            ->displayForm( $formResult );
525
526        // Send banner name into page for access from JS
527        $out->addHTML( Html::element( 'span',
528            [
529                'id' => 'centralnotice-data-container',
530                'data-banner-name' => $this->bannerName
531            ]
532        ) );
533
534        if ( !$this->banner->isTemplate() ) {
535            // Controls to purge banner loader URLs from CDN caches for a given language.
536            if ( $wgUseCdn ) {
537                $out->addHTML( $this->generateCdnPurgeSection() );
538            }
539
540            $out->addHTML( Html::element( 'h2',
541                [ 'class' => 'cn-special-section' ],
542                $this->msg( 'centralnotice-campaigns-using-banner' )->text() ) );
543
544            $pager = new CNCampaignPager( $this, false, $this->banner->getId() );
545            $out->addModules( 'ext.centralNotice.adminUi.campaignPager' );
546            $popts = ParserOptions::newFromContext( $this->getContext() );
547            $out->addParserOutputContent( $pager->getBodyOutput(), $popts );
548            $out->addHTML( $pager->getNavigationBar() );
549        }
550    }
551
552    private function generateBannerEditForm(): array {
553        global $wgCentralNoticeBannerMixins, $wgNoticeUseTranslateExtension, $wgLanguageCode;
554
555        $languages = $this->languageNameUtils
556            ->getLanguageNames( $this->getLanguage()->getCode() );
557        array_walk(
558            $languages,
559            static function ( &$val, $index ) {
560                $val = "$index - $val";
561            }
562        );
563        $languages = array_flip( $languages );
564
565        $bannerSettings = Banner::getBannerSettings( $this->bannerName, true );
566
567        $formDescriptor = [];
568
569        /* --- Use banner as template --- */
570        $campaignNames = $this->banner->getCampaignNames();
571        $formDescriptor['banner-is-template'] = [
572            'section' => 'banner-template',
573            'type' => 'check',
574            'disabled' => !$this->editable || $campaignNames,
575            'label-message' => 'centralnotice-banner-is-template',
576            'default' => $this->banner->isTemplate()
577        ];
578
579        if ( $campaignNames ) {
580            $formDescriptor[ 'banner-assigned-to-campaign' ] = [
581                'section' => 'banner-template',
582                'class' => HTMLInfoField::class,
583                'label-message' => 'centralnotice-messages-banner-in-campaign',
584                'default' => implode( ', ', $campaignNames )
585            ];
586        }
587
588        /* --- Banner Settings --- */
589        $formDescriptor['banner-class'] = [
590            'section' => 'settings',
591            'type' => 'selectorother',
592            'disabled' => !$this->editable,
593            'label-message' => 'centralnotice-banner-class',
594            'help-message' => 'centralnotice-banner-class-desc',
595            'options' => Banner::getAllUsedCategories(),
596            'size' => 30,
597            'maxlength' => 255,
598            'default' => $this->banner->getCategory(),
599        ];
600
601        $selected = [];
602        if ( $bannerSettings[ 'anon' ] === 1 ) {
603            $selected[] = 'anonymous';
604        }
605        if ( $bannerSettings[ 'account' ] === 1 ) {
606            $selected[] = 'registered';
607        }
608        $formDescriptor[ 'display-to' ] = [
609            'section' => 'settings',
610            'type' => 'multiselect',
611            'disabled' => !$this->editable,
612            'label-message' => 'centralnotice-banner-display',
613            'options' => [
614                $this->msg( 'centralnotice-banner-logged-in' )->escaped() => 'registered',
615                $this->msg( 'centralnotice-banner-anonymous' )->escaped() => 'anonymous'
616            ],
617            'default' => $selected,
618            'cssclass' => 'separate-form-element',
619        ];
620
621        $assignedDevices = array_values(
622            CNDeviceTarget::getDevicesAssociatedWithBanner( $this->banner->getId() )
623        );
624        $availableDevices = [];
625        foreach ( CNDeviceTarget::getAvailableDevices() as $k => $value ) {
626            $header = htmlspecialchars( $value[ 'header' ] );
627
628            // Messages keys via CNDeviceTarget and CNDatabasePatcher:
629            // centralnotice-devicetype-desktop, centralnotice-devicetype-android,
630            // centralnotice-devicetype-ipad, centralnotice-devicetype-iphone,
631            // centralnotice-devicetype-unknown
632            $label = $this->getOutput()->parseInlineAsInterface( $value[ 'label' ] );
633            $availableDevices[ "($header$label" ] = $header;
634        }
635        $formDescriptor[ 'device-classes' ] = [
636            'section' => 'settings',
637            'type' => 'multiselect',
638            'disabled' => !$this->editable,
639            'label-message' => 'centralnotice-banner-display-on',
640            'options' => $availableDevices,
641            'default' => $assignedDevices,
642            'cssclass' => 'separate-form-element',
643        ];
644
645        // TODO Remove. See T225831.
646        $mixinNames = array_keys( $wgCentralNoticeBannerMixins );
647        $availableMixins = array_combine( $mixinNames, $mixinNames );
648        $selectedMixins = array_keys( $this->banner->getMixins() );
649        $formDescriptor['mixins'] = [
650            'section' => 'settings',
651            'type' => 'multiselect',
652            'disabled' => !$this->editable,
653            'label-message' => 'centralnotice-banner-mixins',
654            'help-message' => 'centralnotice-banner-mixins-help',
655            'cssclass' => 'separate-form-element',
656            'options' => $availableMixins,
657            'default' => $selectedMixins,
658        ];
659
660        /* --- Translatable Messages Section --- */
661        $messages = $this->banner->getMessageFieldsFromCache();
662
663        $linkRenderer = $this->getLinkRenderer();
664        if ( $messages ) {
665            // Only show this part of the form if messages exist
666
667            $formDescriptor[ 'translate-language' ] = [
668                'section' => 'banner-messages',
669                'class' => LanguageSelectHeaderElement::class,
670                'label-message' => 'centralnotice-language',
671                'options' => $languages,
672                'default' => $this->bannerLanguagePreview,
673                'disabled' => !$this->editable,
674                'cssclass' => 'separate-form-element',
675            ];
676
677            $messageReadOnly = false;
678            if ( $wgNoticeUseTranslateExtension &&
679                ( $this->bannerLanguagePreview !== $wgLanguageCode )
680            ) {
681                $messageReadOnly = true;
682            }
683            foreach ( $messages as $messageName => $count ) {
684                if ( $wgNoticeUseTranslateExtension ) {
685                    // Create per message link to the translate extension
686                    $title = SpecialPage::getTitleFor( 'Translate' );
687                    $label = Html::rawElement( 'td', [],
688                        $linkRenderer->makeLink(
689                            $title,
690                            $messageName,
691                            [],
692                            [
693                                'group' => BannerMessageGroup::getTranslateGroupName(
694                                    $this->banner->getName()
695                                ),
696                                'task' => 'view'
697                            ]
698                        )
699                    );
700                } else {
701                    $label = htmlspecialchars( $messageName );
702                }
703
704                $formDescriptor[ "message-$messageName" ] = [
705                    'section' => 'banner-messages',
706                    'class' => HTMLCentralNoticeBannerMessage::class,
707                    'label-raw' => $label,
708                    'banner' => $this->bannerName,
709                    'message' => $messageName,
710                    'language' => $this->bannerLanguagePreview,
711                    'disabled' => $messageReadOnly || !$this->editable,
712                    'cssclass' => 'separate-form-element',
713                ];
714
715                if ( !$this->editable || $messageReadOnly ) {
716                    $formDescriptor[ "message-$messageName" ][ 'readonly' ] = true;
717                }
718            }
719
720            if ( $wgNoticeUseTranslateExtension && $this->editable ) {
721                $formDescriptor[ 'priority-langs' ] = [
722                    'section' => 'banner-messages',
723                    'class' => HTMLLargeMultiSelectField::class,
724                    'disabled' => !$this->editable,
725                    'label-message' => 'centralnotice-prioritylangs',
726                    'options' => $languages,
727                    'default' => $bannerSettings[ 'prioritylangs' ],
728                    'help-message' => 'centralnotice-prioritylangs-explain',
729                    'cssclass' => 'separate-form-element cn-multiselect',
730                ];
731            }
732
733            if ( $wgNoticeUseTranslateExtension && BannerMessageGroup::isUsingGroupReview() ) {
734                $readyStateLangs = BannerMessageGroup::getLanguagesInState(
735                    $this->bannerName,
736                    'ready'
737                );
738
739                if ( $readyStateLangs ) {
740                    $formDescriptor[ 'pending-languages' ] = [
741                        'section' => 'banner-messages',
742                        'class' => HTMLInfoField::class,
743                        'disabled' => !$this->editable,
744                        'label-message' => 'centralnotice-messages-pending-approval',
745                        'default' => implode( ', ', $readyStateLangs ),
746                        'cssclass' => 'separate-form-element',
747                    ];
748                }
749            }
750        }
751
752        /* -- The banner editor -- */
753        $formDescriptor[ 'banner-magic-words' ] = [
754            'section' => 'edit-template',
755            'class' => HTMLInfoField::class,
756            'default' => Html::rawElement(
757                'div',
758                [ 'class' => 'separate-form-element' ],
759                $this->msg( 'centralnotice-edit-template-summary' )->parse() ),
760            'rawrow' => true,
761        ];
762
763        $renderer = new BannerRenderer( $this->getContext(), $this->banner );
764        $magicWords = $renderer->getMagicWords();
765        foreach ( $magicWords as &$word ) {
766            $word = wfEscapeWikiText( '{{{' . $word . '}}}' );
767        }
768        $formDescriptor[ 'banner-mixin-words' ] = [
769            'section' => 'edit-template',
770            'type' => 'info',
771            'default' => $this->msg(
772                    'centralnotice-edit-template-magicwords',
773                    $this->getLanguage()->listToText( $magicWords )
774                )->parse(),
775            'rawrow' => true,
776        ];
777
778        if ( $this->editable ) {
779            $buttons = [];
780            // TODO: Fix this gawdawful method of inserting the close button
781            $buttons[] =
782                '<a href="#" onclick="mw.centralNotice.adminUi.bannerEditor.insertButton(\'close\');' .
783                    'return false;">' . $this->msg( 'centralnotice-close-button' )->escaped() . '</a>';
784            $formDescriptor[ 'banner-insert-button' ] = [
785                'section' => 'edit-template',
786                'class' => HTMLInfoField::class,
787                'rawrow' => true,
788                'default' => Html::rawElement(
789                    'div',
790                    [ 'class' => 'banner-editing-top-hint separate-form-element' ],
791                    $this->msg( 'centralnotice-insert' )->
792                        rawParams( $this->getLanguage()->commaList( $buttons ) )->
793                        escaped() ),
794            ];
795        }
796
797        $formDescriptor[ 'banner-body' ] = [
798            'section' => 'edit-template',
799            'type' => 'textarea',
800            'readonly' => !$this->editable,
801            'disabled' => !$this->editable,
802            'hidelabel' => true,
803            'placeholder' => '<!-- blank banner -->',
804            'default' => $this->banner->getBodyContent(),
805            'cssclass' => 'separate-form-element'
806        ];
807
808        $links = [];
809        foreach ( $this->banner->getIncludedTemplates() as $titleObj ) {
810            $links[] = $linkRenderer->makeLink( $titleObj );
811        }
812        if ( $links ) {
813            $formDescriptor[ 'links' ] = [
814                'section' => 'edit-template',
815                'type' => 'info',
816                'label-message' => 'centralnotice-templates-included',
817                'default' => implode( '<br />', $links ),
818                'raw' => true
819            ];
820        }
821
822        /* --- Form bottom options --- */
823        $formDescriptor[ 'summary' ] = [
824            'section' => 'form-actions',
825            'class' => HTMLTextField::class,
826            'label-message' => 'centralnotice-change-summary-label',
827            'placeholder' => $this->msg( 'centralnotice-change-summary-prompt' ),
828            'disabled' => !$this->editable,
829            'filter-callback' => [ $this, 'truncateSummaryField' ]
830        ];
831
832        $formDescriptor[ 'save-button' ] = [
833            'section' => 'form-actions',
834            'class' => HTMLSubmitField::class,
835            'default' => $this->msg( 'centralnotice-save-banner' )->text(),
836            'disabled' => !$this->editable,
837            'cssclass' => 'cn-formbutton',
838            'hidelabel' => true,
839        ];
840
841        $formDescriptor[ 'clone-button' ] = [
842            'section' => 'form-actions',
843            'class' => HTMLButtonField::class,
844            'default' => $this->msg( 'centralnotice-clone' )->text(),
845            'disabled' => !$this->editable,
846            'cssclass' => 'cn-formbutton',
847            'hidelabel' => true,
848        ];
849
850        /* TODO: Add this back in when we can actually support it
851        $formDescriptor[ 'archive-button' ] = array(
852            'section' => 'form-actions',
853            'class' => HTMLButtonField::class,
854            'default' => $this->msg( 'centralnotice-archive-banner' )->text(),
855            'disabled' => !$this->editable,
856            'cssclass' => 'cn-formbutton',
857            'hidelabel' => true,
858        );
859        */
860
861        $formDescriptor[ 'delete-button' ] = [
862            'section' => 'form-actions',
863            'class' => HTMLButtonField::class,
864            'default' => $this->msg( 'centralnotice-delete-banner' )->text(),
865            'disabled' => !$this->editable,
866            'cssclass' => 'cn-formbutton',
867            'hidelabel' => true,
868        ];
869
870        /* --- Hidden fields and such --- */
871        $formDescriptor[ 'cloneName' ] = [
872            'section' => 'clone-banner',
873            'type' => 'text',
874            'disabled' => !$this->editable,
875            'label-message' => 'centralnotice-clone-name',
876        ];
877
878        $formDescriptor[ 'cloneEditSummary' ] = [
879            'section' => 'clone-banner',
880            'class' => HTMLTextField::class,
881            'label-message' => 'centralnotice-change-summary-label',
882            'placeholder' => $this->msg( 'centralnotice-change-summary-action-prompt' ),
883            'disabled' => !$this->editable,
884            'filter-callback' => [ $this, 'truncateSummaryField' ]
885        ];
886
887        $formDescriptor[ 'deleteEditSummary' ] = [
888            'section' => 'delete-banner',
889            'class' => HTMLTextField::class,
890            'label-message' => 'centralnotice-change-summary-label',
891            'placeholder-message' => 'centralnotice-change-summary-action-prompt',
892            'disabled' => !$this->editable,
893            'filter-callback' => [ $this, 'truncateSummaryField' ]
894        ];
895
896        $formDescriptor[ 'action' ] = [
897            'section' => 'form-actions',
898            'type' => 'hidden',
899            // The default is to save for historical reasons.  TODO: review.
900            'default' => 'save',
901        ];
902
903        return $formDescriptor;
904    }
905
906    /**
907     * Generate a string with the HTML for controls to request a front-end (CDN) cache
908     * purge of banner content for a language.
909     *
910     * @return string
911     */
912    private function generateCdnPurgeSection() {
913        $purgeControls = Html::element( 'h2',
914            [ 'class' => 'cn-special-section' ],
915            $this->msg( 'centralnotice-banner-cdn-controls' )->text() );
916
917        $purgeControls .= Html::openElement( 'fieldset', [ 'class' => 'prefsection' ] );
918
919        $purgeControls .= Html::openElement( 'label' );
920        $purgeControls .=
921            $this->msg( 'centralnotice-banner-cdn-label' )->escaped() . ' ';
922
923        $disabledAttr = $this->editable ? [] : [ 'disabled' => true ];
924
925        $purgeControls .= Html::openElement( 'select',
926            $disabledAttr + [ 'id' => 'cn-cdn-cache-language' ] );
927
928        // Retrieve the list of languages in user's language
929        // FIXME Similar code in SpecialBannerAllocation::execute(), maybe switch
930        // to language selector?
931        $languages = $this->languageNameUtils
932            ->getLanguageNames( $this->getLanguage()->getCode() );
933        ksort( $languages );
934
935        foreach ( $languages as $code => $name ) {
936            $purgeControls .= Html::element(
937                'option',
938                [ 'value' => $code ],
939                $this->msg( 'centralnotice-language-listing', $code, $name )->text()
940            );
941        }
942
943        $purgeControls .= Html::closeElement( 'select' );
944        $purgeControls .= Html::closeElement( 'label' );
945
946        $purgeControls .= ' ' . Html::element( 'button',
947            $disabledAttr + [ 'id' => 'cn-cdn-cache-purge' ],
948            $this->msg( 'centralnotice-banner-cdn-button' )->text()
949        );
950
951        $purgeControls .= Html::element(
952            'div',
953            [ 'class' => 'htmlform-help' ],
954            $this->msg( 'centralnotice-banner-cdn-help' )->text()
955        );
956
957        $purgeControls .= Html::closeElement( 'fieldset' );
958
959        return $purgeControls;
960    }
961
962    /**
963     * @param array $formData
964     * @return string|null
965     */
966    public function processEditBanner( $formData ) {
967        // First things first! Figure out what the heck we're actually doing!
968        switch ( $formData[ 'action' ] ) {
969            case 'update-lang':
970                $newLanguage = $formData[ 'translate-language' ];
971                $this->setCNSessionVar( 'bannerLanguagePreview', $newLanguage );
972                $this->bannerLanguagePreview = $newLanguage;
973                break;
974
975            case 'delete':
976                if ( !$this->editable ) {
977                    return null;
978                }
979                if ( !Banner::isValidBannerName( $this->bannerName ) ) {
980                    throw new ErrorPageError( 'noticetemplate', 'centralnotice-banner-name-error' );
981                }
982                try {
983                    Banner::removeBanner(
984                        $this->bannerName, $this->getUser(),
985                        $formData[ 'deleteEditSummary' ] );
986
987                    $this->getOutput()->redirect( $this->getPageTitle( '' )->getCanonicalURL() );
988                    $this->bannerFormRedirectRequired = true;
989                } catch ( Exception $ex ) {
990                    return htmlspecialchars( $ex->getMessage() ) . " <br /> " .
991                        $this->msg( 'centralnotice-template-still-bound', $this->bannerName )->parse();
992                }
993                break;
994
995            case 'archive':
996                if ( !$this->editable ) {
997                    return null;
998                }
999                return 'Archiving currently does not work';
1000
1001            case 'clone':
1002                if ( !$this->editable ) {
1003                    return null;
1004                }
1005                $newBannerName = $formData[ 'cloneName' ];
1006                if ( !Banner::isValidBannerName( $newBannerName ) ) {
1007                    throw new ErrorPageError( 'noticetemplate', 'centralnotice-banner-name-error' );
1008                }
1009
1010                $this->ensureBanner( $this->bannerName );
1011                try {
1012                    $this->banner->cloneBanner(
1013                        $newBannerName,
1014                        $this->getUser(),
1015                        $formData['cloneEditSummary']
1016                    );
1017                } catch ( BannerExistenceException ) {
1018                    return $this->msg( 'centralnotice-template-exists', $this->bannerName )->parse();
1019                }
1020
1021                $this->getOutput()->redirect(
1022                    $this->getPageTitle( "Edit/$newBannerName" )->getCanonicalURL()
1023                );
1024                $this->bannerFormRedirectRequired = true;
1025                break;
1026
1027            case 'save':
1028                if ( !$this->editable ) {
1029                    return null;
1030                }
1031
1032                $ret = $this->processSaveBannerAction( $formData );
1033
1034                // Clear the edit summary field in the request so the form
1035                // doesn't re-display the same value. Note: this is a hack :(
1036                $this->getRequest()->setVal( 'wpsummary', '' );
1037
1038                return $ret;
1039
1040            default:
1041                // Nothing was requested, so do nothing
1042                break;
1043        }
1044    }
1045
1046    /**
1047     * @param array $formData
1048     * @return null
1049     */
1050    private function processSaveBannerAction( $formData ) {
1051        global $wgNoticeUseTranslateExtension, $wgLanguageCode;
1052
1053        $this->ensureBanner( $this->bannerName );
1054        $summary = $formData['summary'];
1055
1056        /* --- Update the translations --- */
1057        // But only if we aren't using translate or if the preview language is the content language
1058        if ( !$wgNoticeUseTranslateExtension || $this->bannerLanguagePreview === $wgLanguageCode ) {
1059            foreach ( $formData as $key => $value ) {
1060                if ( str_starts_with( $key, 'message-' ) ) {
1061                    $messageName = substr( $key, strlen( 'message-' ) );
1062                    $bannerMessage = $this->banner->getMessageField( $messageName );
1063
1064                    $bannerMessage->update(
1065                        $value, $this->bannerLanguagePreview, $this->getUser(),
1066                        $summary );
1067                }
1068            }
1069        }
1070
1071        /* --- Banner settings --- */
1072        if ( array_key_exists( 'priority-langs', $formData ) ) {
1073            $prioLang = $formData[ 'priority-langs' ];
1074            if ( !is_array( $prioLang ) ) {
1075                $prioLang = [ $prioLang ];
1076            }
1077        } else {
1078            $prioLang = [];
1079        }
1080
1081        $this->banner->setAllocation(
1082            in_array( 'anonymous', $formData[ 'display-to' ] ),
1083            in_array( 'registered', $formData[ 'display-to' ] )
1084        );
1085        $this->banner->setCategory( $formData[ 'banner-class' ] );
1086        $this->banner->setDevices( $formData[ 'device-classes' ] );
1087        $this->banner->setPriorityLanguages( $prioLang );
1088        $this->banner->setBodyContent( $formData[ 'banner-body' ] );
1089
1090        $this->banner->setMixins( $formData['mixins'] );
1091        $this->banner->setIsTemplate( (bool)$formData['banner-is-template'] );
1092
1093        $this->banner->save( $this->getUser(), $summary );
1094
1095        // Deferred update to purge CDN caches for banner content (for user's lang)
1096        DeferredUpdates::addUpdate(
1097            new CdnCacheUpdateBannerLoader( $this->getLanguage()->getCode(), $this->banner ),
1098            DeferredUpdates::POSTSEND
1099        );
1100
1101        return null;
1102    }
1103
1104    /**
1105     * Returns all template banner names for dropdown
1106     *
1107     * @return array
1108     */
1109    private function getTemplateBannerDropdownItems() {
1110        if ( $this->templateBannerNames === null ) {
1111            $dbr = CNDatabase::getReplicaDb();
1112            $this->templateBannerNames = [];
1113
1114            $res = $dbr->newSelectQueryBuilder()
1115                ->select( [ 'tmp_name' ] )
1116                ->from( 'cn_templates' )
1117                ->where( [ 'tmp_is_template' => true ] )
1118                ->caller( __METHOD__ )
1119                ->fetchResultSet();
1120
1121            foreach ( $res as $row ) {
1122                // name of the banner as a key for HTMLSelectField
1123                $this->templateBannerNames[$row->tmp_name] = $row->tmp_name;
1124            }
1125        }
1126
1127        return $this->templateBannerNames;
1128    }
1129}