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