Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.14% covered (warning)
50.14%
350 / 698
47.06% covered (danger)
47.06%
32 / 68
CRAP
0.00% covered (danger)
0.00%
0 / 1
Banner
50.14% covered (warning)
50.14%
350 / 698
47.06% covered (danger)
47.06%
32 / 68
3580.98
0.00% covered (danger)
0.00%
0 / 1
 fromId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 fromName
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 newFromName
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 getId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getName
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 allocateToAnon
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 allocateToLoggedIn
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setAllocation
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getCategory
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setCategory
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getAllUsedCategories
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 sanitizeRenderedCategory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isArchived
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isTemplate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setIsTemplate
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 populateBasicData
74.36% covered (warning)
74.36%
29 / 39
0.00% covered (danger)
0.00%
0 / 1
6.61
 setBasicDataDirty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 initializeDbBasicData
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 saveBasicData
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 getDevices
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setDevices
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
5.06
 populateDeviceTargetData
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 markDeviceTargetDataDirty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveDeviceTargetData
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 getMixins
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setMixins
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
4.77
 populateMixinData
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
4.41
 markMixinDataDirty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveMixinData
36.84% covered (danger)
36.84%
7 / 19
0.00% covered (danger)
0.00%
0 / 1
8.03
 getPriorityLanguages
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setPriorityLanguages
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 populatePriorityLanguageData
23.53% covered (danger)
23.53%
4 / 17
0.00% covered (danger)
0.00%
0 / 1
16.18
 markPriorityLanguageDataDirty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 savePriorityLanguageData
