Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.60% |
7 / 1169 |
|
6.38% |
3 / 47 |
CRAP | |
0.00% |
0 / 1 |
CentralNotice | |
0.60% |
7 / 1169 |
|
6.38% |
3 / 47 |
38701.95 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
90 | |||
outputEnclosingDivStartTag | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
outputEnclosingDivEndTag | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
outputListOfNotices | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
30 | |||
handleNoticePostFromList | |
0.00% |
0 / 46 |
|
0.00% |
0 / 1 |
156 | |||
dateSelector | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
12 | |||
timeSelectorTd | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
timeSelector | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
campaignTypeSelector | |
0.00% |
0 / 40 |
|
0.00% |
0 / 1 |
132 | |||
prioritySelector | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
20 | |||
createSelector | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
addNoticeForm | |
0.00% |
0 / 81 |
|
0.00% |
0 / 1 |
12 | |||
handleAddCampaignPost | |
0.00% |
0 / 39 |
|
0.00% |
0 / 1 |
72 | |||
getDateTime | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
outputNoticeDetail | |
0.00% |
0 / 77 |
|
0.00% |
0 / 1 |
272 | |||
handleNoticeDetailPost | |
0.00% |
0 / 131 |
|
0.00% |
0 / 1 |
1122 | |||
displayCampaignWarnings | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
noticeDetailForm | |
0.00% |
0 / 212 |
|
0.00% |
0 / 1 |
132 | |||
makeNoticeMixinControlName | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
assignedTemplatesForm | |
0.00% |
0 / 115 |
|
0.00% |
0 / 1 |
110 | |||
weightDropdown | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
bucketDropdown | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
42 | |||
numBucketsDropdown | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
addTemplatesForm | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
6 | |||
languageMultiSelector | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
20 | |||
projectMultiSelector | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
12 | |||
dropdownList | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
makeSummaryField | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
6 | |||
getSummaryFromRequest | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
paddedRange | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
showError | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
geoMultiSelectorTree | |
0.00% |
0 / 105 |
|
0.00% |
0 / 1 |
42 | |||
sanitizeSearchTerms | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
truncateSummaryField | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getAssociatedNavigationLinks | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getShortDescription | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getCNSessionVar | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
setCNSessionVar | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
listProjects | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
listCountriesRegions | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
30 | |||
listLanguages | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
makeShortList | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
listToArray | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
outputHeader | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | use MediaWiki\Html\Html; |
4 | use MediaWiki\MediaWikiServices; |
5 | use MediaWiki\SpecialPage\UnlistedSpecialPage; |
6 | |
7 | class 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 | } |