Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
50.14% |
350 / 698 |
|
47.06% |
32 / 68 |
CRAP | |
0.00% |
0 / 1 |
Banner | |
50.14% |
350 / 698 |
|
47.06% |
32 / 68 |
3580.98 | |
0.00% |
0 / 1 |
fromId | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
fromName | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
newFromName | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
getId | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getName | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
allocateToAnon | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
allocateToLoggedIn | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setAllocation | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
getCategory | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setCategory | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
getAllUsedCategories | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
sanitizeRenderedCategory | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isArchived | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
isTemplate | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setIsTemplate | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
populateBasicData | |
74.36% |
29 / 39 |
|
0.00% |
0 / 1 |
6.61 | |||
setBasicDataDirty | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
initializeDbBasicData | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
saveBasicData | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
2 | |||
getDevices | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setDevices | |
86.67% |
13 / 15 |
|
0.00% |
0 / 1 |
5.06 | |||
populateDeviceTargetData | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
3 | |||
markDeviceTargetDataDirty | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
saveDeviceTargetData | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 | |||
getMixins | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setMixins | |
63.64% |
7 / 11 |
|
0.00% |
0 / 1 |
4.77 | |||
populateMixinData | |
70.59% |
12 / 17 |
|
0.00% |
0 / 1 |
4.41 | |||
markMixinDataDirty | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
saveMixinData | |
36.84% |
7 / 19 |
|
0.00% |
0 / 1 |
8.03 | |||
getPriorityLanguages | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setPriorityLanguages | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
populatePriorityLanguageData | |
23.53% |
4 / 17 |
|
0.00% |
0 / 1 |
16.18 | |||
markPriorityLanguageDataDirty | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
savePriorityLanguageData | |
5.26% |
1 / 19 |
|
0.00% |
0 / 1 |
36.61 | |||
getDbKey | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getTitle | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getCampaignNames | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
6 | |||
getIncludedTemplates | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getBodyContent | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setBodyContent | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
populateBodyContent | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
4.02 | |||
markBodyContentDirty | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
saveBodyContent | |
64.00% |
16 / 25 |
|
0.00% |
0 / 1 |
7.68 | |||
getMessageField | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getMessageFieldsFromCache | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
invalidateCache | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
getMessageFieldsCacheKey | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
extractMessageFields | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
2 | |||
getAvailableLanguages | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
30 | |||
save | |
68.18% |
15 / 22 |
|
0.00% |
0 / 1 |
5.81 | |||
initializeDbForNewBanner | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
saveBannerInternal | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
archive | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
cloneBanner | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
42 | |||
remove | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
removeBanner | |
51.72% |
15 / 29 |
|
0.00% |
0 / 1 |
7.81 | |||
addTag | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
12 | |||
removeTag | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
getCampaignBanners | |
0.00% |
0 / 54 |
|
0.00% |
0 / 1 |
20 | |||
getBannerSettings | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
3.00 | |||
getHistoricalBanner | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
6 | |||
addFromBannerTemplate | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
2.00 | |||
addBanner | |
83.33% |
15 / 18 |
|
0.00% |
0 / 1 |
7.23 | |||
logBannerChange | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
5 | |||
isValidBannerName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
exists | |
75.00% |
9 / 12 |
|
0.00% |
0 / 1 |
4.25 | |||
getMessageFieldForBanner | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
protectBannerContent | |
73.91% |
17 / 23 |
|
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 | |
25 | use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups; |
26 | use MediaWiki\Extension\Translate\Services; |
27 | use MediaWiki\MediaWikiServices; |
28 | use MediaWiki\Revision\SlotRecord; |
29 | use 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 | */ |
45 | class 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 | } |