5.26% covered (danger)
5.26%
1 / 19
0.00% covered (danger)
0.00%
0 / 1
36.61
 getDbKey
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCampaignNames
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
6
 getIncludedTemplates
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBodyContent
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setBodyContent
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 populateBodyContent
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 markBodyContentDirty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveBodyContent
64.00% covered (warning)
64.00%
16 / 25
0.00% covered (danger)
0.00%
0 / 1
7.68
 getMessageField
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMessageFieldsFromCache
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 invalidateCache
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getMessageFieldsCacheKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 extractMessageFields
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 getAvailableLanguages
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 save
68.18% covered (warning)
68.18%
15 / 22
0.00% covered (danger)
0.00%
0 / 1
5.81
 initializeDbForNewBanner
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveBannerInternal
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 archive
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 cloneBanner
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
42
 remove
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeBanner
51.72% covered (warning)
51.72%
15 / 29
0.00% covered (danger)
0.00%
0 / 1
7.81
 addTag
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 removeTag
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getCampaignBanners
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 1
20
 getBannerSettings
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 getHistoricalBanner
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
6
 addFromBannerTemplate
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
2.00
 addBanner
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
7.23
 logBannerChange
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
5
 isValidBannerName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 exists
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
4.25
 getMessageFieldForBanner
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 protectBannerContent
73.91% covered (warning)
73.91%
17 / 23
0.00% covered (danger)
0.00%
0 / 1
5.44
1<?php
2/**
3 * This file is part of the CentralNotice Extension to MediaWiki
4 * https://www.mediawiki.org/wiki/Extension:CentralNotice
5 *
6 * @section LICENSE
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; either version 2 of the License, or
10 * (at your option) any later version.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU General Public License for more details.
16 *
17 * You should have received a copy of the GNU General Public License along
18 * with this program; if not, write to the Free Software Foundation, Inc.,
19 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 * http://www.gnu.org/copyleft/gpl.html
21 *
22 * @file
23 */
24
25use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
26use MediaWiki\Extension\Translate\Services;
27use MediaWiki\MediaWikiServices;
28use MediaWiki\Revision\SlotRecord;
29use Wikimedia\Rdbms\IDatabase;
30
31/**
32 * CentralNotice banner object. Banners are pieces of rendered wikimarkup
33 * injected as HTML onto MediaWiki pages via the sitenotice hook.
34 *
35 * - They are allowed to be specific to devices and user status.
36 * - They allow 'mixins', pieces of javascript that add additional standard
37 *   functionality to the banner.
38 * - They have a concept of 'messages' which are translatable strings marked
39 *   out by {{{name}}} in the banner body.
40 *
41 * @see BannerMessage
42 * @see BannerRenderer
43 * @see BannerMixin
44 */
45class Banner {
46    /** Indicates a revision of a banner that can be translated. */
47    public const TRANSLATE_BANNER_TAG = 'banner:translate';
48
49    /**
50     * Keys indicate a group of properties (which should be a 1-to-1 match to
51     * a database table.) If the value is null it means the data is not yet
52     * loaded. True means the data is clean and not modified. False means the
53     * data should be saved on the next call to save().
54     *
55     * Most functions should only ever set the flag to true; flags will be
56     * reset to false in save().
57     *
58     * @var (null|bool)[]
59     */
60    private $dirtyFlags = [
61        'content' => null,
62        'messages' => null,
63        'basic' => null,
64        'devices' => null,
65        'mixins' => null,
66        'prioritylang' => null,
67    ];
68
69    /** @var int Unique database identifier key. */
70    private $id = null;
71
72    /** @var string Unique human friendly name of banner. */
73    private $name = null;
74
75    /** @var bool True if the banner should be allocated to anonymous users. */
76    private $allocateAnon = false;
77
78    /** @var bool True if the banner should be allocated to logged in users. */
79    private $allocateLoggedIn = false;
80
81    /** @var string Category that the banner belongs to. Will be special value expanded. */
82    private $category = '{{{campaign}}}';
83
84    /** @var bool True if archived and hidden from default view. */
85    private $archived = false;
86
87    /** @var string[] Devices this banner should be allocated to in the form
88     * {Device ID => Device header name}
89     */
90    private $devices = [];
91
92    /** @var string[] Names of enabled mixins */
93    private $mixins = [];
94
95    /** @var string[] Language codes considered a priority for translation. */
96    private $priorityLanguages = [];
97
98    /** @var string Wikitext content of the banner */
99    private $bodyContent = '';
100
101    /** @var bool */
102    private $runTranslateJob = false;
103
104    /** @var bool Is banner meant to be used as a template for other banners */
105    private $template = false;
106
107    /**
108     * Create a banner object from a known ID. Must already be
109     * an object in the database. If a fully new banner is to be created
110     * use @see newFromName().
111     *
112     * @param int $id Unique database ID of the banner
113     *
114     * @return self
115     */
116    public static function fromId( $id ) {
117        $obj = new self();
118        $obj->id = $id;
119        return $obj;
120    }
121
122    /**
123     * Create a banner object from a known banner name. Must already be
124     * an object in the database. If a fully new banner is to be created
125     * use @see newFromName().
126     *
127     * @param string $name
128     *
129     * @return self
130     * @throws BannerDataException
131     */
132    public static function fromName( $name ) {
133        if ( !self::isValidBannerName( $name ) ) {
134            throw new BannerDataException( "Invalid banner name supplied." );
135        }
136
137        $obj = new self();
138        $obj->name = $name;
139        return $obj;
140    }
141
142    /**
143     * Create a brand new banner object.
144     *
145     * @param string $name
146     *
147     * @return self
148     * @throws BannerDataException
149     */
150    public static function newFromName( $name ) {
151        if ( !self::isValidBannerName( $name ) ) {
152            throw new BannerDataException( "Invalid banner name supplied." );
153        }
154
155        $obj = new self();
156        $obj->name = $name;
157
158        foreach ( $obj->dirtyFlags as $flag => &$value ) {
159            $value = true;
160        }
161
162        return $obj;
163    }
164
165    /**
166     * Get the unique ID for this banner.
167     *
168     * @return int
169     */
170    public function getId() {
171        $this->populateBasicData();
172        return $this->id;
173    }
174
175    /**
176     * Get the unique name for this banner.
177     *
178     * This specifically does not include namespace or other prefixing.
179     *
180     * @return null|string
181     */
182    public function getName() {
183        // Optimization: Speed up BannerMessageGroup's getKeys and getDefinitions.
184        //
185        // BannerMessageGroup calls self::fromName which populates $this->name.
186        // Translate calls BannerMessageGroup::getKeys(), which calls
187        // self::getMessageFieldsFromCache to load the message keys.
188        // self::getMessageFieldsFromCache calls self::getMessageFieldsCacheKey which calls
189        // this method. self::populateBasicData does a database query, which is not needed
190        // if we only need to know the banner name, which we already have.
191        if ( $this->name === null ) {
192            $this->populateBasicData();
193        }
194        return $this->name;
195    }
196
197    /**
198     * Should we allocate this banner to anonymous users.
199     *
200     * @return bool
201     */
202    public function allocateToAnon() {
203        $this->populateBasicData();
204        return $this->allocateAnon;
205    }
206
207    /**
208     * Should we allocate this banner to logged in users.
209     *
210     * @return bool
211     */
212    public function allocateToLoggedIn() {
213        $this->populateBasicData();
214        return $this->allocateLoggedIn;
215    }
216
217    /**
218     * Set user state allocation properties for this banner
219     *
220     * @param bool $anon Should the banner be allocated to logged out users.
221     * @param bool $loggedIn Should the banner be allocated to logged in users.
222     *
223     * @return $this
224     */
225    public function setAllocation( $anon, $loggedIn ) {
226        $this->populateBasicData();
227
228        if ( ( $this->allocateAnon !== $anon ) || ( $this->allocateLoggedIn !== $loggedIn ) ) {
229            $this->setBasicDataDirty();
230            $this->allocateAnon = $anon;
231            $this->allocateLoggedIn = $loggedIn;
232        }
233
234        return $this;
235    }
236
237    /**
238     * Get the banner category.
239     *
240     * The category is the name of the cookie stored on the users computer. In this way
241     * banners in the same category may share settings.
242     *
243     * @return string
244     */
245    public function getCategory() {
246        $this->populateBasicData();
247        return $this->category;
248    }
249
250    /**
251     * Set the banner category.
252     *
253     * @see Banner->getCategory()
254     *
255     * @param string $value
256     *
257     * @return $this
258     */
259    public function setCategory( $value ) {
260        $this->populateBasicData();
261
262        if ( $this->category !== $value ) {
263            $this->setBasicDataDirty();
264            $this->category = $value;
265        }
266
267        return $this;
268    }
269
270    /**
271     * Obtain an array of all categories currently seen attached to banners
272     * @return string[]
273     */
274    public static function getAllUsedCategories() {
275        $db = CNDatabase::getDb();
276        $res = $db->select(
277            'cn_templates',
278            'tmp_category',
279            '',
280            __METHOD__,
281            [ 'DISTINCT', 'ORDER BY tmp_category ASC' ]
282        );
283
284        $categories = [];
285        foreach ( $res as $row ) {
286            $categories[$row->tmp_category] = $row->tmp_category;
287        }
288        return $categories;
289    }
290
291    /**
292     * Remove invalid characters from a category string that has been magic
293     * word expanded.
294     *
295     * @param string $cat Category string to sanitize
296     *
297     * @return string
298     */
299    public static function sanitizeRenderedCategory( $cat ) {
300        return preg_replace( '/[^a-zA-Z0-9_]/', '', $cat );
301    }
302
303    /**
304     * Should the banner be considered archived and hidden from default view
305     *
306     * @return bool
307     */
308    public function isArchived() {
309        $this->populateBasicData();
310        return $this->archived;
311    }
312
313    /**
314     * Is this banner meant to be used as a template for other banners
315     *
316     * @return bool
317     * @throws BannerDataException
318     * @throws BannerExistenceException
319     */
320    public function isTemplate() {
321        $this->populateBasicData();
322        return $this->template;
323    }
324
325    /**
326     * Mark banner as a template
327     *
328     * @param bool $value
329     * @throws BannerDataException
330     * @throws BannerExistenceException
331     */
332    public function setIsTemplate( $value ) {
333        $this->populateBasicData();
334        if ( $this->template !== $value ) {
335            $this->setBasicDataDirty();
336            $this->template = $value;
337        }
338    }
339
340    /**
341     * Populates basic banner data by querying the cn_templates table
342     *
343     * @throws BannerDataException If neither a name or ID can be used to query for data
344     * @throws BannerExistenceException If no banner data was received
345     */
346    private function populateBasicData() {
347        if ( $this->dirtyFlags['basic'] !== null ) {
348            return;
349        }
350
351        $db = CNDatabase::getDb();
352
353        // What are we using to select on?
354        if ( $this->name !== null ) {
355            $selector = [ 'tmp_name' => $this->name ];
356        } elseif ( $this->id !== null ) {
357            $selector = [ 'tmp_id' => $this->id ];
358        } else {
359            throw new BannerDataException( 'Cannot retrieve banner data without name or ID.' );
360        }
361
362        // Query!
363        $rowRes = $db->select(
364            [ 'templates' => 'cn_templates' ],
365            [
366                'tmp_id',
367                'tmp_name',
368                'tmp_display_anon',
369                'tmp_display_account',
370                'tmp_archived',
371                'tmp_category',
372                'tmp_is_template'
373            ],
374            $selector,
375            __METHOD__
376        );
377
378        // Extract the dataz!
379        $row = $rowRes->fetchObject();
380        if ( $row ) {
381            $this->id = (int)$row->tmp_id;
382            $this->name = $row->tmp_name;
383            $this->allocateAnon = (bool)$row->tmp_display_anon;
384            $this->allocateLoggedIn = (bool)$row->tmp_display_account;
385            $this->archived = (bool)$row->tmp_archived;
386            $this->category = $row->tmp_category;
387            $this->template = (bool)$row->tmp_is_template;
388        } else {
389            $keystr = [];
390            foreach ( $selector as $key => $value ) {
391                $keystr[] = "{$key} = {$value}";
392            }
393            $keystr = implode( " AND ", $keystr );
394            throw new BannerExistenceException(
395                "No banner exists where {$keystr}. Could not load."
396            );
397        }
398
399        // Set the dirty flag to not dirty because we just loaded clean data
400        $this->setBasicDataDirty( false );
401    }
402
403    /**
404     * Sets the flag which will save basic metadata on next save()
405     * @param bool $dirty
406     */
407    private function setBasicDataDirty( $dirty = true ) {
408        $this->dirtyFlags['basic'] = $dirty;
409    }
410
411    /**
412     * Helper function to initializeDbForNewBanner()
413     *
414     * @param IDatabase $db
415     */
416    private function initializeDbBasicData( IDatabase $db ) {
417        $db->newInsertQueryBuilder()
418            ->insertInto( 'cn_templates' )
419            ->row( [ 'tmp_name' => $this->name ] )
420            ->caller( __METHOD__ )
421            ->execute();
422        $this->id = $db->insertId();
423    }
424
425    /**
426     * Helper function to saveBannerInternal() for saving basic banner metadata
427     * @param IDatabase $db
428     */
429    private function saveBasicData( IDatabase $db ) {
430        if ( $this->dirtyFlags['basic'] ) {
431            $db->newUpdateQueryBuilder()
432                ->update( 'cn_templates' )
433                ->set( [
434                    'tmp_display_anon'    => (int)$this->allocateAnon,
435                    'tmp_display_account' => (int)$this->allocateLoggedIn,
436                    'tmp_archived'        => (int)$this->archived,
437                    'tmp_category'        => $this->category,
438                    'tmp_is_template'     => (int)$this->template
439                ] )
440                ->where( [
441                    'tmp_id'              => $this->id
442                ] )
443                ->caller( __METHOD__ )
444                ->execute();
445        }
446    }
447
448    /**
449     * Get the devices that this banner should be allocated to.
450     *
451     * Array is in the form of {Device internal ID => Device header name}
452     *
453     * @return string[]
454     */
455    public function getDevices() {
456        $this->populateDeviceTargetData();
457        return $this->devices;
458    }
459
460    /**
461     * Set the devices that this banner should be allocated to.
462     *
463     * @param string[]|string $devices Header name of devices. E.g. {'android', 'desktop'}
464     *
465     * @return $this
466     * @throws BannerDataException on unknown device header name.
467     */
468    public function setDevices( $devices ) {
469        $this->populateDeviceTargetData();
470
471        $knownDevices = CNDeviceTarget::getAvailableDevices( true );
472
473        $devices = (array)$devices;
474        $devices = array_unique( array_values( $devices ) );
475        sort( $devices );
476
477        if ( $devices != $this->devices ) {
478            $this->devices = [];
479
480            foreach ( $devices as $device ) {
481                if ( !$device ) {
482                    // Empty...
483                    continue;
484                } elseif ( !array_key_exists( $device, $knownDevices ) ) {
485                    throw new BannerDataException( "Device name '$device' not known! Cannot add." );
486                } else {
487                    $this->devices[$knownDevices[$device]['id']] = $device;
488                }
489            }
490            $this->markDeviceTargetDataDirty();
491        }
492
493        return $this;
494    }
495
496    /**
497     * Populates device targeting data by querying the cn_template_devices table.
498     *
499     * @see CNDeviceTarget for more information about mapping.
500     */
501    private function populateDeviceTargetData() {
502        if ( $this->dirtyFlags['devices'] !== null ) {
503            return;
504        }
505
506        $db = CNDatabase::getDb();
507
508        $rowObj = $db->select(
509            [
510                'tdev' => 'cn_template_devices',
511                'devices' => 'cn_known_devices'
512            ],
513            [ 'devices.dev_id', 'dev_name' ],
514            [
515                'tdev.tmp_id' => $this->getId(),
516                'tdev.dev_id = devices.dev_id'
517            ],
518            __METHOD__
519        );
520
521        foreach ( $rowObj as $row ) {
522            $this->devices[ intval( $row->dev_id ) ] = $row->dev_name;
523        }
524
525        $this->markDeviceTargetDataDirty( false );
526    }
527
528    /**
529     * Sets the flag which will force saving of device targeting data on next save()
530     * @param bool $dirty
531     */
532    private function markDeviceTargetDataDirty( $dirty = true ) {
533        $this->dirtyFlags['devices'] = $dirty;
534    }
535
536    /**
537     * Helper function to saveBannerInternal()
538     *
539     * @param IDatabase $db
540     */
541    private function saveDeviceTargetData( IDatabase $db ) {
542        if ( $this->dirtyFlags['devices'] ) {
543            // Remove all entries from the table for this banner
544            $db->newDeleteQueryBuilder()
545                ->deleteFrom( 'cn_template_devices' )
546                ->where( [ 'tmp_id' => $this->getId() ] )
547                ->caller( __METHOD__ )
548                ->execute();
549
550            // Add the new device mappings
551            if ( $this->devices ) {
552                $modifyArray = [];
553                foreach ( $this->devices as $deviceId => $deviceName ) {
554                    $modifyArray[] = [ 'tmp_id' => $this->getId(), 'dev_id' => $deviceId ];
555                }
556                $db->newInsertQueryBuilder()
557                    ->insertInto( 'cn_template_devices' )
558                    ->rows( $modifyArray )
559                    ->caller( __METHOD__ )
560                    ->execute();
561            }
562        }
563    }
564
565    /**
566     * @return array Keys are names of enabled mixins; valeus are mixin params.
567     * @see $wgCentralNoticeBannerMixins
568     * TODO: Remove. See T225831.
569     */
570    public function getMixins() {
571        $this->populateMixinData();
572        return $this->mixins;
573    }
574
575    /**
576     * Set the banner mixins to enable.
577     *
578     * @param array $mixins Names of mixins to enable on this banner. Valid values
579     * come from @see $wgCentralNoticeBannerMixins
580     *
581     * @throws RangeException
582     * @return $this
583     */
584    public function setMixins( $mixins ) {
585        global $wgCentralNoticeBannerMixins;
586
587        $this->populateMixinData();
588
589        $mixins = array_unique( $mixins );
590        sort( $mixins );
591
592        if ( $this->mixins != $mixins ) {
593            $this->markMixinDataDirty();
594        }
595
596        $this->mixins = [];
597        foreach ( $mixins as $mixin ) {
598            if ( !array_key_exists( $mixin, $wgCentralNoticeBannerMixins ) ) {
599                throw new RangeException( "Mixin does not exist: {$mixin}" );
600            }
601            $this->mixins[$mixin] = $wgCentralNoticeBannerMixins[$mixin];
602        }
603
604        return $this;
605    }
606
607    /**
608     * Populates mixin data from the cn_template_mixins table.
609     */
610    private function populateMixinData() {
611        global $wgCentralNoticeBannerMixins;
612
613        if ( $this->dirtyFlags['mixins'] !== null ) {
614            return;
615        }
616
617        $dbr = CNDatabase::getDb();
618
619        $result = $dbr->select( 'cn_template_mixins', 'mixin_name',
620            [
621                "tmp_id" => $this->getId(),
622            ],
623            __METHOD__
624        );
625
626        $this->mixins = [];
627        foreach ( $result as $row ) {
628            if ( !array_key_exists( $row->mixin_name, $wgCentralNoticeBannerMixins ) ) {
629                // We only want to warn here otherwise we'd never be able to
630                // edit the banner to fix the issue! The editor should warn
631                // when a deprecated mixin is being used; but also when we
632                // do deprecate something we should make sure nothing is using
633                // it!
634                wfLogWarning(
635                    "Mixin does not exist: {$row->mixin_name}, included from banner {$this->name}"
636                );
637            }
638            $this->mixins[$row->mixin_name] = $wgCentralNoticeBannerMixins[$row->mixin_name];
639        }
640
641        $this->markMixinDataDirty( false );
642    }
643
644    /**
645     * Sets the flag which will force saving of mixin data upon next save()
646     * @param bool $dirty
647     */
648    private function markMixinDataDirty( $dirty = true ) {
649        $this->dirtyFlags['mixins'] = $dirty;
650    }
651
652    /**
653     * @param IDatabase $db
654     */
655    private function saveMixinData( IDatabase $db ) {
656        if ( $this->dirtyFlags['mixins'] ) {
657            $db->newDeleteQueryBuilder()
658                ->deleteFrom( 'cn_template_mixins' )
659                ->where( [ 'tmp_id' => $this->getId() ] )
660                ->caller( __METHOD__ )
661                ->execute();
662
663            foreach ( $this->mixins as $name => $params ) {
664                $name = trim( $name );
665                if ( !$name ) {
666                    continue;
667                }
668                $db->newInsertQueryBuilder()
669                    ->insertInto( 'cn_template_mixins' )
670                    ->row( [
671                        'tmp_id' => $this->getId(),
672                        'page_id' => 0,    // TODO: What were we going to use this for again?
673                        'mixin_name' => $name,
674                    ] )
675                    ->caller( __METHOD__ )
676                    ->execute();
677            }
678        }
679    }
680
681    /**
682     * Returns language codes that are considered a priority for translations.
683     *
684     * If a language is in this list it means that the translation UI will promote
685     * translating them, and discourage translating other languages.
686     *
687     * @return string[]
688     */
689    public function getPriorityLanguages() {
690        $this->populatePriorityLanguageData();
691        return $this->priorityLanguages;
692    }
693
694    /**
695     * Set language codes that should be considered a priority for translation.
696     *
697     * If a language is in this list it means that the translation UI will promote
698     * translating them, and discourage translating other languages.
699     *
700     * @param string[] $languageCodes
701     *
702     * @return $this
703     */
704    public function setPriorityLanguages( $languageCodes ) {
705        $this->populatePriorityLanguageData();
706
707        $languageCodes = array_unique( (array)$languageCodes );
708        sort( $languageCodes );
709
710        if ( $this->priorityLanguages != $languageCodes ) {
711            $this->priorityLanguages = $languageCodes;
712            $this->markPriorityLanguageDataDirty();
713        }
714
715        return $this;
716    }
717
718    private function populatePriorityLanguageData() {
719        global $wgNoticeUseTranslateExtension;
720
721        if ( $this->dirtyFlags['prioritylang'] !== null ) {
722            return;
723        }
724
725        if ( $wgNoticeUseTranslateExtension ) {
726            $services = Services::getInstance();
727            if ( method_exists( $services, 'getMessageGroupMetadata' ) ) {
728                $langs = $services->getMessageGroupMetadata()->get(
729                    BannerMessageGroup::getTranslateGroupName( $this->getName() ),
730                    'prioritylangs'
731                );
732            } else {
733                // @phan-suppress-next-line PhanUndeclaredClassMethod
734                $langs = TranslateMetadata::get(
735                    BannerMessageGroup::getTranslateGroupName( $this->getName() ),
736                    'prioritylangs'
737                );
738            }
739            if ( !$langs ) {
740                // If priority langs is not set; MessageGroupMetadata::get will return false
741                $langs = '';
742            }
743            $this->priorityLanguages = explode( ',', $langs );
744        }
745        $this->markPriorityLanguageDataDirty( false );
746    }
747
748    private function markPriorityLanguageDataDirty( $dirty = true ) {
749        $this->dirtyFlags['prioritylang'] = $dirty;
750    }
751
752    private function savePriorityLanguageData() {
753        global $wgNoticeUseTranslateExtension;
754
755        if ( $wgNoticeUseTranslateExtension && $this->dirtyFlags['prioritylang'] ) {
756            $groupName = BannerMessageGroup::getTranslateGroupName( $this->getName() );
757
758            $services = Services::getInstance();
759            if ( method_exists( $services, 'getMessageGroupMetadata' ) ) {
760                $messageGroupMetadata = $services->getMessageGroupMetadata();
761
762                if ( $this->priorityLanguages === [] ) {
763                    // Using false to delete the value instead of writing empty content
764                    $messageGroupMetadata->set( $groupName, 'prioritylangs', false );
765                } else {
766                    $messageGroupMetadata->set(
767                        $groupName,
768                        'prioritylangs',
769                        implode( ',', $this->priorityLanguages )
770                    );
771                }
772            } else {
773                if ( $this->priorityLanguages === [] ) {
774                    // Using false to delete the value instead of writing empty content
775                    // @phan-suppress-next-line PhanUndeclaredClassMethod
776                    TranslateMetadata::set( $groupName, 'prioritylangs', false );
777                } else {
778                    // @phan-suppress-next-line PhanUndeclaredClassMethod
779                    TranslateMetadata::set(
780                        $groupName,
781                        'prioritylangs',
782                        implode( ',', $this->priorityLanguages )
783                    );
784                }
785            }
786        }
787    }
788
789    public function getDbKey() {
790        $name = $this->getName();
791        return "Centralnotice-template-{$name}";
792    }
793
794    public function getTitle() {
795        return Title::newFromText( $this->getDbKey(), NS_MEDIAWIKI );
796    }
797
798    /**
799     * Return the names of campaigns that this banner is currently used in.
800     *
801     * @return string[]
802     */
803    public function getCampaignNames() {
804        $dbr = CNDatabase::getDb();
805
806        $result = $dbr->select(
807            [
808                'notices' => 'cn_notices',
809                'assignments' => 'cn_assignments',
810            ],
811            'notices.not_name',
812            [
813                'assignments.tmp_id' => $this->getId(),
814            ],
815            __METHOD__,
816            [],
817            [
818                'assignments' =>
819                [
820                    'INNER JOIN', 'notices.not_id = assignments.not_id'
821                ]
822            ]
823        );
824
825        $campaigns = [];
826        foreach ( $result as $row ) {
827            $campaigns[] = $row->not_name;
828        }
829
830        return $campaigns;
831    }
832
833    /**
834     * Returns an array of Title objects that have been included as templates
835     * in this banner.
836     *
837     * @return Title[]
838     */
839    public function getIncludedTemplates() {
840        return $this->getTitle()->getTemplateLinksFrom();
841    }
842
843    /**
844     * Get the raw body HTML for the banner.
845     *
846     * @return string HTML
847     */
848    public function getBodyContent() {
849        $this->populateBodyContent();
850        return $this->bodyContent;
851    }
852
853    /**
854     * Set the raw body HTML for the banner.
855     *
856     * @param string $text HTML
857     *
858     * @return $this
859     */
860    public function setBodyContent( $text ) {
861        $this->populateBodyContent();
862
863        if ( $this->bodyContent !== $text ) {
864            $this->bodyContent = $text;
865            $this->markBodyContentDirty();
866        }
867
868        return $this;
869    }
870
871    private function populateBodyContent() {
872        if ( $this->dirtyFlags['content'] !== null ) {
873            return;
874        }
875
876        $curRev = MediaWikiServices::getInstance()
877            ->getRevisionLookup()
878            ->getRevisionByTitle( $this->getTitle() );
879        if ( !$curRev ) {
880            throw new BannerContentException( "No content for banner: {$this->name}" );
881        }
882
883        $content = $curRev->getContent( SlotRecord::MAIN );
884        $this->bodyContent = ( $content instanceof TextContent ) ? $content->getText() : null;
885
886        $this->markBodyContentDirty( false );
887    }
888
889    /**
890     * @param bool $dirty If true, we're storing a flag that means the
891     * in-memory banner content is newer than what's stored in the database.
892     * If false, we're clearing that bit.
893     */
894    private function markBodyContentDirty( $dirty = true ) {
895        $this->dirtyFlags['content'] = $dirty;
896    }
897
898    /**
899     * @param string|null $summary
900     * @param User $user
901     */
902    private function saveBodyContent( $summary, User $user ) {
903        global $wgNoticeUseTranslateExtension;
904
905        if ( $this->dirtyFlags['content'] ) {
906            $wikiPage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $this->getTitle() );
907
908            if ( $summary === null ) {
909                $summary = '';
910            }
911
912            $contentObj = ContentHandler::makeContent( $this->bodyContent, $wikiPage->getTitle() );
913
914            $tags = [ 'centralnotice' ];
915            $pageResult = $wikiPage->doUserEditContent(
916                $contentObj,
917                $user,
918                $summary,
919                EDIT_FORCE_BOT,
920                false, // $originalRevId
921                $tags
922            );
923
924            self::protectBannerContent( $wikiPage, $user );
925
926            if ( $wgNoticeUseTranslateExtension ) {
927                // Get the revision and page ID of the page that was created/modified
928                // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
929                if ( $pageResult->value['revision-record'] ) {
930                    // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
931                    $revisionRecord = $pageResult->value['revision-record'];
932                    $revisionId = $revisionRecord->getId();
933                    $pageId = $revisionRecord->getPageId();
934
935                    // If the banner includes translatable messages, tag it for translation
936                    $fields = $this->extractMessageFields();
937                    if ( count( $fields ) > 0 ) {
938                        // Tag the banner for translation
939                        self::addTag( self::TRANSLATE_BANNER_TAG, $revisionId, $pageId, (string)$this->getId() );
940                        $this->runTranslateJob = true;
941                    }
942                    $this->invalidateCache( $fields );
943                }
944            }
945        }
946    }
947
948    public function getMessageField( $field_name ) {
949        return new BannerMessage( $this->getName(), $field_name );
950    }
951
952    /**
953     * Returns all the message fields in a banner
954     *
955     * Check the cache first, then calculate if necessary.  Will always prefer the cache.
956     * @see Banner::extractMessageFields()
957     *
958     * @return array
959     */
960    public function getMessageFieldsFromCache() {
961        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
962        $key = $this->getMessageFieldsCacheKey( $cache );
963
964        return $cache->getWithSetCallback(
965            $key,
966            $cache::TTL_MONTH,
967            function () {
968                return $this->extractMessageFields();
969            },
970            [ 'checkKeys' => [ $key ], 'lockTSE' => 60 ]
971        );
972    }
973
974    /**
975     * @param array|null $newFields Optional new result of the extractMessageFields()
976     */
977    public function invalidateCache( $newFields = null ) {
978        // Update cache after the DB transaction finishes
979        CNDatabase::getDb()->onTransactionCommitOrIdle(
980            function () use ( $newFields ) {
981                $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
982                $key = $this->getMessageFieldsCacheKey( $cache );
983
984                $cache->touchCheckKey( $key );
985                if ( $newFields !== null ) {
986                    // May as well set the new volatile cache value in the local datacenter
987                    $cache->set( $key, $newFields, $cache::TTL_MONTH );
988                }
989            },
990            __METHOD__
991        );
992    }
993
994    /**
995     * @param WANObjectCache $cache
996     * @return mixed
997     */
998    private function getMessageFieldsCacheKey( $cache ) {
999        return $cache->makeKey( 'centralnotice', 'bannerfields', $this->getName() );
1000    }
1001
1002    /**
1003     * Extract the raw fields and field names from the banner body source.
1004     *
1005     * Always recalculate.  If you want the cached value, please use getMessageFieldsFromCache.
1006     *
1007     * @return array
1008     */
1009    public function extractMessageFields() {
1010        $parser = MediaWikiServices::getInstance()->getParser();
1011
1012        $expanded = $parser->parse(
1013            $this->getBodyContent(), $this->getTitle(),
1014            ParserOptions::newFromContext( RequestContext::getMain() )
1015        )->getText();
1016
1017        // Also search the preload js for fields.
1018        $renderer = new BannerRenderer( RequestContext::getMain(), $this );
1019        $expanded .= $renderer->getPreloadJsRaw();
1020
1021        // Extract message fields from the banner body
1022        $fields = [];
1023        $allowedChars = Title::legalChars();
1024        // Janky custom syntax to pass arguments to a message, broken and unused:
1025        // "{{{fieldname:arg1|arg2}}}". This is why ':' is forbidden. FIXME: Remove.
1026        // Also forbid ',' so we can use it as a separator between banner name and
1027        // message name in banner content, and thus insert messages from other banners.
1028        // ' ' is forbidden to avoid problems in the UI.
1029        $allowedChars = str_replace( [ ':', ',', ' ' ], '', $allowedChars );
1030        preg_match_all( "/{{{([$allowedChars]+)(:[^}]*)?}}}/u", $expanded, $fields );
1031
1032        // Remove duplicate keys and count occurrences
1033        $unique_fields = array_unique( array_flip( $fields[1] ) );
1034        $fields = array_intersect_key( array_count_values( $fields[1] ), $unique_fields );
1035
1036        $fields = array_diff_key( $fields, array_flip( $renderer->getMagicWords() ) );
1037
1038        return $fields;
1039    }
1040
1041    /**
1042     * Returns a list of messages that are either published or in the CNBanner translation
1043     *
1044     * @param bool $inTranslation If true and using group translation this will return
1045     * all the messages that are in the translation system
1046     *
1047     * @return array A list of languages with existing field translations
1048     */
1049    public function getAvailableLanguages( $inTranslation = false ) {
1050        global $wgLanguageCode;
1051        $availableLangs = [];
1052
1053        // Bit of an ugly hack to get just the banner prefix
1054        $prefix = $this->getMessageField( '' )
1055            ->getDbKey( null, $inTranslation ? NS_CN_BANNER : NS_MEDIAWIKI );
1056
1057        $db = CNDatabase::getDb();
1058        $result = $db->select( 'page',
1059            'page_title',
1060            [
1061                'page_namespace' => $inTranslation ? NS_CN_BANNER : NS_MEDIAWIKI,
1062                'page_title' . $db->buildLike( $prefix, $db->anyString() ),
1063            ],
1064            __METHOD__
1065        );
1066        foreach ( $result as $row ) {
1067            if (
1068                preg_match(
1069                    "/\Q{$prefix}\E([^\/]+)(?:\/([a-z_]+))?/", $row->page_title,
1070                    $matches
1071                )
1072            ) {
1073                $lang = $matches[2] ?? $wgLanguageCode;
1074                $availableLangs[$lang] = true;
1075            }
1076        }
1077        return array_keys( $availableLangs );
1078    }
1079
1080    /**
1081     * Saves any changes made to the banner object into the database
1082     *
1083     * @param User $user
1084     * @param string|null $summary Summary (comment) to associate with all changes,
1085     *   including banner content and messages (which are implemented as wiki
1086     *   pages).
1087     *
1088     * @return $this
1089     * @throws Exception
1090     */
1091    public function save( User $user, $summary = null ) {
1092        $db = CNDatabase::getDb();
1093        $action = 'modified';
1094
1095        try {
1096            // Don't move this to saveBannerInternal--can't be in a transaction
1097            // TODO: explain why not.  Is text in another database?
1098            $this->saveBodyContent( $summary, $user );
1099
1100            // Open a transaction so that everything is consistent
1101            $db->startAtomic( __METHOD__ );
1102
1103            if ( !$this->exists() ) {
1104                $action = 'created';
1105                $this->initializeDbForNewBanner( $db );
1106            }
1107            $this->saveBannerInternal( $db );
1108            $this->logBannerChange( $action, $user, $summary );
1109
1110            $db->endAtomic( __METHOD__ );
1111
1112            // Clear the dirty flags
1113            foreach ( $this->dirtyFlags as $flag => &$value ) {
1114                $value = false;
1115            }
1116
1117            if ( $this->runTranslateJob ) {
1118                // Must be run after banner has finished saving due to some dependencies that
1119                // exist in the render job.
1120                // TODO: This will go away if we start tracking messages in database :)
1121                MessageGroups::singleton()->recache();
1122                MediaWikiServices::getInstance()->getJobQueueGroup()->push(
1123                    MessageIndexRebuildJob::newJob()
1124                );
1125                $this->runTranslateJob = false;
1126            }
1127
1128        } catch ( Exception $ex ) {
1129            $db->rollback( __METHOD__ );
1130            throw $ex;
1131        }
1132
1133        return $this;
1134    }
1135
1136    /**
1137     * Called before saveBannerInternal() when a new to the database banner is
1138     * being saved. Intended to create all table rows required such that any
1139     * additional operation can be an UPDATE statement.
1140     *
1141     * @param IDatabase $db
1142     */
1143    private function initializeDbForNewBanner( IDatabase $db ) {
1144        $this->initializeDbBasicData( $db );
1145    }
1146
1147    /**
1148     * Helper function to save(). This is wrapped in a database transaction and
1149     * is intended to be easy to override -- though overriding function should
1150     * call this at some point. :)
1151     *
1152     * Because it is wrapped in a database transaction; most MediaWiki calls
1153     * like page saving cannot be performed here.
1154     *
1155     * Dirty flags are not globally reset until after this function is called.
1156     *
1157     * @param IDatabase $db
1158     *
1159     * @throws BannerExistenceException
1160     */
1161    private function saveBannerInternal( IDatabase $db ) {
1162        $this->saveBasicData( $db );
1163        $this->saveDeviceTargetData( $db );
1164        $this->saveMixinData( $db );
1165        $this->savePriorityLanguageData();
1166    }
1167
1168    /**
1169     * Archive a banner.
1170     *
1171     * TODO: Remove data from translation, in place replace all templates
1172     *
1173     * @return $this
1174     */
1175    public function archive() {
1176        if ( $this->dirtyFlags['basic'] === null ) {
1177            $this->populateBasicData();
1178        }
1179        $this->dirtyFlags['basic'] = true;
1180
1181        $this->archived = true;
1182
1183        return $this;
1184    }
1185
1186    public function cloneBanner( $destination, $user, $summary = null ) {
1187        if ( !self::isValidBannerName( $destination ) ) {
1188            throw new BannerDataException( "Banner name must be in format /^[A-Za-z0-9_]+$/" );
1189        }
1190
1191        $destBanner = self::newFromName( $destination );
1192        if ( $destBanner->exists() ) {
1193            throw new BannerExistenceException( "Banner by that name already exists!" );
1194        }
1195
1196        $destBanner->setAllocation( $this->allocateToAnon(), $this->allocateToLoggedIn() );
1197        $destBanner->setCategory( $this->getCategory() );
1198        $destBanner->setDevices( $this->getDevices() );
1199        $destBanner->setMixins( array_keys( $this->getMixins() ) );
1200        $destBanner->setPriorityLanguages( $this->getPriorityLanguages() );
1201
1202        $destBanner->setBodyContent( $this->getBodyContent() );
1203
1204        // Populate the message fields
1205        $langs = $this->getAvailableLanguages();
1206        $fields = $this->extractMessageFields();
1207        foreach ( $langs as $lang ) {
1208            foreach ( $fields as $field => $count ) {
1209                $text = $this->getMessageField( $field )->getContents( $lang );
1210                if ( $text !== null ) {
1211                    $destBanner->getMessageField( $field )
1212                        ->update( $text, $lang, $user, $summary );
1213                }
1214            }
1215        }
1216
1217        // Save it!
1218        $destBanner->save( $user, $summary );
1219        $this->invalidateCache( $fields );
1220
1221        return $destBanner;
1222    }
1223
1224    public function remove( User $user ) {
1225        self::removeBanner( $this->getName(), $user );
1226    }
1227
1228    public static function removeBanner( $name, $user, $summary = null ) {
1229        global $wgNoticeUseTranslateExtension;
1230
1231        $bannerObj = self::fromName( $name );
1232        $id = $bannerObj->getId();
1233        $dbr = CNDatabase::getDb();
1234        $res = $dbr->select( 'cn_assignments', 'asn_id', [ 'tmp_id' => $id ], __METHOD__ );
1235
1236        if ( $res->numRows() > 0 ) {
1237            throw new LogicException( 'Cannot remove a template still bound to a campaign!' );
1238        } else {
1239            // Log the removal of the banner
1240            // FIXME: this log line will display changes with inverted sense
1241            $bannerObj->logBannerChange( 'removed', $user, $summary );
1242
1243            // Delete banner record from the CentralNotice cn_templates table
1244            $dbw = CNDatabase::getDb();
1245            $dbw->newDeleteQueryBuilder()
1246                ->deleteFrom( 'cn_templates' )
1247                ->where( [ 'tmp_id' => $id ] )
1248                ->caller( __METHOD__ )
1249                ->execute();
1250
1251            // Delete the MediaWiki page that contains the banner source
1252            // TODO Inconsistency: deletion of banner content is not recorded
1253            // as a bot edit, so it does not appear on the CN logs page. Also,
1254            // related messages are not deleted.
1255            $wikiPage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $bannerObj->getTitle() );
1256            $wikiPage->doDeleteArticleReal( $summary ?: '', $user );
1257
1258            if ( $wgNoticeUseTranslateExtension ) {
1259                // Remove any revision tags related to the banner
1260                self::removeTag( self::TRANSLATE_BANNER_TAG, $wikiPage->getId() );
1261
1262                $services = Services::getInstance();
1263                // Add the preferred language metadata if it exists
1264                if ( method_exists( $services, 'getMessageGroupMetadata' ) ) {
1265                    $services->getMessageGroupMetadata()->set(
1266                        BannerMessageGroup::getTranslateGroupName( $name ),
1267                        'prioritylangs',
1268                        false
1269                    );
1270                // Add the preferred language metadata if it exists
1271                } else {
1272                    // @phan-suppress-next-line PhanUndeclaredClassMethod
1273                    TranslateMetadata::set(
1274                        BannerMessageGroup::getTranslateGroupName( $name ),
1275                        'prioritylangs',
1276                        false
1277                    );
1278                }
1279            }
1280        }
1281    }
1282
1283    /**
1284     * Add a revision tag for the banner
1285     * @param string $tag The name of the tag
1286     * @param int $revisionId ID of the revision
1287     * @param int $pageId ID of the MediaWiki page for the banner
1288     * @param string $bannerId ID of banner this revtag belongs to
1289     * @throws Exception
1290     */
1291    public static function addTag( $tag, $revisionId, $pageId, $bannerId ) {
1292        $dbw = CNDatabase::getDb();
1293
1294        if ( is_object( $revisionId ) ) {
1295            throw new LogicException( 'Got object, excepted id' );
1296        }
1297
1298        // There should only ever be one tag applied to a banner object
1299        self::removeTag( $tag, $pageId );
1300
1301        $conds = [
1302            'rt_page' => $pageId,
1303            'rt_type' => $tag,
1304            'rt_revision' => $revisionId
1305        ];
1306
1307        if ( $bannerId !== null ) {
1308            $conds['rt_value'] = $bannerId;
1309        }
1310
1311        $dbw->newInsertQueryBuilder()
1312            ->insertInto( 'revtag' )
1313            ->row( $conds )
1314            ->caller( __METHOD__ )
1315            ->execute();
1316    }
1317
1318    /**
1319     * Make sure banner is not tagged with specified tag
1320     * @param string $tag The name of the tag
1321     * @param int $pageId ID of the MediaWiki page for the banner
1322     * @throws Exception
1323     */
1324    private static function removeTag( $tag, $pageId ) {
1325        $dbw = CNDatabase::getDb();
1326
1327        $conds = [
1328            'rt_page' => $pageId,
1329            'rt_type' => $tag
1330        ];
1331        $dbw->newDeleteQueryBuilder()
1332            ->deleteFrom( 'revtag' )
1333            ->where( $conds )
1334            ->caller( __METHOD__ )
1335            ->execute();
1336    }
1337
1338    /**
1339     * Given one or more campaign ids, return all banners bound to them
1340     *
1341     * @param int|array $campaigns list of campaign numeric IDs
1342     *
1343     * @return array a 2D array of banners with associated weights and settings
1344     */
1345    public static function getCampaignBanners( $campaigns ) {
1346        $dbr = CNDatabase::getDb();
1347
1348        $banners = [];
1349
1350        if ( $campaigns ) {
1351            $res = $dbr->select(
1352                // Aliases (keys) are needed to avoid problems with table prefixes
1353                [
1354                    'notices' => 'cn_notices',
1355                    'templates' => 'cn_templates',
1356                    'known_devices' => 'cn_known_devices',
1357                    'template_devices' => 'cn_template_devices',
1358                    'assignments' => 'cn_assignments',
1359                ],
1360                [
1361                    'tmp_name',
1362                    'tmp_weight',
1363                    'tmp_display_anon',
1364                    'tmp_display_account',
1365                    'tmp_category',
1366                    'not_name',
1367                    'not_preferred',
1368                    'asn_bucket',
1369                    'not_buckets',
1370                    'not_throttle',
1371                    'dev_name',
1372                ],
1373                [
1374                    'notices.not_id' => $campaigns,
1375                    'notices.not_id = assignments.not_id',
1376                    'known_devices.dev_id = template_devices.dev_id',
1377                    'assignments.tmp_id = templates.tmp_id'
1378                ],
1379                __METHOD__,
1380                [],
1381                [
1382                    'template_devices' => [
1383                        'LEFT JOIN', 'template_devices.tmp_id = assignments.tmp_id'
1384                    ]
1385                ]
1386            );
1387
1388            foreach ( $res as $row ) {
1389                $banners[] = [
1390                    // name of the banner
1391                    'name'             => $row->tmp_name,
1392                    // weight assigned to the banner
1393                    'weight'           => intval( $row->tmp_weight ),
1394                    // display to anonymous users?
1395                    'display_anon'     => intval( $row->tmp_display_anon ),
1396                    // display to logged in users?
1397                    'display_account'  => intval( $row->tmp_display_account ),
1398                    // fundraising banner?
1399                    'fundraising'      => intval( $row->tmp_category === 'fundraising' ),
1400                    // device this banner can target
1401                    'device'           => $row->dev_name,
1402                    // campaign the banner is assigned to
1403                    'campaign'         => $row->not_name,
1404                    // z level of the campaign
1405                    'campaign_z_index' => $row->not_preferred,
1406                    'campaign_num_buckets' => intval( $row->not_buckets ),
1407                    'campaign_throttle' => intval( $row->not_throttle ),
1408                    'bucket'           => ( intval( $row->not_buckets ) == 1 )
1409                        ? 0 : intval( $row->asn_bucket ),
1410                ];
1411            }
1412        }
1413        return $banners;
1414    }
1415
1416    /**
1417     * Return settings for a banner
1418     *
1419     * @param string $bannerName name of banner
1420     * @param bool $detailed if true, get some more expensive info
1421     *
1422     * @return array an array of banner settings
1423     * @throws RangeException
1424     */
1425    public static function getBannerSettings( $bannerName, $detailed = true ) {
1426        $banner = self::fromName( $bannerName );
1427        if ( !$banner->exists() ) {
1428            throw new RangeException( "Banner doesn't exist!" );
1429        }
1430
1431        $details = [
1432            'anon'             => (int)$banner->allocateToAnon(),
1433            'account'          => (int)$banner->allocateToLoggedIn(),
1434            // TODO: Death to this!
1435            'fundraising'      => (int)( $banner->getCategory() === 'fundraising' ),
1436            'category'         => $banner->getCategory(),
1437            'controller_mixin' => implode( ",", array_keys( $banner->getMixins() ) ),
1438            'devices'          => array_values( $banner->getDevices() ),
1439        ];
1440
1441        if ( $detailed ) {
1442            $details['prioritylangs'] = $banner->getPriorityLanguages();
1443        }
1444
1445        return $details;
1446    }
1447
1448    /**
1449     * FIXME: a little thin, it's just enough to get the job done
1450     *
1451     * @param string $name
1452     * @param int $ts
1453     * @return array|null banner settings as an associative array, with these properties:
1454     *    display_anon: 0/1 whether the banner is displayed to anonymous users
1455     *    display_account: 0/1 same, for logged-in users
1456     *    fundraising: 0/1, is in the fundraising group
1457     *    device: device key
1458     */
1459    public static function getHistoricalBanner( $name, $ts ) {
1460        $id = self::fromName( $name )->getId();
1461
1462        $dbr = CNDatabase::getDb();
1463        $tsEnc = $dbr->addQuotes( $ts );
1464
1465        $newestLog = $dbr->selectRow(
1466            "cn_template_log",
1467            [
1468                "log_id" => "MAX(tmplog_id)",
1469            ],
1470            [
1471                "tmplog_timestamp <= $tsEnc",
1472                "tmplog_template_id = $id",
1473            ],
1474            __METHOD__
1475        );
1476
1477        if ( $newestLog->log_id === null ) {
1478            return null;
1479        }
1480
1481        $row = $dbr->selectRow(
1482            "cn_template_log",
1483            [
1484                "display_anon" => "tmplog_end_anon",
1485                "display_account" => "tmplog_end_account",
1486                "fundraising" => "tmplog_end_fundraising",
1487            ],
1488            [
1489                "tmplog_id" => $newestLog->log_id,
1490            ],
1491            __METHOD__
1492        );
1493
1494        return [
1495            'display_anon' => (int)$row->display_anon,
1496            'display_account' => (int)$row->display_account,
1497            'fundraising' => (int)$row->fundraising,
1498            'devices' => [ 'desktop' ],
1499        ];
1500    }
1501
1502    /**
1503     * @param string $name
1504     * @param User $user
1505     * @param Banner $template
1506     * @param string|null $summary
1507     * @return string|null error message key or null on success
1508     * @throws BannerDataException
1509     * @throws BannerExistenceException
1510     */
1511    public static function addFromBannerTemplate( $name, $user, Banner $template, $summary = null ) {
1512        if ( !$template->isTemplate() ) {
1513            return 'centralnotice-banner-template-error';
1514        }
1515        return static::addBanner(
1516            $name, $template->getBodyContent(), $user,
1517            $template->allocateToAnon(),
1518            $template->allocateToLoggedIn(),
1519            $template->getMixins(),
1520            $template->getPriorityLanguages(),
1521            $template->getDevices(),
1522            $summary,
1523            false,
1524            $template->getCategory()
1525        );
1526    }
1527
1528    /**
1529     * Create a new banner
1530     *
1531     * @param string $name name of banner
1532     * @param string $body content of banner
1533     * @param User $user User causing the change
1534     * @param bool $displayAnon flag for display to anonymous users
1535     * @param bool $displayAccount flag for display to logged in users
1536     * @param array $mixins list of mixins (optional)
1537     * @param array $priorityLangs Array of priority languages for the translate extension
1538     * @param array|null $devices Array of device names this banner is targeted at
1539     * @param string|null $summary Optional summary of changes for logging
1540     * @param bool $isTemplate Is banner marked as a template
1541     * @param string|null $category Category of the banner
1542     *
1543     * @return string|null error message key or null on success
1544     * @throws BannerDataException
1545     */
1546    public static function addBanner( $name, $body, $user, $displayAnon,
1547        $displayAccount, $mixins = [], $priorityLangs = [], $devices = null,
1548        $summary = null, $isTemplate = false, $category = null
1549    ) {
1550        // Default initial value for devices
1551        if ( $devices === null ) {
1552            $devices = [ 'desktop' ];
1553        }
1554
1555        // Set default value for category
1556        if ( !$category ) {
1557            $category = '{{{campaign}}}';
1558        }
1559
1560        if ( $name == '' || !self::isValidBannerName( $name ) || $body == '' ) {
1561            return 'centralnotice-null-string';
1562        }
1563
1564        $banner = self::newFromName( $name );
1565        if ( $banner->exists() ) {
1566            return 'centralnotice-template-exists';
1567        }
1568
1569        $banner->setAllocation( $displayAnon, $displayAccount );
1570        $banner->setCategory( $category );
1571        $banner->setDevices( $devices );
1572        $banner->setPriorityLanguages( $priorityLangs );
1573        $banner->setBodyContent( $body );
1574        $banner->setIsTemplate( $isTemplate );
1575
1576        $banner->setMixins( $mixins );
1577
1578        $banner->save( $user, $summary );
1579        return null;
1580    }
1581
1582    /**
1583     * Log setting changes related to a banner
1584     *
1585     * @param string $action 'created', 'modified', or 'removed'
1586     * @param User $user The user causing the change
1587     * @param string|null $summary Summary (comment) for this action
1588     */
1589    public function logBannerChange( $action, $user, $summary = null ) {
1590        ChoiceDataProvider::invalidateCache();
1591
1592        // Summary shouldn't actually come in null, but just in case...
1593        if ( $summary === null ) {
1594            $summary = '';
1595        }
1596
1597        $endSettings = [];
1598        if ( $action !== 'removed' ) {
1599            $endSettings = self::getBannerSettings( $this->getName(), true );
1600        }
1601
1602        $dbw = CNDatabase::getDb();
1603
1604        $log = [
1605            'tmplog_timestamp'     => $dbw->timestamp(),
1606            'tmplog_user_id'       => $user->getId(),
1607            'tmplog_action'        => $action,
1608            'tmplog_template_id'   => $this->getId(),
1609            'tmplog_template_name' => $this->getName(),
1610            'tmplog_content_change' => (int)$this->dirtyFlags['content'],
1611            'tmplog_comment'       => $summary,
1612        ];
1613
1614        foreach ( $endSettings as $key => $value ) {
1615            if ( is_array( $value ) ) {
1616                $value = FormatJson::encode( $value );
1617            }
1618
1619            $log[ 'tmplog_end_' . $key ] = $value;
1620        }
1621
1622        $dbw->newInsertQueryBuilder()
1623            ->insertInto( 'cn_template_log' )
1624            ->row( $log )
1625            ->caller( __METHOD__ )
1626            ->execute();
1627    }
1628
1629    /**
1630     * Validation function for banner names. Will return true iff the name fits
1631     * the generic format of letters, numbers, and dashes.
1632     *
1633     * @param string $name The name to check
1634     *
1635     * @return bool True if valid
1636     */
1637    public static function isValidBannerName( $name ) {
1638        // Note: regex should coordinate with banner name validation
1639        // in ext.centralNotice.adminUi.bannerSequence.js
1640        return preg_match( '/^[A-Za-z0-9_]+$/', $name );
1641    }
1642
1643    /**
1644     * Check to see if a banner actually exists in the database
1645     *
1646     * @return bool
1647     * @throws BannerDataException If it's a silly query
1648     */
1649    public function exists() {
1650        $db = CNDatabase::getDb();
1651        if ( $this->name !== null ) {
1652            $selector = [ 'tmp_name' => $this->name ];
1653        } elseif ( $this->id !== null ) {
1654            $selector = [ 'tmp_id' => $this->id ];
1655        } else {
1656            throw new BannerDataException(
1657                'Cannot determine banner existence without name or ID.'
1658            );
1659        }
1660        $row = $db->selectRow( 'cn_templates', 'tmp_name', $selector, __METHOD__ );
1661        if ( $row ) {
1662            return true;
1663        } else {
1664            return false;
1665        }
1666    }
1667
1668    /**
1669     * Get BannerMessage defined in a specific banner.
1670     *
1671     * @param string $bannerName
1672     * @param string $messageName
1673     * @return BannerMessage
1674     */
1675    public static function getMessageFieldForBanner( $bannerName, $messageName ) {
1676        return new BannerMessage( $bannerName, $messageName );
1677    }
1678
1679    /**
1680     * Apply cascading protection to the banner template or message
1681     *
1682     * @param WikiPage $wikiPage
1683     * @param User $user
1684     * @param bool $isTranslatedMessage if true, protect with $wgCentralNoticeMessageProtectRight
1685     * @throws BannerContentException
1686     */
1687    public static function protectBannerContent(
1688        WikiPage $wikiPage, User $user, $isTranslatedMessage = false
1689    ) {
1690        if ( $isTranslatedMessage ) {
1691            global $wgCentralNoticeMessageProtectRight;
1692            if ( $wgCentralNoticeMessageProtectRight === '' ) {
1693                return;
1694            }
1695            $protectionRight = $wgCentralNoticeMessageProtectRight;
1696        } else {
1697            $protectionRight = 'centralnotice-admin';
1698        }
1699
1700        $limits = [
1701            'edit' => $protectionRight,
1702            'move' => $protectionRight,
1703        ];
1704
1705        $expiry = [
1706            'edit' => 'infinity',
1707            'move' => 'infinity',
1708        ];
1709
1710        $cascade = true;
1711        $reason = wfMessage( 'centralnotice-banner-protection-log-reason' )
1712            ->inContentLanguage()->text();
1713
1714        $status = $wikiPage->doUpdateRestrictions(
1715            $limits, $expiry, $cascade, $reason, $user
1716        );
1717        if ( !$status->isGood() || !$cascade ) {
1718            throw new BannerContentException(
1719                'Unable to protect banner' . $status->getMessage()->text()
1720            );
1721        }
1722    }
1723}