Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.59% covered (danger)
0.59%
7 / 1192
6.38% covered (danger)
6.38%
3 / 47
CRAP
0.00% covered (danger)
0.00%
0 / 1
CentralNotice
0.59% covered (danger)
0.59%
7 / 1192
6.38% covered (danger)
6.38%
3 / 47
36789.61
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
 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 / 29
0.00% covered (danger)
0.00%
0 / 1
90
 outputEnclosingDivStartTag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 outputEnclosingDivEndTag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 outputListOfNotices
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 handleNoticePostFromList
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
156
 dateSelector
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 timeSelectorTd
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 timeSelector
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 campaignTypeSelector
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
132
 prioritySelector
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 createSelector
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 addNoticeForm
0.00% covered (danger)
0.00%
0 / 81
0.00% covered (danger)
0.00%
0 / 1
12
 handleAddCampaignPost
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
72
 getDateTime
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 outputNoticeDetail
0.00% covered (danger)
0.00%
0 / 78
0.00% covered (danger)
0.00%
0 / 1
272
 handleNoticeDetailPost
0.00% covered (danger)
0.00%
0 / 137
0.00% covered (danger)
0.00%
0 / 1
1122
 displayCampaignWarnings
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 noticeDetailForm
0.00% covered (danger)
0.00%
0 / 215
0.00% covered (danger)
0.00%
0 / 1
132
 makeNoticeMixinControlName
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 assignedTemplatesForm
0.00% covered (danger)
0.00%
0 / 108
0.00% covered (danger)
0.00%
0 / 1
90
 weightDropdown
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 bucketDropdown
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 numBucketsDropdown
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 addTemplatesForm
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
6
 languageMultiSelector
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 projectMultiSelector
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 dropdownList
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 makeSummaryField
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 getSummaryFromRequest
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 paddedRange
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 showError
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 geoMultiSelectorTree
0.00% covered (danger)
0.00%
0 / 109
0.00% covered (danger)
0.00%
0 / 1
72
 sanitizeSearchTerms
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 truncateSummaryField
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getAssociatedNavigationLinks
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getShortDescription
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getCNSessionVar
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCNSessionVar
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 listProjects
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 listCountriesRegions
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 listLanguages
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 makeShortList
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 listToArray
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 outputHeader
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3use MediaWiki\Exception\ErrorPageError;
4use MediaWiki\Exception\ReadOnlyError;
5use MediaWiki\Html\Html;
6use MediaWiki\Json\FormatJson;
7use MediaWiki\MainConfigNames;
8use MediaWiki\MediaWikiServices;
9use MediaWiki\Parser\ParserOptions;
10use MediaWiki\Parser\Sanitizer;
11use MediaWiki\Request\WebRequest;
12use MediaWiki\SpecialPage\SpecialPage;
13use MediaWiki\SpecialPage\UnlistedSpecialPage;
14use MediaWiki\Xml\XmlSelect;
15
16class CentralNotice extends UnlistedSpecialPage {
17
18    // TODO review usage of Xml class and unnecessary openElement() and closeElement()
19    // methods.
20
21    // Note: These values are not arbitrary. Higher priority is indicated by a
22    // higher value.
23    public const LOW_PRIORITY = 0;
24    public const NORMAL_PRIORITY = 1;
25    public const HIGH_PRIORITY = 2;
26    public const EMERGENCY_PRIORITY = 3;
27
28    // String to use in drop-down to indicate no campaign type (repesented as null in DB)
29    private const EMPTY_CAMPAIGN_TYPE_OPTION = 'empty-campaign-type-option';
30
31    // When displaying a long list, display the complement "all except ~LIST"
32    // past a threshold, given as a proportion of the "all" list length.
33    private const LIST_COMPLEMENT_THRESHOLD = 0.75;
34
35    /** @var bool|null */
36    public $editable;
37    /** @var bool|null */
38    public $centralNoticeError;
39
40    /**
41     * @var Campaign
42     */
43    private $campaign;
44    /** @var array */
45    private $campaignWarnings = [];
46
47    public function __construct( string $name = 'CentralNotice' ) {
48        // Register special page
49        parent::__construct( $name );
50    }
51
52    /** @inheritDoc */
53    public function doesWrites() {
54        return true;
55    }
56
57    /**
58     * Handle different types of page requests
59     * @param string|null $sub
60     */
61    public function execute( $sub ) {
62        // Begin output
63        $this->setHeaders();
64        $this->outputHeader();
65
66        $out = $this->getOutput();
67        $request = $this->getRequest();
68
69        $this->addHelpLink(
70            '//meta.wikimedia.org/wiki/Special:MyLanguage/Help:CentralNotice',
71            true
72        );
73
74        // Check permissions
75        $this->editable = $this->getUser()->isAllowed( 'centralnotice-admin' );
76        $this->getOutput()->addJsConfigVars( [ 'CentralNoticeEditable' => $this->editable ] );
77
78        // Initialize error variable
79        $this->centralNoticeError = false;
80
81        $subaction = $request->getVal( 'subaction' );
82
83        // Switch to campaign detail interface if requested.
84        // This will also handle post submissions from the detail interface.
85        if ( $subaction === 'noticeDetail' ) {
86            $notice = $request->getVal( 'notice' );
87            $this->outputNoticeDetail( $notice );
88            return;
89        }
90
91        // Handle form submissions from "Manage campaigns" or "Add a campaign" interface
92        if ( $this->editable && $request->wasPosted() ) {
93            if ( MediaWikiServices::getInstance()->getReadOnlyMode()->isReadOnly()
94                || CNDatabase::getPrimaryDb()->isReadOnly()
95            ) {
96                throw new ReadOnlyError();
97            }
98
99            // Check authentication token
100            if ( $this->getUser()->matchEditToken( $request->getVal( 'authtoken' ) ) ) {
101                // Handle adding a campaign or changing existing campaign settings
102                // via the list interface. In either case, we'll retirect to the
103                // list view.
104                if ( $subaction === 'addCampaign' ) {
105                    $this->handleAddCampaignPost();
106                } else {
107                    $this->handleNoticePostFromList();
108                }
109
110                // If there were no errors, reload the page to prevent duplicate form submission
111                if ( !$this->centralNoticeError ) {
112                    $out->redirect( $this->getPageTitle()->getLocalURL() );
113                    return;
114                }
115            } else {
116                $this->showError( 'sessionfailure' );
117            }
118        }
119
120        $this->outputListOfNotices( $subaction === 'showArchived' );
121    }
122
123    /**
124     * Output the start tag for the enclosing div we use on all subactions
125     */
126    private function outputEnclosingDivStartTag() {
127        $this->getOutput()->addHTML( Html::openElement( 'div', [ 'id' => 'preferences' ] ) );
128    }
129
130    /**
131     * Output the end tag for the enclosing div we use on all subactions
132     */
133    private function outputEnclosingDivEndTag() {
134        $this->getOutput()->addHTML( Html::closeElement( 'div' ) );
135    }
136
137    /**
138     * Send the list of notices (campaigns) to output and, if appropriate,
139     * the "Add campaign" form.
140     * @param bool|null $showArchived Set true to only show archived campaigns,
141     *      false to only show unarchived campaigns
142     */
143    private function outputListOfNotices( $showArchived = null ) {
144        $this->outputEnclosingDivStartTag();
145
146        $out = $this->getOutput();
147        $out->addModules( 'ext.centralNotice.adminUi' );
148
149        $out->addHTML( Html::element( 'h2',
150            [ 'class' => 'cn-special-section' ],
151            $this->msg(
152                $showArchived ? 'centralnotice-archived-campaigns' : 'centralnotice-manage'
153            )->text()
154        ) );
155
156        $out->addModules( 'ext.centralNotice.adminUi.campaignPager' );
157
158        if ( $showArchived === false ) {
159            $out->addHTML(
160                $this->getLinkRenderer()->makeLink(
161                    SpecialPage::getTitleFor( 'CentralNotice' ),
162                    $this->msg( 'centralnotice-archive-show' )->text(),
163                    [],
164                    [ 'subaction' => 'showArchived' ]
165                )
166            );
167        }
168
169        $pager = new CNCampaignPager( $this, $this->editable, null, $showArchived );
170        $popts = ParserOptions::newFromContext( $this->getContext() );
171        $out->addParserOutputContent( $pager->getBodyOutput(), $popts );
172        $out->addHTML( $pager->getNavigationBar() );
173
174        // If the user has edit rights, show a form for adding a campaign
175        if ( $this->editable && $showArchived !== true ) {
176            $this->addNoticeForm();
177        }
178
179        $this->outputEnclosingDivEndTag();
180    }
181
182    private function handleNoticePostFromList() {
183        $request = $this->getRequest();
184        $changes = json_decode( $request->getText( 'changes' ), true );
185        $summary = $this->getSummaryFromRequest( $request );
186
187        // Make the changes requested
188        foreach ( $changes as $campaignName => $campaignChanges ) {
189            $initialSettings = Campaign::getCampaignSettings( $campaignName, true );
190
191            // Next campaign if somehow this one doesn't exist
192            if ( !$initialSettings ) {
193                wfLogWarning( 'Change requested for non-existent campaign ' .
194                    $campaignName );
195
196                continue;
197            }
198
199            // Set values as per $changes
200            if ( isset( $campaignChanges['archived'] ) ) {
201                Campaign::setBooleanCampaignSetting( $campaignName, 'archived',
202                    $campaignChanges['archived'] );
203            }
204
205            if ( isset( $campaignChanges['locked'] ) ) {
206                Campaign::setBooleanCampaignSetting( $campaignName, 'locked',
207                    $campaignChanges['locked'] );
208            }
209
210            if ( isset( $campaignChanges['enabled'] ) ) {
211                Campaign::setBooleanCampaignSetting( $campaignName, 'enabled',
212                    $campaignChanges['enabled'] );
213            }
214
215            if ( isset( $campaignChanges['priority'] ) ) {
216                Campaign::setNumericCampaignSetting(
217                    $campaignName,
218                    'preferred',
219                    intval( $campaignChanges['priority'] ),
220                    self::EMERGENCY_PRIORITY,
221                    self::LOW_PRIORITY
222                );
223            }
224
225            if ( isset( $campaignChanges['campaign_type'] ) ) {
226                $type = $campaignChanges['campaign_type'];
227                $type = $type === self::EMPTY_CAMPAIGN_TYPE_OPTION ? null : $type;
228
229                // Sanity check: does the requested campaign type exist?
230                if ( $type && !CampaignType::getById( $type ) ) {
231                    $this->showError( 'centralnotice-non-existent-campaign-type-error' );
232                    return;
233                }
234
235                Campaign::setType( $campaignName, $type );
236            }
237
238            // Log any differences in settings
239            $newSettings = Campaign::getCampaignSettings( $campaignName, true );
240            $diffs = array_diff_assoc( $initialSettings, $newSettings );
241
242            if ( $diffs ) {
243                $campaignId = Campaign::getNoticeId( $campaignName, CNDatabase::getPrimaryDb() );
244                Campaign::processAfterCampaignChange(
245                    'modified',
246                    $campaignId,
247                    $campaignName,
248                    $this->getUser(),
249                    $initialSettings,
250                    $newSettings,
251                    $summary
252                );
253            }
254        }
255    }
256
257    /**
258     * Render a field suitable for jquery.ui datepicker
259     * @param string $name
260     * @param bool $editable
261     * @param string|null $timestamp
262     * @return string
263     */
264    protected function dateSelector( $name, $editable, $timestamp = null ) {
265        if ( $editable ) {
266            // Normalize timestamp format. If no timestamp is passed, default to now. If -1 is
267            // passed, set no defaults.
268            if ( $timestamp === -1 ) {
269                $ts = '';
270            } else {
271                $ts = wfTimestamp( TS_MW, $timestamp );
272            }
273
274            $out = Html::element( 'input',
275                [
276                    'id' => "{$name}Date",
277                    'name' => "{$name}Date",
278                    'type' => 'text',
279                    'class' => 'centralnotice-datepicker centralnotice-datepicker-limit_one_year',
280                ]
281            );
282            $out .= Html::element( 'input',
283                [
284                    'id' => "{$name}Date_timestamp",
285                    'name' => "{$name}Date_timestamp",
286                    'type' => 'hidden',
287                    'value' => $ts,
288                ]
289            );
290            return $out;
291        } else {
292            return htmlspecialchars( $this->getLanguage()->date( $timestamp ) );
293        }
294    }
295
296    /**
297     * @param string $prefix
298     * @param bool $editable
299     * @param string|null $timestamp
300     * @return string
301     */
302    private function timeSelectorTd( $prefix, $editable, $timestamp = null ) {
303        return Html::rawElement(
304            'td',
305            [
306                // Time is left-to-right in all languages
307                'dir' => 'ltr',
308                'class' => 'cn-timepicker',
309            ],
310            $this->timeSelector( $prefix, $editable, $timestamp )
311        );
312    }
313
314    /**
315     * @param string $prefix
316     * @param bool $editable
317     * @param string|null $timestamp
318     * @return string
319     */
320    private function timeSelector( $prefix, $editable, $timestamp = null ) {
321        if ( $editable ) {
322            $minutes = $this->paddedRange( 0, 59 );
323            $hours = $this->paddedRange( 0, 23 );
324
325            // Normalize timestamp format...
326            $ts = wfTimestamp( TS_MW, $timestamp );
327
328            $fields = [
329                [ "hour", "centralnotice-hours", $hours, substr( $ts, 8, 2 ) ],
330                [ "min", "centralnotice-min", $minutes, substr( $ts, 10, 2 ) ],
331            ];
332
333            return $this->createSelector( $prefix, $fields );
334        } else {
335            return htmlspecialchars( $this->getLanguage()->time( $timestamp ) );
336        }
337    }
338
339    /**
340     * @param bool $editable
341     * @param string|null $selectedTypeId
342     * @param string|null $index The name of the campaign (used when selector is included
343     *   in a list of campaigns by CNCampaignPager).
344     * @return string
345     */
346    public function campaignTypeSelector( $editable, $selectedTypeId, $index = null ) {
347        $types = CampaignType::getTypes();
348        if ( $editable ) {
349            $options = Html::element(
350                'option',
351                [ 'value' => self::EMPTY_CAMPAIGN_TYPE_OPTION ],
352                $this->msg( 'centralnotice-empty-campaign-type-option' )->plain()
353            );
354
355            foreach ( $types as $type ) {
356                $message = $this->msg( $type->getMessageKey() );
357                $text = $message->exists() ? $message->text() : $type->getId();
358                $options .= Html::element(
359                    'option',
360                    [ 'value' => $type->getId(), 'selected' => $selectedTypeId === $type->getId() ],
361                    $text
362                );
363            }
364
365            // Handle the case of a type removed from config but still assigned to
366            // a campaign in the DB.
367            if ( $selectedTypeId && !CampaignType::getById( $selectedTypeId ) ) {
368                $options .= Html::element(
369                    'option',
370                    [ 'value' => $selectedTypeId, 'selected' => true ],
371                    $this->msg(
372                        'centralntoice-deleted-campaign-type',
373                        $selectedTypeId
374                    )->text()
375                );
376            }
377
378            // Data attributes set below (data-campaign-name and
379            // data-initial-value) must coordinate with CNCampaignPager and
380            // ext.centralNotice.adminUi.campaignPager.js
381
382            $selectAttribs = [
383                'name' => 'campaign_type',
384            ];
385
386            if ( $selectedTypeId ) {
387                $selectAttribs['data-initial-value'] = $selectedTypeId;
388            }
389            if ( $index ) {
390                $selectAttribs['data-campaign-name'] = $index;
391            }
392
393            return Html::rawElement( 'select', $selectAttribs, $options );
394
395        } else {
396            if ( $selectedTypeId ) {
397                $type = CampaignType::getById( $selectedTypeId );
398                // We might get a null type if the DB has type identifiers that are
399                // not currently in the configuraiton.
400                if ( $type ) {
401                    $message = $this->msg( $type->getMessageKey() );
402                    return $message->exists()
403                        ? $message->escaped()
404                        : htmlspecialchars( $type->getId() );
405                } else {
406                    return htmlspecialchars( $selectedTypeId );
407                }
408            }
409            return $this->msg( 'centralnotice-empty-campaign-type-option' )->escaped();
410        }
411    }
412
413    /**
414     * Construct the priority select list for a campaign
415     *
416     * @param string|bool $index The name of the campaign (or false if it isn't needed)
417     * @param bool $editable Whether or not the form is editable by the user
418     * @param int $priorityValue The current priority value for this campaign
419     *
420     * @return string HTML for the select list
421     */
422    public function prioritySelector( $index, $editable, $priorityValue ) {
423        $priorities = [
424            self::LOW_PRIORITY => $this->msg( 'centralnotice-priority-low' ),
425            self::NORMAL_PRIORITY =>
426                $this->msg( 'centralnotice-priority-normal' ),
427            self::HIGH_PRIORITY => $this->msg( 'centralnotice-priority-high' ),
428            self::EMERGENCY_PRIORITY =>
429                $this->msg( 'centralnotice-priority-emergency' ),
430        ];
431
432        if ( $editable ) {
433            // The HTML for the select list options
434            $options = '';
435            foreach ( $priorities as $key => $labelMsg ) {
436                $options .= Html::element(
437                    'option',
438                    [ 'value' => (string)$key, 'selected' => $priorityValue == $key ],
439                    $labelMsg->text()
440                );
441            }
442
443            // Data attributes set below (data-campaign-name and
444            // data-initial-value) must coordinate with CNCampaignPager and
445            // ext.centralNotice.adminUi.campaignPager.js
446
447            $selectAttribs = [
448                'name' => 'priority',
449                'data-initial-value' => $priorityValue
450            ];
451
452            if ( $index ) {
453                $selectAttribs['data-campaign-name'] = $index;
454            }
455
456            return Html::rawElement( 'select', $selectAttribs, $options );
457        } else {
458            return $priorities[$priorityValue]->escaped();
459        }
460    }
461
462    /**
463     * Build a set of select lists. Used by timeSelector.
464     * @param string $prefix string to identify selector set, for example, 'start' or 'end'
465     * @param array $fields array of select lists to build
466     * @return string
467     */
468    private function createSelector( $prefix, $fields ) {
469        $out = '';
470        foreach ( $fields as [ $field, $label, $set, $current ] ) {
471            $options = Html::listDropdownOptions(
472                self::dropdownList( $this->msg( $label )->text(), $set ),
473                [ 'other' => '' ]
474            );
475
476            $xmlSelect = new XmlSelect( "{$prefix}[{$field}]", "{$prefix}[{$field}]", $current );
477            $xmlSelect->addOptions( $options );
478
479            $out .= $xmlSelect->getHTML();
480        }
481        return $out;
482    }
483
484    /**
485     * Output a form for adding a campaign.
486     */
487    private function addNoticeForm() {
488        $request = $this->getRequest();
489        $start = null;
490        $campaignType = null;
491        $noticeProjects = [];
492        $noticeLanguages = [];
493        // If there was an error, we'll need to restore the state of the form
494        if ( $request->wasPosted() && ( $request->getVal( 'subaction' ) === 'addCampaign' ) ) {
495            $start = $this->getDateTime( 'start' );
496            $noticeLanguages = $request->getArray( 'project_languages', [] );
497            $noticeProjects = $request->getArray( 'projects', [] );
498            $campaignType = $request->getText( 'campaign_type' );
499        }
500        '@phan-var array $noticeLanguages';
501        '@phan-var array $noticeProjects';
502
503        $htmlOut = '';
504
505        // Section heading
506        $htmlOut .= Html::element( 'h2',
507            [ 'class' => 'cn-special-section' ],
508            $this->msg( 'centralnotice-add-notice' )->text() );
509
510        // Begin Add a campaign fieldset
511        $htmlOut .= Html::openElement( 'fieldset', [ 'class' => 'prefsection' ] );
512
513        // Form for adding a campaign
514        $htmlOut .= Html::openElement( 'form', [ 'method' => 'post' ] );
515        $htmlOut .= Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() );
516        $htmlOut .= Html::hidden( 'subaction', 'addCampaign' );
517
518        $htmlOut .= Html::openElement( 'table', [ 'cellpadding' => 9 ] );
519
520        // Name
521        $htmlOut .= Html::rawElement( 'tr', [],
522            Html::element( 'td', [],
523                $this->msg( 'centralnotice-notice-name' )->text()
524            ) .
525            Html::rawElement( 'td', [],
526                Html::input( 'noticeName', $request->getVal( 'noticeName', '' ), 'text', [ 'size' => 25 ] )
527            )
528        );
529
530        // Campaign type selector
531        $htmlOut .= Html::rawElement( 'tr', [],
532            Html::rawElement( 'td', [],
533                Html::label( $this->msg( 'centralnotice-campaign-type' )->text(), 'campaign_type' )
534            ) .
535            Html::rawElement( 'td', [],
536                $this->campaignTypeSelector( $this->editable, $campaignType )
537            )
538        );
539
540        // Start Date
541        $htmlOut .= Html::rawElement( 'tr', [],
542            Html::element( 'td', [],
543                $this->msg( 'centralnotice-start-date' )->text()
544            ) .
545            Html::rawElement( 'td', [], $this->dateSelector( 'start', $this->editable, $start ) )
546        );
547        // Start Time
548        $htmlOut .= Html::rawElement( 'tr', [],
549            Html::element( 'td', [],
550                $this->msg( 'centralnotice-start-time' )->text()
551            ) .
552            $this->timeSelectorTd( 'start', $this->editable, $start )
553        );
554        // Project
555        $htmlOut .= Html::rawElement( 'tr', [],
556            Html::element( 'td', [ 'valign' => 'top' ],
557                $this->msg( 'centralnotice-projects' )->text()
558            ) .
559            Html::rawElement( 'td', [], $this->projectMultiSelector( $noticeProjects ) )
560        );
561        // Languages
562        $htmlOut .= Html::rawElement( 'tr', [],
563            Html::element( 'td', [ 'valign' => 'top' ],
564                $this->msg( 'centralnotice-languages' )->text()
565            ) .
566            Html::rawElement( 'td', [], $this->languageMultiSelector( $noticeLanguages ) )
567        );
568        // Countries
569        $htmlOut .= Html::openElement( 'tr' );
570        $htmlOut .= Html::rawElement( 'td', [],
571            Html::label( $this->msg( 'centralnotice-geo' )->text(), 'geotargeted' ) );
572        $htmlOut .= Html::rawElement( 'td', [],
573            Html::check( 'geotargeted', false, [ 'value' => 1, 'id' => 'geotargeted' ] ) );
574        $htmlOut .= Html::closeElement( 'tr' );
575
576        // Locations multi-selector
577        $htmlOut .= Html::openElement( 'tr', [ 'id' => 'centralnotice-geo-region-multiselector' ] );
578        $htmlOut .= Html::element( 'td', [ 'valign' => 'top' ],
579            $this->msg( 'centralnotice-location' )->text() );
580        $htmlOut .= Html::rawElement( 'td', [], $this->geoMultiSelectorTree() );
581        $htmlOut .= Html::closeElement( 'tr' );
582
583        $htmlOut .= Html::closeElement( 'table' );
584        $htmlOut .= Html::hidden( 'change', 'weight' );
585        $htmlOut .= Html::hidden( 'authtoken', $this->getUser()->getEditToken() );
586
587        // Submit button
588        $htmlOut .= Html::rawElement( 'div',
589            [ 'class' => 'cn-buttons' ],
590            $this->makeSummaryField( true ) .
591            Html::submitButton( $this->msg( 'centralnotice-modify' )->text() )
592        );
593
594        $htmlOut .= Html::closeElement( 'form' );
595
596        // End Add a campaign fieldset
597        $htmlOut .= Html::closeElement( 'fieldset' );
598
599        // Output HTML
600        $this->getOutput()->addHTML( $htmlOut );
601    }
602
603    private function handleAddCampaignPost() {
604        $request = $this->getRequest();
605        $noticeName = $request->getVal( 'noticeName' );
606        $start = $this->getDateTime( 'start' );
607        $projects = $request->getArray( 'projects' );
608        $project_languages = $request->getArray( 'project_languages' );
609        $geotargeted = $request->getCheck( 'geotargeted' );
610
611        $geo_countries = $request->getVal( 'geo_countries' );
612        if ( $geo_countries ) {
613            $geo_countries = explode( ',', $geo_countries );
614        } else {
615            $geo_countries = [];
616        }
617
618        $geo_regions = $request->getVal( 'geo_regions' );
619        if ( $geo_regions ) {
620            $geo_regions = explode( ',', $geo_regions );
621        } else {
622            $geo_regions = [];
623        }
624
625        $campaignType = $request->getText( 'campaign_type' );
626        $campaignType =
627            $campaignType === self::EMPTY_CAMPAIGN_TYPE_OPTION ? null : $campaignType;
628
629        // Sanity check: does the requested campaign type exist?
630        if ( $campaignType && !CampaignType::getById( $campaignType ) ) {
631            $this->showError( 'centralnotice-non-existent-campaign-type-error' );
632            return;
633        }
634
635        if ( $noticeName == '' ) {
636            $this->showError( 'centralnotice-null-string' );
637        } else {
638            $result = Campaign::addCampaign(
639                $noticeName,
640                false,
641                $start,
642                $projects,
643                $project_languages,
644                $geotargeted,
645                $geo_countries,
646                $geo_regions,
647                100,
648                self::NORMAL_PRIORITY,
649                $this->getUser(),
650                $campaignType,
651                $this->getSummaryFromRequest( $request )
652            );
653            if ( is_string( $result ) ) {
654                // TODO Better error handling
655                $this->showError( $result );
656            }
657        }
658    }
659
660    /**
661     * Retrieve jquery.ui.datepicker date and homebrew time,
662     * and return as a MW timestamp string.
663     * @param string $prefix
664     * @return null|string
665     */
666    private function getDateTime( $prefix ) {
667        $request = $this->getRequest();
668        // Check whether the user left the date field blank.
669        // Interpret any form of "empty" as a blank value.
670        $manual_entry = $request->getVal( "{$prefix}Date" );
671        if ( !$manual_entry ) {
672            return null;
673        }
674
675        $datestamp = $request->getVal( "{$prefix}Date_timestamp" );
676        $timeArray = $request->getArray( $prefix );
677        $timestamp = substr( $datestamp, 0, 8 ) .
678            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
679            $timeArray[ 'hour' ] .
680            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
681            $timeArray[ 'min' ] . '00';
682        return $timestamp;
683    }
684
685    /**
686     * Show the interface for viewing/editing an individual campaign
687     *
688     * @param string $notice The name of the campaign to view
689     */
690    private function outputNoticeDetail( $notice ) {
691        $out = $this->getOutput();
692
693        // Output specific ResourceLoader module
694        $out->addModules( 'ext.centralNotice.adminUi.campaignManager' );
695
696        // Output ResourceLoader modules for campaign mixins with custom controls
697        foreach ( $this->getConfig()->get( 'CentralNoticeCampaignMixins' ) as $mixinConfig ) {
698            if ( !empty( $mixinConfig['customAdminUIControlsModule'] ) ) {
699                $out->addModules( $mixinConfig['customAdminUIControlsModule'] );
700            }
701        }
702
703        $this->outputEnclosingDivStartTag();
704
705        // Todo: Convert the rest of this page to use this object
706        $this->campaign = new Campaign( $notice );
707        try {
708            if ( $this->campaign->isArchived() || $this->campaign->isLocked() ) {
709                $out->setSubtitle( $this->msg( 'centralnotice-archive-edit-prevented' ) );
710                // TODO: Fix this gross hack to prevent editing
711                $this->editable = false;
712            }
713            $out->addSubtitle(
714                $this->getLinkRenderer()->makeKnownLink(
715                    SpecialPage::getTitleFor( 'CentralNoticeLogs' ),
716                    $this->msg( 'centralnotice-campaign-view-logs' )->text(),
717                    [],
718                    [
719                        'log_type' => 'campaignSettings',
720                        'campaign' => $notice
721                    ]
722                )
723            );
724        } catch ( CampaignExistenceException ) {
725            throw new ErrorPageError( 'centralnotice', 'centralnotice-notice-doesnt-exist' );
726        }
727
728        if ( $this->editable && $this->getRequest()->wasPosted() ) {
729            $this->handleNoticeDetailPost( $notice );
730        }
731
732        $htmlOut = '';
733
734        // Begin Campaign detail fieldset
735        $htmlOut .= Html::openElement( 'fieldset', [ 'class' => 'prefsection' ] );
736
737        if ( $this->editable ) {
738            $htmlOut .= Html::openElement( 'form',
739                [
740                    'method' => 'post',
741                    'id' => 'centralnotice-notice-detail',
742                    'autocomplete' => 'off',
743                    'action' => $this->getPageTitle()->getLocalURL( [
744                        'subaction' => 'noticeDetail',
745                        'notice' => $notice
746                    ] )
747                ]
748            );
749        }
750
751        $output_detail = $this->noticeDetailForm( $notice );
752        $output_assigned = $this->assignedTemplatesForm( $notice );
753        $output_templates = $this->addTemplatesForm();
754
755        $htmlOut .= $output_detail;
756
757        // Catch for no banners so that we don't double message
758        if ( $output_assigned == '' && $output_templates == '' ) {
759            $htmlOut .= $this->msg( 'centralnotice-no-templates' )->escaped();
760            $htmlOut .= Html::element( 'p' );
761            $newPage = SpecialPage::getTitleFor( 'NoticeTemplate', 'add' );
762            $htmlOut .= $this->getLinkRenderer()->makeLink(
763                $newPage,
764                $this->msg( 'centralnotice-add-template' )->text()
765            );
766            $htmlOut .= Html::element( 'p' );
767        } elseif ( $output_assigned == '' ) {
768            $htmlOut .= Html::openElement( 'fieldset' ) . "\n" .
769                Html::element( 'legend', [], $this->msg( 'centralnotice-assigned-templates' )->text() ) . "\n";
770            $htmlOut .= $this->msg( 'centralnotice-no-templates-assigned' )->escaped();
771            $htmlOut .= Html::closeElement( 'fieldset' );
772            if ( $this->editable ) {
773                $htmlOut .= $output_templates;
774            }
775        } else {
776            $htmlOut .= $output_assigned;
777            if ( $this->editable ) {
778                $htmlOut .= $output_templates;
779            }
780        }
781        if ( $this->editable ) {
782            $htmlOut .= Html::hidden( 'authtoken', $this->getUser()->getEditToken() );
783
784            $htmlOut .= $this->makeSummaryField();
785
786            // Submit button
787            $htmlOut .= Html::rawElement( 'div',
788                [ 'class' => 'cn-buttons' ],
789                Html::submitButton(
790                    $this->msg( 'centralnotice-modify' )->text(),
791                    [ 'id' => 'noticeDetailSubmit' ]
792                )
793            );
794        }
795
796        if ( $this->editable ) {
797            $htmlOut .= Html::closeElement( 'form' );
798        }
799        $htmlOut .= Html::closeElement( 'fieldset' );
800
801        $this->displayCampaignWarnings();
802
803        $out->addHTML( $htmlOut );
804        $this->outputEnclosingDivEndTag();
805    }
806
807    /**
808     * Process a post request from the campaign (notice) detail subaction. Make
809     * changes to the campaign based on the post parameters.
810     *
811     * @param string $notice
812     */
813    private function handleNoticeDetailPost( $notice ) {
814        $request = $this->getRequest();
815
816        // If what we're doing is actually serious (ie: not updating the banner
817        // filter); process the request. Recall that if the serious request
818        // succeeds, the page will be reloaded again.
819        if ( !$request->getCheck( 'template-search' ) ) {
820            // Check authentication token
821            if ( $this->getUser()->matchEditToken( $request->getVal( 'authtoken' ) ) ) {
822                // Handle removing campaign
823                if ( $request->getVal( 'archive' ) ) {
824                    Campaign::setBooleanCampaignSetting( $notice, 'archived', true );
825                }
826
827                $initialCampaignSettings = Campaign::getCampaignSettings( $notice, true );
828
829                // Handle locking/unlocking campaign
830                Campaign::setBooleanCampaignSetting(
831                    $notice, 'locked', $request->getCheck( 'locked' )
832                );
833
834                // Handle enabling/disabling campaign
835                Campaign::setBooleanCampaignSetting(
836                    $notice, 'enabled', $request->getCheck( 'enabled' )
837                );
838
839                // Set campaign traffic throttle
840                if ( $request->getCheck( 'throttle-enabled' ) ) {
841                    $throttle = $request->getInt( 'throttle-cur', 100 );
842                } else {
843                    $throttle = 100;
844                }
845                Campaign::setNumericCampaignSetting( $notice, 'throttle', $throttle, 100, 0 );
846
847                $config = $this->getConfig();
848                $noticeNumberOfBuckets = $config->get( 'NoticeNumberOfBuckets' );
849                // Handle user bucketing setting for campaign
850                $numCampaignBuckets = min( $request->getInt( 'buckets', 1 ),
851                    $noticeNumberOfBuckets );
852                $numCampaignBuckets = (int)pow( 2, floor( log( $numCampaignBuckets, 2 ) ) );
853
854                Campaign::setNumericCampaignSetting(
855                    $notice,
856                    'buckets',
857                    $numCampaignBuckets,
858                    $noticeNumberOfBuckets,
859                    1
860                );
861
862                // Handle setting campaign priority
863                Campaign::setNumericCampaignSetting(
864                    $notice,
865                    'preferred',
866                    $request->getInt( 'priority', self::NORMAL_PRIORITY ),
867                    self::EMERGENCY_PRIORITY,
868                    self::LOW_PRIORITY
869                );
870
871                // Handle setting campaign type
872
873                $type = $request->getText( 'campaign_type' );
874                $type = $type === self::EMPTY_CAMPAIGN_TYPE_OPTION ? null : $type;
875
876                // Sanity check: does the requested campaign type exist?
877                if ( $type && !CampaignType::getById( $type ) ) {
878                    $this->showError( 'centralnotice-non-existent-campaign-type-error' );
879                    return;
880                }
881
882                Campaign::setType( $notice, $type );
883
884                // Handle updating geotargeting
885                if ( $request->getCheck( 'geotargeted' ) ) {
886                    Campaign::setBooleanCampaignSetting( $notice, 'geo', true );
887
888                    $countries = $this->listToArray( $request->getVal( 'geo_countries' ) );
889                    Campaign::updateCountries( $notice, $countries );
890
891                    // Regions in format CountryCode_RegionCode
892                    $regions = $this->listToArray( $request->getVal( 'geo_regions' ) );
893                    Campaign::updateRegions( $notice, $regions );
894
895                } else {
896                    Campaign::setBooleanCampaignSetting( $notice, 'geo', false );
897                }
898
899                // Handle updating the start and end settings
900                $start = $this->getDateTime( 'start' );
901                $end = $this->getDateTime( 'end' );
902                if ( $start && $end ) {
903                    Campaign::updateNoticeDate( $notice, $start, $end );
904                }
905
906                // Handle adding of banners to the campaign
907                $templatesToAdd = $request->getArray( 'addTemplates' );
908                if ( $templatesToAdd ) {
909                    $weight = $request->getArray( 'weight' );
910                    foreach ( $templatesToAdd as $templateName ) {
911                        $templateId = Banner::fromName( $templateName )->getId();
912                        $bucket = $request->getInt( "bucket-{$templateName}" );
913                        $result = Campaign::addTemplateTo(
914                            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
915                            $notice, $templateName, $weight[$templateId], $bucket
916                        );
917                        if ( $result !== true ) {
918                            $this->showError( $result );
919                        }
920                    }
921                }
922
923                // Handle removing of banners from the campaign
924                $templateToRemove = $request->getArray( 'removeTemplates' );
925                if ( $templateToRemove ) {
926                    foreach ( $templateToRemove as $template ) {
927                        Campaign::removeTemplateFor( $notice, $template );
928                    }
929                }
930
931                // Handle weight changes
932                $updatedWeights = $request->getArray( 'weight' );
933                $balanced = $request->getCheck( 'balanced' );
934                if ( $updatedWeights ) {
935                    foreach ( $updatedWeights as $templateId => $weight ) {
936                        if ( $balanced ) {
937                            $weight = 25;
938                        }
939                        Campaign::updateWeight( $notice, $templateId, $weight );
940                    }
941                }
942
943                // Handle bucket changes - keep in mind that the number of campaign buckets
944                // might have changed simultaneously (and might have happened server side)
945                $updatedBuckets = $request->getArray( 'bucket' );
946                if ( $updatedBuckets ) {
947                    foreach ( $updatedBuckets as $templateId => $bucket ) {
948                        Campaign::updateBucket(
949                            $notice,
950                            $templateId,
951                            intval( $bucket ) % $numCampaignBuckets
952                        );
953                    }
954                }
955
956                // Handle new projects
957                $projects = $request->getArray( 'projects' );
958                if ( $projects ) {
959                    Campaign::updateProjects( $notice, $projects );
960                }
961
962                // Handle new project languages
963                $projectLangs = $request->getArray( 'project_languages' );
964                if ( $projectLangs ) {
965                    Campaign::updateProjectLanguages( $notice, $projectLangs );
966                }
967
968                // Handle campaign-associated mixins
969                foreach ( $config->get( 'CentralNoticeCampaignMixins' ) as $mixinName => $mixinDef ) {
970                    $mixinControlName = self::makeNoticeMixinControlName( $mixinName );
971
972                    if ( $request->getCheck( $mixinControlName ) ) {
973                        $params = [];
974
975                        foreach ( $mixinDef['parameters'] as $paramName => $paramDef ) {
976                            $requestParamName =
977                                self::makeNoticeMixinControlName( $mixinName, $paramName );
978
979                            switch ( $paramDef['type'] ) {
980                                case 'string':
981                                case 'json':
982                                    $paramVal = Sanitizer::removeSomeTags(
983                                        $request->getText( $requestParamName )
984                                    );
985                                    break;
986
987                                case 'integer':
988                                    $paramVal = $request->getInt( $requestParamName );
989                                    break;
990
991                                case 'float':
992                                    $paramVal = $request->getFloat( $requestParamName );
993                                    break;
994
995                                case 'boolean':
996                                    $paramVal = $request->getCheck( $requestParamName );
997                                    break;
998
999                                default:
1000                                    throw new DomainException(
1001                                        "Unknown parameter type: '{$paramDef['type']}'" );
1002                            }
1003
1004                            $params[$paramName] = $paramVal;
1005                        }
1006
1007                        Campaign::updateCampaignMixins(
1008                            $notice, $mixinName, true, $params );
1009
1010                    } else {
1011                        Campaign::updateCampaignMixins( $notice, $mixinName, false );
1012                    }
1013                }
1014
1015                $finalCampaignSettings = Campaign::getCampaignSettings( $notice, true );
1016                $campaignId = Campaign::getNoticeId( $notice, CNDatabase::getPrimaryDb() );
1017
1018                $summary = $this->getSummaryFromRequest( $request );
1019
1020                Campaign::processAfterCampaignChange(
1021                    'modified',
1022                    $campaignId,
1023                    $notice,
1024                    $this->getUser(),
1025                    $initialCampaignSettings,
1026                    $finalCampaignSettings,
1027                    $summary
1028                );
1029
1030                // If there were no errors, reload the page to prevent duplicate form submission
1031                if ( !$this->centralNoticeError ) {
1032                    $this->getOutput()->redirect( $this->getPageTitle()->getLocalURL( [
1033                        'subaction' => 'noticeDetail',
1034                        'notice' => $notice
1035                    ] ) );
1036                    return;
1037                }
1038
1039                ChoiceDataProvider::invalidateCache();
1040            } else {
1041                $this->showError( 'sessionfailure' );
1042            }
1043        }
1044    }
1045
1046    /**
1047     * Output stored campaign warnings
1048     */
1049    private function displayCampaignWarnings() {
1050        foreach ( $this->campaignWarnings as $message ) {
1051            $this->getOutput()->wrapWikiMsg( "<div class='cn-error'>\n$1\n</div>", $message );
1052        }
1053    }
1054
1055    /**
1056     * Create the form for managing campaign settings (start date, end date, languages, etc.)
1057     * @param string $notice
1058     * @return string HTML
1059     */
1060    private function noticeDetailForm( $notice ) {
1061        $readonly = [ 'disabled' => !$this->editable ];
1062
1063        $request = $this->getRequest();
1064        $wasPosted = $request->wasPosted();
1065
1066        // If it's being posted, it's probably a write, so read from primary
1067        $campaign = Campaign::getCampaignSettings( $notice, $wasPosted );
1068
1069        if ( $campaign ) {
1070            // If there was an error, we'll need to restore the state of the form
1071            if ( $wasPosted ) {
1072                // TODO: Avoid duplicating handleNoticePostFromList which is where the logic
1073                // for reading and parsing post data resides. While that method is used when
1074                // saving changes, the one below is only used when clicking misc buttons
1075                // like "Apply filters", thus leading to subtle bugs (T182343).
1076                $start = $this->getDateTime( 'start' );
1077                $end = $this->getDateTime( 'end' );
1078                $isEnabled = $request->getCheck( 'enabled' );
1079                $priority = $request->getInt( 'priority', self::NORMAL_PRIORITY );
1080                if ( $request->getCheck( 'throttle-enabled' ) ) {
1081                    $throttle = $request->getInt( 'throttle-cur', 100 );
1082                } else {
1083                    $throttle = 100;
1084                }
1085                $isLocked = $request->getCheck( 'locked' );
1086                $isArchived = $request->getCheck( 'archived' );
1087                $noticeProjects = $request->getArray( 'projects', [] );
1088                $noticeLanguages = $request->getArray( 'project_languages', [] );
1089                $isGeotargeted = $request->getCheck( 'geotargeted' );
1090                $numBuckets = $request->getInt( 'buckets', 1 );
1091                $countries = $this->listToArray( $request->getVal( 'geo_countries' ) );
1092                $regions = $this->listToArray( $request->getVal( 'geo_regions' ) );
1093                $type = $request->getText( 'campaign_type' );
1094            } else {
1095                // Defaults
1096                $start = $campaign[ 'start' ];
1097                $end = $campaign[ 'end' ];
1098                $isEnabled = (bool)$campaign['enabled'];
1099                $priority = $campaign[ 'preferred' ];
1100                $throttle = intval( $campaign[ 'throttle' ] );
1101                $isLocked = (bool)$campaign['locked'];
1102                $isArchived = (bool)$campaign['archived'];
1103                $noticeProjects = Campaign::getNoticeProjects( $notice );
1104                $noticeLanguages = Campaign::getNoticeLanguages( $notice );
1105                $isGeotargeted = (bool)$campaign['geo'];
1106                $numBuckets = intval( $campaign[ 'buckets' ] );
1107                $countries = Campaign::getNoticeCountries( $notice );
1108                $regions = Campaign::getNoticeRegions( $notice );
1109                $type = $campaign['type'];
1110            }
1111            '@phan-var array $noticeLanguages';
1112            '@phan-var array $noticeProjects';
1113            $isThrottled = ( $throttle < 100 );
1114            $type = $type === self::EMPTY_CAMPAIGN_TYPE_OPTION ? null : $type;
1115
1116            $htmlOut = Html::rawElement( 'h2', [],
1117                $this->msg( 'centralnotice-notice-heading', $notice )->parse() );
1118            $htmlOut .= Html::openElement( 'table', [ 'cellpadding' => 9 ] );
1119
1120            // Rows
1121            // Campaign type selector
1122            $htmlOut .= Html::openElement( 'tr' );
1123            $htmlOut .= Html::rawElement( 'td', [],
1124                Html::label( $this->msg( 'centralnotice-campaign-type' )->text(), 'campaign_type' ) );
1125            $htmlOut .= Html::rawElement( 'td', [],
1126                $this->campaignTypeSelector( $this->editable, $type ) );
1127            $htmlOut .= Html::closeElement( 'tr' );
1128
1129            // Start Date
1130            $htmlOut .= Html::rawElement( 'tr', [],
1131                Html::element( 'td', [],
1132                    $this->msg( 'centralnotice-start-date' )->text()
1133                ) .
1134                Html::rawElement( 'td', [],
1135                    $this->dateSelector( 'start', $this->editable, $start )
1136                )
1137            );
1138            // Start Time
1139            $htmlOut .= Html::rawElement( 'tr', [],
1140                Html::element( 'td', [],
1141                    $this->msg( 'centralnotice-start-time' )->text()
1142                ) .
1143                $this->timeSelectorTd( 'start', $this->editable, $start )
1144            );
1145            // End Date
1146            $htmlOut .= Html::rawElement( 'tr', [],
1147                Html::element( 'td', [],
1148                    $this->msg( 'centralnotice-end-date' )->text()
1149                ) .
1150                Html::rawElement( 'td', [],
1151                    $this->dateSelector( 'end', $this->editable, $end )
1152                )
1153            );
1154            // End Time
1155            $htmlOut .= Html::rawElement( 'tr', [],
1156                Html::element( 'td', [],
1157                    $this->msg( 'centralnotice-end-time' )->text()
1158                ) .
1159                $this->timeSelectorTd( 'end', $this->editable, $end )
1160            );
1161            // Project
1162            $htmlOut .= Html::rawElement( 'tr', [],
1163                Html::element( 'td', [ 'valign' => 'top' ],
1164                    $this->msg( 'centralnotice-projects' )->text()
1165                ) .
1166                Html::rawElement( 'td', [], $this->projectMultiSelector( $noticeProjects ) )
1167            );
1168            // Languages
1169            $htmlOut .= Html::rawElement( 'tr', [],
1170                Html::element( 'td', [ 'valign' => 'top' ],
1171                    $this->msg( 'centralnotice-languages' )->text()
1172                ) .
1173                Html::rawElement( 'td', [], $this->languageMultiSelector( $noticeLanguages ) )
1174            );
1175            // Countries
1176            $htmlOut .= Html::rawElement( 'tr', [],
1177                Html::rawElement( 'td', [],
1178                    Html::label( $this->msg( 'centralnotice-geo' )->text(), 'geotargeted' )
1179                ) .
1180                Html::rawElement( 'td', [],
1181                    Html::check( 'geotargeted', $isGeotargeted,
1182                        [ ...$readonly, 'value' => $notice, 'id' => 'geotargeted' ]
1183                    )
1184                )
1185            );
1186
1187            // Locations multi-selector
1188            $htmlOut .= Html::rawElement( 'tr',
1189                [ 'id' => 'centralnotice-geo-region-multiselector' ],
1190                Html::element( 'td', [ 'valign' => 'top' ],
1191                    $this->msg( 'centralnotice-location' )->text()
1192                ) .
1193                Html::rawElement( 'td', [], $this->geoMultiSelectorTree( $countries, $regions ) )
1194            );
1195
1196            $config = $this->getConfig();
1197            // User bucketing
1198            $htmlOut .= Html::rawElement( 'tr', [],
1199                Html::rawElement( 'td', [],
1200                    Html::label( $this->msg( 'centralnotice-buckets' )->text(), 'buckets' )
1201                ) .
1202                Html::rawElement( 'td', [],
1203                    $this->numBucketsDropdown( $config->get( 'NoticeNumberOfBuckets' ), $numBuckets )
1204                )
1205            );
1206            // Enabled
1207            $htmlOut .= Html::rawElement( 'tr', [],
1208                Html::rawElement( 'td', [],
1209                    Html::label( $this->msg( 'centralnotice-enabled' )->text(), 'enabled' )
1210                ) .
1211                Html::rawElement( 'td', [],
1212                    Html::check( 'enabled', $isEnabled,
1213                        [ ...$readonly, 'value' => $notice, 'id' => 'enabled' ]
1214                    )
1215                )
1216            );
1217            // Preferred / Priority
1218            $htmlOut .= Html::rawElement( 'tr', [],
1219                Html::rawElement( 'td', [],
1220                    Html::label( $this->msg( 'centralnotice-preferred' )->text(), 'priority' )
1221                ) .
1222                Html::rawElement( 'td', [],
1223                    $this->prioritySelector( false, $this->editable, $priority )
1224                )
1225            );
1226            // Throttle impressions
1227            $htmlOut .= Html::rawElement( 'tr', [],
1228                Html::rawElement( 'td', [],
1229                    Html::label( $this->msg( 'centralnotice-throttle' )->text(), 'throttle-enabled' )
1230                ) .
1231                Html::rawElement( 'td', [],
1232                    Html::check( 'throttle-enabled', $isThrottled,
1233                        [ ...$readonly, 'value' => $notice, 'id' => 'throttle-enabled' ]
1234                    )
1235                )
1236            );
1237            // Throttle value
1238            $htmlOut .= Html::openElement( 'tr', [ 'class' => 'cn-throttle-amount' ] );
1239            $htmlOut .= Html::rawElement( 'td', [],
1240                Html::label( $this->msg( 'centralnotice-throttle-amount' )->text(), 'throttle' ) );
1241            $throttleLabel = $this->msg( 'percent' )->numParams( $throttle )->text();
1242            if ( $this->editable ) {
1243                $htmlOut .= Html::rawElement( 'td', [],
1244                    Html::element( 'span',
1245                        [ 'class' => 'cn-throttle', 'id' => 'centralnotice-throttle-echo' ],
1246                        $throttleLabel ) .
1247                    Html::hidden( 'throttle-cur', $throttle,
1248                        [ 'id' => 'centralnotice-throttle-cur' ] ) .
1249                    Html::rawElement( 'div', [ 'id' => 'centralnotice-throttle-amount' ], '' ) );
1250            } else {
1251                $htmlOut .= Html::element( 'td', [], $throttleLabel );
1252            }
1253            $htmlOut .= Html::closeElement( 'tr' );
1254            // Locked
1255            $htmlOut .= Html::rawElement( 'tr', [],
1256                Html::rawElement( 'td', [],
1257                    Html::label( $this->msg( 'centralnotice-locked' )->text(), 'locked' )
1258                ) .
1259                Html::rawElement( 'td', [],
1260                    Html::check( 'locked', $isLocked,
1261                        [ ...$readonly, 'value' => $notice, 'id' => 'locked' ]
1262                    )
1263                )
1264            );
1265            if ( $this->editable ) {
1266                // Locked
1267                $htmlOut .= Html::rawElement( 'tr', [],
1268                    Html::rawElement( 'td', [],
1269                        Html::label( $this->msg( 'centralnotice-archive-campaign' )->text(), 'archive' )
1270                    ) .
1271                    Html::rawElement( 'td', [],
1272                        Html::check( 'archive', $isArchived, [ 'value' => $notice, 'id' => 'archive' ] )
1273                    )
1274                );
1275            }
1276            $htmlOut .= Html::closeElement( 'table' );
1277
1278            // Create controls for campaign-associated mixins (if there are any)
1279            $centralNoticeCampaignMixins = $config->get( 'CentralNoticeCampaignMixins' );
1280            if ( $centralNoticeCampaignMixins ) {
1281                $mixinsThisNotice = Campaign::getCampaignMixins( $notice, false, $wasPosted );
1282
1283                $htmlOut .= Html::openElement( 'fieldset' ) . "\n" .
1284                    Html::element( 'legend', [], $this->msg( 'centralnotice-notice-mixins-fieldset' )->text() ) . "\n";
1285
1286                foreach ( $centralNoticeCampaignMixins as $mixinName => $mixinDef ) {
1287                    $mixinControlName = self::makeNoticeMixinControlName( $mixinName );
1288
1289                    $attribs = [
1290                        'value' => $notice,
1291                        'class' => 'noticeMixinCheck',
1292                        'id' => $mixinControlName,
1293                        'data-mixin-name' => $mixinName,
1294                        ...$readonly,
1295                    ];
1296
1297                    if ( isset( $mixinsThisNotice[$mixinName] ) ) {
1298                        // We have data on the mixin for this campaign, though
1299                        // it may not have been enabled.
1300
1301                        $checked = $mixinsThisNotice[$mixinName]['enabled'];
1302
1303                        $attribs['data-mixin-param-values'] =
1304                            FormatJson::encode(
1305                            $mixinsThisNotice[$mixinName]['parameters'] );
1306
1307                    } else {
1308
1309                        // No data; it's never been enabled for this campaign
1310                        // before. Note: default settings values are set on the
1311                        // client.
1312                        $checked = false;
1313                    }
1314
1315                    $htmlOut .= Html::openElement( 'div' );
1316
1317                    $htmlOut .= Html::check(
1318                        $mixinControlName,
1319                        $checked,
1320                        $attribs
1321                    );
1322
1323                    $htmlOut .= Html::label(
1324                        $this->msg( $mixinDef['nameMsg'] )->text(),
1325                        $mixinControlName,
1326                        [ 'for' => $mixinControlName ]
1327                    );
1328
1329                    if ( !empty( $mixinDef['helpMsg'] ) ) {
1330                        $htmlOut .= Html::element( 'div',
1331                            [ 'class' => 'htmlform-help' ],
1332                            $this->msg( $mixinDef['helpMsg'] )->text()
1333                        );
1334                    }
1335
1336                    $htmlOut .= Html::closeElement( 'div' );
1337
1338                }
1339
1340                $htmlOut .= Html::closeElement( 'fieldset' );
1341            }
1342
1343            return $htmlOut;
1344        }
1345
1346        return '';
1347    }
1348
1349    private static function makeNoticeMixinControlName(
1350        string $mixinName, ?string $mixinParam = null
1351    ): string {
1352        return 'notice-mixin-' . $mixinName .
1353            ( $mixinParam ? '-' . $mixinParam : '' );
1354    }
1355
1356    /**
1357     * Create form for managing banners assigned to a campaign
1358     *
1359     * Common campaign misconfigurations will cause warnings to appear
1360     * at the top of this form.
1361     * @param string $notice
1362     * @return string HTML
1363     */
1364    private function assignedTemplatesForm( $notice ) {
1365        $dbr = CNDatabase::getReplicaDb();
1366        $res = $dbr->newSelectQueryBuilder()
1367            // Aliases are needed to avoid problems with table prefixes
1368            ->select( [
1369                'templates.tmp_id',
1370                'templates.tmp_name',
1371                'assignments.tmp_weight',
1372                'assignments.asn_bucket',
1373                'notices.not_buckets',
1374            ] )
1375            ->from( 'cn_notices', 'notices' )
1376            ->join( 'cn_assignments', 'assignments', 'notices.not_id = assignments.not_id' )
1377            ->join( 'cn_templates', 'templates', 'assignments.tmp_id = templates.tmp_id' )
1378            ->where( [
1379                'notices.not_name' => $notice,
1380            ] )
1381            ->orderBy( [ 'assignments.asn_bucket', 'notices.not_id' ] )
1382            ->caller( __METHOD__ )
1383            ->fetchResultSet();
1384
1385        // No banners found
1386        if ( $res->numRows() < 1 ) {
1387            return '';
1388        }
1389
1390        $weights = [];
1391
1392        $banners = [];
1393        foreach ( $res as $row ) {
1394            $banners[] = $row;
1395
1396            $weights[] = $row->tmp_weight;
1397        }
1398        $isBalanced = ( count( array_unique( $weights ) ) === 1 );
1399
1400        // Build Assigned banners HTML
1401
1402        $htmlOut = Html::hidden( 'change', 'weight' );
1403
1404        // Prepare data about assigned banners to provide to client-side code, and
1405        // make it available within the fieldsset element.
1406
1407        $bannersForJS = array_map(
1408            static function ( $banner ) {
1409                return [
1410                    'bannerName' => $banner->tmp_name,
1411                    'bucket' => $banner->asn_bucket
1412                ];
1413            },
1414            $banners
1415        );
1416
1417        $readonly = [ 'disabled' => !$this->editable ];
1418
1419        $htmlOut .= Html::openElement( 'fieldset', [
1420                'data-assigned-banners' => json_encode( $bannersForJS ),
1421                'id' => 'centralnotice-assigned-banners'
1422            ] ) . "\n" .
1423            Html::element( 'legend', [], $this->msg( 'centralnotice-assigned-templates' )->text() ) . "\n";
1424
1425        // Equal weight banners
1426        $htmlOut .= Html::rawElement( 'tr', [],
1427            Html::rawElement( 'td', [],
1428                Html::label( $this->msg( 'centralnotice-balanced' )->text(), 'balanced' )
1429            ) .
1430            Html::rawElement( 'td', [],
1431                Html::check( 'balanced', $isBalanced,
1432                    [ ...$readonly, 'value' => $notice, 'id' => 'balanced' ]
1433                )
1434            )
1435        );
1436
1437        $htmlOut .= Html::openElement( 'table',
1438            [
1439                'cellpadding' => 9,
1440                'width'       => '100%'
1441            ]
1442        );
1443        if ( $this->editable ) {
1444            $htmlOut .= Html::element( 'th', [ 'align' => 'left', 'width' => '5%' ],
1445                $this->msg( "centralnotice-remove" )->text() );
1446        }
1447        $htmlOut .= Html::element( 'th',
1448            [ 'align' => 'left', 'width' => '5%', 'class' => 'cn-weight' ],
1449            $this->msg( 'centralnotice-weight' )->text() );
1450        $htmlOut .= Html::element( 'th', [ 'align' => 'left', 'width' => '5%' ],
1451            $this->msg( 'centralnotice-bucket' )->text() );
1452        $htmlOut .= Html::element( 'th', [ 'align' => 'left', 'width' => '70%' ],
1453            $this->msg( 'centralnotice-templates' )->text() );
1454
1455        // Table rows
1456        $noticeNumberOfBuckets = $this->getConfig()->get( 'NoticeNumberOfBuckets' );
1457        foreach ( $banners as $row ) {
1458            $htmlOut .= Html::openElement( 'tr' );
1459
1460            if ( $this->editable ) {
1461                // Remove
1462                $htmlOut .= Html::rawElement( 'td', [ 'valign' => 'top' ],
1463                    Html::check( 'removeTemplates[]', false, [
1464                        'value' => $row->tmp_name,
1465                        'class' => 'bannerRemoveCheckbox'
1466                    ] )
1467                );
1468            }
1469
1470            // Weight
1471            $htmlOut .= Html::rawElement( 'td', [ 'valign' => 'top', 'class' => 'cn-weight' ],
1472                $this->weightDropdown( "weight[$row->tmp_id]", $row->tmp_weight )
1473            );
1474
1475            // Bucket
1476            $numCampaignBuckets = min( intval( $row->not_buckets ), $noticeNumberOfBuckets );
1477            $htmlOut .= Html::rawElement( 'td', [ 'valign' => 'top' ],
1478                $this->bucketDropdown(
1479                    "bucket[$row->tmp_id]",
1480                    ( $numCampaignBuckets == 1 ? null : intval( $row->asn_bucket ) ),
1481                    $numCampaignBuckets,
1482                    $row->tmp_name
1483                )
1484            );
1485
1486            // Banner
1487            $htmlOut .= Html::rawElement( 'td', [ 'valign' => 'top' ],
1488                BannerRenderer::linkToBanner( $row->tmp_name )
1489            );
1490
1491            $htmlOut .= Html::closeElement( 'tr' );
1492        }
1493        $htmlOut .= Html::closeElement( 'table' );
1494        $htmlOut .= Html::closeElement( 'fieldset' );
1495
1496        // Sneak in some extra processing, to detect errors in bucket assignment.
1497        // Test for campaign buckets without an assigned banner or with multiple banners.
1498        $assignedBuckets = [];
1499        $numBuckets = $this->campaign->getBuckets();
1500        foreach ( $banners as $banner ) {
1501            $bannerBucket = $banner->asn_bucket;
1502            $bannerName = $banner->tmp_name;
1503
1504            $assignedBuckets[$bannerBucket] = $bannerName;
1505        }
1506        // Do any buckets not have a banner assigned?
1507        if ( count( $assignedBuckets ) < $numBuckets ) {
1508            $this->campaignWarnings[] = [
1509                'centralnotice-banner-empty-bucket'
1510            ];
1511        }
1512
1513        return $htmlOut;
1514    }
1515
1516    /**
1517     * @param string $name
1518     * @param int|null $selected
1519     * @return string
1520     */
1521    private function weightDropdown( $name, $selected ) {
1522        $selected = intval( $selected );
1523
1524        if ( $this->editable ) {
1525            $html = Html::openElement( 'select', [ 'name' => $name ] );
1526            foreach ( range( 5, 100, 5 ) as $value ) {
1527                $html .= Html::element(
1528                    'option',
1529                    [ 'value' => (string)$value, 'selected' => $value === $selected ],
1530                    (string)$value
1531                );
1532            }
1533            $html .= Html::closeElement( 'select' );
1534            return $html;
1535        } else {
1536            return htmlspecialchars( (string)$selected );
1537        }
1538    }
1539
1540    /**
1541     * @param string $name
1542     * @param int|null $selected
1543     * @param int $numberCampaignBuckets
1544     * @param string $bannerName
1545     * @return string
1546     */
1547    private function bucketDropdown( $name, $selected, $numberCampaignBuckets, $bannerName ) {
1548        $bucketLabel = static fn ( int $val ) => chr( ord( 'A' ) + $val );
1549
1550        if ( $this->editable ) {
1551            // Default to bucket 'A'
1552            $selected ??= 0;
1553            $selected %= $numberCampaignBuckets;
1554
1555            // bucketSelector class is for all bucket selectors (for assigned or
1556            // unassigned banners). Coordinate with CentralNoticePager::bucketDropdown().
1557            $html = Html::openElement( 'select', [
1558                'name' => $name,
1559                'class' => 'bucketSelector bucketSelectorForAssignedBanners',
1560                'data-banner-name' => $bannerName
1561            ] );
1562
1563            foreach ( range( 0, $this->getConfig()->get( 'NoticeNumberOfBuckets' ) - 1 ) as $value ) {
1564                $html .= Html::element(
1565                    'option',
1566                    [
1567                        'value' => $value,
1568                        'selected' => $value === $selected,
1569                        'disabled' => $value >= $numberCampaignBuckets,
1570                    ],
1571                    $bucketLabel( $value )
1572                );
1573            }
1574            $html .= Html::closeElement( 'select' );
1575            return $html;
1576        } else {
1577            if ( $selected === null ) {
1578                return '-';
1579            }
1580            return htmlspecialchars( $bucketLabel( $selected ) );
1581        }
1582    }
1583
1584    /**
1585     * @param int $numBuckets
1586     * @param int|null $selected
1587     * @return string
1588     */
1589    private function numBucketsDropdown( $numBuckets, $selected ) {
1590        $selected ??= 1;
1591
1592        if ( $this->editable ) {
1593            $html = Html::openElement( 'select', [ 'name' => 'buckets', 'id' => 'buckets' ] );
1594            foreach ( range( 0, intval( log( $numBuckets, 2 ) ) ) as $value ) {
1595                $value = pow( 2, $value );
1596                $html .= Html::element(
1597                    'option',
1598                    [ 'value' => (string)$value, 'selected' => $value === $selected ],
1599                    (string)$value
1600                );
1601            }
1602            $html .= Html::closeElement( 'select' );
1603            return $html;
1604        } else {
1605            return htmlspecialchars( (string)$selected );
1606        }
1607    }
1608
1609    /**
1610     * Create form for adding banners to a campaign
1611     * @return string
1612     */
1613    private function addTemplatesForm() {
1614        // Sanitize input on search key and split out terms
1615        $searchTerms = $this->sanitizeSearchTerms( $this->getRequest()->getText( 'tplsearchkey' ) );
1616
1617        $pager = new CentralNoticePager( $this, $searchTerms );
1618
1619        // Build HTML
1620        $htmlOut = Html::openElement( 'fieldset' ) . "\n" .
1621            Html::element( 'legend', [], $this->msg( 'centralnotice-available-templates' )->text() ) . "\n";
1622
1623        // Banner search box
1624        $htmlOut .= Html::openElement( 'fieldset', [ 'id' => 'cn-template-searchbox' ] );
1625        $htmlOut .= Html::element(
1626            'legend', [], $this->msg( 'centralnotice-filter-template-banner' )->text()
1627        );
1628
1629        $htmlOut .= Html::element( 'label', [ 'for' => 'tplsearchkey' ],
1630            $this->msg( 'centralnotice-filter-template-prompt' )->text() );
1631        $htmlOut .= Html::input( 'tplsearchkey', $searchTerms );
1632        $htmlOut .= Html::element(
1633            'input',
1634            [
1635                'type' => 'submit',
1636                'name' => 'template-search',
1637                'value' => $this->msg( 'centralnotice-filter-template-submit' )->text()
1638            ]
1639        );
1640
1641        $htmlOut .= Html::closeElement( 'fieldset' );
1642
1643        // And now the banners, if any
1644        if ( $pager->getNumRows() > 0 ) {
1645            // Show paginated list of banners
1646            $htmlOut .= Html::rawElement( 'div',
1647                [ 'class' => 'cn-pager' ],
1648                $pager->getNavigationBar() );
1649            $htmlOut .= $pager->getBody();
1650            $htmlOut .= Html::rawElement( 'div',
1651                [ 'class' => 'cn-pager' ],
1652                $pager->getNavigationBar() );
1653
1654        } else {
1655            $htmlOut .= $this->msg( 'centralnotice-no-templates' )->escaped();
1656        }
1657        $htmlOut .= Html::closeElement( 'fieldset' );
1658
1659        return $htmlOut;
1660    }
1661
1662    /**
1663     * Generates a multiple select list of all languages.
1664     *
1665     * @param array $selected The language codes of the selected languages
1666     *
1667     * @return string multiple select list
1668     */
1669    private function languageMultiSelector( $selected = [] ) {
1670        // Retrieve the list of languages in user's language
1671        $languages = MediaWikiServices::getInstance()->getLanguageNameUtils()
1672            ->getLanguageNames( $this->getLanguage()->getCode() );
1673
1674        // Make sure the site language is in the list; a custom language code
1675        // might not have a defined name...
1676        $languageCode = $this->getConfig()->get( MainConfigNames::LanguageCode );
1677        if ( !array_key_exists( $languageCode, $languages ) ) {
1678            $languages[$languageCode] = $languageCode;
1679        }
1680        ksort( $languages );
1681
1682        $options = "\n";
1683        foreach ( $languages as $code => $name ) {
1684            $options .= Html::element(
1685                'option',
1686                [ 'value' => $code, 'selected' => in_array( $code, $selected ) ],
1687                $this->msg( 'centralnotice-language-listing', $code, $name )->text()
1688            ) . "\n";
1689        }
1690
1691        $attribs = [
1692            'multiple' => true,
1693            'id' => 'project_languages',
1694            'name' => 'project_languages[]',
1695            'class' => 'cn-multiselect',
1696            'autocomplete' => 'off',
1697            'disabled' => !$this->editable,
1698        ];
1699
1700        return Html::rawElement( 'select', $attribs, $options );
1701    }
1702
1703    /**
1704     * Generates a multiple select list of all project types.
1705     *
1706     * @param array $selected The name of the selected project type
1707     *
1708     * @return string multiple select list
1709     */
1710    private function projectMultiSelector( $selected = [] ) {
1711        $options = "\n";
1712        foreach ( $this->getConfig()->get( 'NoticeProjects' ) as $project ) {
1713            $options .= Html::element(
1714                'option',
1715                [ 'value' => $project, 'selected' => in_array( $project, $selected ) ],
1716                $project
1717            ) . "\n";
1718        }
1719
1720        $attribs = [
1721            'multiple' => true,
1722            'id' => 'projects',
1723            'name' => 'projects[]',
1724            'class' => 'cn-multiselect',
1725            'autocomplete' => 'off',
1726            'disabled' => !$this->editable,
1727        ];
1728
1729        return Html::rawElement( 'select', $attribs, $options );
1730    }
1731
1732    /**
1733     * @param string $text
1734     * @param string[] $values
1735     * @return string
1736     */
1737    public static function dropdownList( $text, $values ) {
1738        $dropdown = "*{$text}\n";
1739        foreach ( $values as $value ) {
1740            $dropdown .= "**{$value}\n";
1741        }
1742        return $dropdown;
1743    }
1744
1745    /**
1746     * Create a  string with summary label and text field.
1747     *
1748     * @param bool $action If true, use a placeholder message appropriate for
1749     *   a single action (such as creating a campaign).
1750     * @return string
1751     */
1752    public function makeSummaryField( $action = false ) {
1753        $placeholderMsg = $action ? 'centralnotice-change-summary-action-prompt'
1754            : 'centralnotice-change-summary-prompt';
1755
1756        return Html::element( 'label',
1757                [ 'class' => 'cn-change-summary-label' ],
1758                $this->msg( 'centralnotice-change-summary-label' )->text()
1759            ) . Html::element( 'input',
1760                [
1761                    'class' => 'cn-change-summary-input',
1762                    'placeholder' => $this->msg( $placeholderMsg )->text(),
1763                    'size' => 45,
1764                    'name' => 'changeSummary'
1765                ]
1766            );
1767    }
1768
1769    private function getSummaryFromRequest( WebRequest $request ): string {
1770        return static::truncateSummaryField( $request->getVal( 'changeSummary' ) );
1771    }
1772
1773    /**
1774     * @param int $begin
1775     * @param int $end
1776     * @return string[]
1777     */
1778    private function paddedRange( $begin, $end ) {
1779        $unpaddedRange = range( $begin, $end );
1780        $paddedRange = [];
1781        foreach ( $unpaddedRange as $number ) {
1782            // pad number with 0 if needed
1783            $paddedRange[] = sprintf( "%02d", $number );
1784        }
1785        return $paddedRange;
1786    }
1787
1788    private function showError( string $message ) {
1789        $this->getOutput()->wrapWikiMsg( "<div class='cn-error'>\n$1\n</div>", $message );
1790        $this->centralNoticeError = true;
1791    }
1792
1793    /**
1794     * Generates a multiple select list of all countries.
1795     *
1796     * @param array $selectedCountries The country codes of the selected countries
1797     * @param array $selectedRegions The unique region codes of the selected regions
1798     *                               in format CountryCode_RegionCode
1799     *
1800     * @return string multiple select list
1801     */
1802    private function geoMultiSelectorTree( $selectedCountries = [], $selectedRegions = [] ) {
1803        $userLanguageCode = $this->getLanguage()->getCode();
1804        $countries = GeoTarget::getCountriesList( $userLanguageCode );
1805        $locationElements = "\n";
1806        foreach ( $countries as $countryCode => $country ) {
1807
1808            $regions = '';
1809            if ( $country->getRegions() ) {
1810                foreach ( $country->getRegions() as $regionCode => $name ) {
1811                    $uniqueRegionCode = GeoTarget::makeUniqueRegionCode(
1812                        $countryCode, $regionCode
1813                    );
1814                    $isSelected = in_array( $uniqueRegionCode, $selectedRegions );
1815                    $data = [
1816                        'type' => 'region',
1817                        'code' => $regionCode,
1818                        'opened' => $isSelected,
1819                        'selected' => $isSelected
1820                    ];
1821                    if ( !$this->editable ) {
1822                        $data['disabled'] = true;
1823                    }
1824                    $regions .= Html::element(
1825                        'li',
1826                        [
1827                            'id' => $uniqueRegionCode,
1828                            'data-jstree' => json_encode( $data )
1829                        ],
1830                        $this->msg(
1831                            'centralnotice-location-name-and-code',
1832                            $name,
1833                            $regionCode
1834                        )->text()
1835                    );
1836                }
1837            }
1838
1839            $isSelected = in_array( $countryCode, $selectedCountries );
1840            $data = [
1841                'type' => 'country',
1842                'code' => $countryCode,
1843                'opened' => $isSelected,
1844                'selected' => $isSelected
1845            ];
1846            if ( !$this->editable ) {
1847                $data['disabled'] = true;
1848            }
1849
1850            $countryNameAndCode = $this->msg(
1851                'centralnotice-location-name-and-code',
1852                $country->getName(),
1853                $countryCode
1854            )->escaped();
1855
1856            $locationElements .= Html::rawElement(
1857                'li',
1858                [
1859                    'data-jstree' => json_encode( $data ),
1860                    'id' => $countryCode
1861                ],
1862                $countryNameAndCode . ( $regions ? Html::rawElement( 'ul', [], $regions ) : '' )
1863            );
1864        }
1865
1866        $properties = [
1867            'id'       => 'geo_locations',
1868            'class'    => 'cn-tree'
1869        ];
1870
1871        if ( !$this->editable ) {
1872            $properties['disabled'] = 'disabled';
1873        }
1874
1875        $search = Html::rawElement(
1876            'input',
1877            [
1878                'type' => 'text',
1879                'class' => 'cn-tree-search'
1880            ],
1881            ''
1882        );
1883        $searchClear = Html::element(
1884            'button',
1885            [
1886                'class' => 'cn-tree-clear'
1887            ],
1888            $this->msg( 'centralnotice-location-filter-clear' )->text()
1889        );
1890        $searchLabelText = $this->msg( 'centralnotice-location-filter' )->escaped();
1891        $searchLabel = Html::rawElement(
1892            'label',
1893            [
1894                'class' => 'cn-tree-search-label'
1895            ],
1896            $searchLabelText . $search . $searchClear
1897        );
1898
1899        $statusText = Html::rawElement( 'div', [ 'class' => 'cn-tree-status' ], '' );
1900
1901        $tree = Html::rawElement(
1902            'div',
1903            $properties,
1904            Html::rawElement(
1905                'ul',
1906                [],
1907                $locationElements
1908            )
1909        );
1910
1911        $hiddenInputs = Html::input(
1912            'geo_countries',
1913            implode( ',', $selectedCountries ),
1914            'hidden',
1915            [ 'id' => 'geo_countries_value' ]
1916        );
1917
1918        $hiddenInputs .= Html::input(
1919            'geo_regions',
1920            implode( ',', $selectedRegions ),
1921            'hidden',
1922            [ 'id' => 'geo_regions_value' ]
1923        );
1924
1925        return Html::rawElement(
1926            'div',
1927            [ 'class' => 'cn-tree-wrapper' ],
1928            $searchLabel . $tree . $statusText . $hiddenInputs
1929        );
1930    }
1931
1932    /**
1933     * Sanitizes template search terms by removing non alpha and ensuring space delimiting.
1934     *
1935     * @param string $terms Search terms to sanitize
1936     *
1937     * @return string Space delimited string
1938     */
1939    public function sanitizeSearchTerms( $terms ) {
1940        preg_match_all( '/([\w-]+)\S*/s', $terms, $matches );
1941        return implode( ' ', $matches[1] );
1942    }
1943
1944    /**
1945     * Truncate the summary field in a linguistically appropriate way.
1946     * @param string|null $summary
1947     * @return string
1948     */
1949    public static function truncateSummaryField( $summary ) {
1950        return MediaWikiServices::getInstance()->getContentLanguage()
1951            ->truncateForDatabase( $summary ?? '', 255 );
1952    }
1953
1954    /**
1955     * Provides names of sub-pages of the CentralNotice admin interface.
1956     *
1957     * @inheritDoc
1958     */
1959    public function getAssociatedNavigationLinks(): array {
1960        // Keys of (default value of $wgNoticeTabifyPages) are the names of the special
1961        // pages of the admin interface without the initial 'Special:'.
1962        // TODO Make $wgNoticeTabifyPages a constant rather than a config variable.
1963        return array_map(
1964            static fn ( $name ) => "Special:$name",
1965            array_keys( $this->getConfig()->get( 'NoticeTabifyPages' ) )
1966        );
1967    }
1968
1969    /**
1970     * Return the localized text used for subpages of the CN admin interface (on skins
1971     * that support navigation links).
1972     *
1973     * @inheritDoc
1974     */
1975    public function getShortDescription( string $path = '' ): string {
1976        $noticeTabifyPages = $this->getConfig()->get( 'NoticeTabifyPages' );
1977        return $this->msg( $noticeTabifyPages[ $path ][ 'message' ] )->parse();
1978    }
1979
1980    /**
1981     * Loads a CentralNotice variable from session data.
1982     *
1983     * @param string $variable Name of the variable
1984     * @param mixed|null $default Default value of the variable
1985     *
1986     * @return mixed Stored variable or default
1987     */
1988    public function getCNSessionVar( $variable, $default = null ) {
1989        return $this->getRequest()->getSessionData( "centralnotice-$variable" ) ?? $default;
1990    }
1991
1992    /**
1993     * Sets a CentralNotice session variable. Note that this will fail silently if a
1994     * session does not exist for the user.
1995     *
1996     * @param string $variable Name of the variable
1997     * @param mixed $value Value for the variable
1998     */
1999    public function setCNSessionVar( $variable, $value ) {
2000        $this->getRequest()->setSessionData( "centralnotice-{$variable}", $value );
2001    }
2002
2003    /**
2004     * @param string[] $projects
2005     * @return string
2006     */
2007    public function listProjects( $projects ) {
2008        return $this->makeShortList( $this->getConfig()->get( 'NoticeProjects' ), $projects );
2009    }
2010
2011    /**
2012     * @param string[] $countries
2013     * @param string[] $regions
2014     * @return string
2015     */
2016    public function listCountriesRegions( array $countries, array $regions ) {
2017        $allCountries = array_keys( GeoTarget::getCountriesList() );
2018        $list = $this->makeShortList( $allCountries, $countries );
2019        $regionsByCountry = [];
2020        foreach ( $regions as $region ) {
2021            $countryCode = substr( $region, 0, 2 );
2022            $regionCode = substr( $region, 3 );
2023            $regionsByCountry[$countryCode][] = $regionCode;
2024        }
2025        if ( $list !== '' && count( $regionsByCountry ) > 0 ) {
2026            $list .= '; ';
2027        }
2028        $regionsByCountryList = [];
2029        foreach ( $regionsByCountry as $countryCode => $regions ) {
2030            $all = array_keys( GeoTarget::getRegionsList( $countryCode ) );
2031            $regionList = $this->makeShortList( $all, $regions );
2032            $regionsByCountryList[] = "$countryCode: ($regionList)";
2033        }
2034        $list .= $this->getContext()->getLanguage()->listToText( $regionsByCountryList );
2035
2036        return $list;
2037    }
2038
2039    /**
2040     * @param string[] $languages
2041     * @return string
2042     */
2043    public function listLanguages( $languages ) {
2044        $all = array_keys( MediaWikiServices::getInstance()->getLanguageNameUtils()
2045            ->getLanguageNames( 'en' ) );
2046        return $this->makeShortList( $all, $languages );
2047    }
2048
2049    /**
2050     * @param string[] $all
2051     * @param string[] $list
2052     * @return string
2053     */
2054    private function makeShortList( $all, $list ) {
2055        // TODO ellipsis and js/css expansion
2056        if ( count( $list ) == count( $all ) ) {
2057            return $this->getContext()->msg( 'centralnotice-all' )->text();
2058        }
2059        if ( count( $list ) > self::LIST_COMPLEMENT_THRESHOLD * count( $all ) ) {
2060            $inverse = array_values( array_diff( $all, $list ) );
2061            $txt = $this->getContext()->getLanguage()->listToText( $inverse );
2062            return $this->getContext()->msg( 'centralnotice-all-except', $txt )->text();
2063        }
2064        return $this->getContext()->getLanguage()->listToText( array_values( $list ) );
2065    }
2066
2067    /**
2068     * Convert comma separated list to array
2069     * @param string $list
2070     * @return array
2071     */
2072    private function listToArray( $list ) {
2073        if ( $list ) {
2074            $array = explode( ',', $list );
2075        } else {
2076            $array = [];
2077        }
2078        return $array;
2079    }
2080
2081    /** @inheritDoc */
2082    protected function getGroupName() {
2083        return 'wiki';
2084    }
2085
2086    /** @inheritDoc */
2087    public function outputHeader( $summaryMsg = '' ) {
2088        // Allow users to add a custom nav bar (T138284)
2089        $navBar = $this->msg( 'centralnotice-navbar' )->inContentLanguage();
2090        if ( !$navBar->isDisabled() ) {
2091            $this->getOutput()->addHTML( $navBar->parseAsBlock() );
2092        }
2093        parent::outputHeader( $summaryMsg );
2094    }
2095}