Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
73.91% |
323 / 437 |
|
30.00% |
9 / 30 |
CRAP | |
0.00% |
0 / 1 |
LocalisationCache | |
73.91% |
323 / 437 |
|
30.00% |
9 / 30 |
838.64 | |
0.00% |
0 / 1 |
getStoreFromConf | |
35.29% |
6 / 17 |
|
0.00% |
0 / 1 |
37.09 | |||
__construct | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
isMergeableKey | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
getItem | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
4.13 | |||
getSubitem | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
getSubitemWithSource | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
getSubitemList | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
loadItem | |
73.91% |
17 / 23 |
|
0.00% |
0 / 1 |
14.56 | |||
loadSubitem | |
31.58% |
6 / 19 |
|
0.00% |
0 / 1 |
28.50 | |||
isExpired | |
66.67% |
10 / 15 |
|
0.00% |
0 / 1 |
12.00 | |||
initLanguage | |
65.85% |
27 / 41 |
|
0.00% |
0 / 1 |
33.37 | |||
initShallowFallback | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
readPHPFile | |
73.33% |
11 / 15 |
|
0.00% |
0 / 1 |
10.54 | |||
readJSONFile | |
83.33% |
10 / 12 |
|
0.00% |
0 / 1 |
7.23 | |||
getCompiledPluralRules | |
62.50% |
5 / 8 |
|
0.00% |
0 / 1 |
3.47 | |||
getPluralRules | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
getPluralRuleTypes | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
loadPluralFiles | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
loadPluralFile | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
42 | |||
readSourceFilesAndRegisterDeps | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
2.02 | |||
readPluralFilesAndRegisterDeps | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
mergeItem | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
8.03 | |||
mergeMagicWords | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
getMessagesDirs | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
loadCoreData | |
94.12% |
32 / 34 |
|
0.00% |
0 / 1 |
18.07 | |||
recache | |
93.28% |
111 / 119 |
|
0.00% |
0 / 1 |
44.59 | |||
buildPreload | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
unload | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
unloadAll | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
disableBackend | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | |
21 | use CLDRPluralRuleParser\Error as CLDRPluralRuleError; |
22 | use CLDRPluralRuleParser\Evaluator; |
23 | use MediaWiki\Config\ServiceOptions; |
24 | use MediaWiki\HookContainer\HookContainer; |
25 | use MediaWiki\HookContainer\HookRunner; |
26 | use MediaWiki\Languages\LanguageNameUtils; |
27 | use MediaWiki\MainConfigNames; |
28 | use Psr\Log\LoggerInterface; |
29 | |
30 | /** |
31 | * Caching for the contents of localisation files. |
32 | * |
33 | * Including for i18n JSON files under `/languages/messages`, `Messages*.php`, |
34 | * and `*.i18n.php`. |
35 | * |
36 | * An instance of this class is available using MediaWikiServices. |
37 | * |
38 | * The values retrieved from here are merged, containing items from extension |
39 | * files, core messages files and the language fallback sequence (e.g. zh-cn -> |
40 | * zh-hans -> en ). Some common errors are corrected, for example namespace |
41 | * names with spaces instead of underscores, but heavyweight processing, such |
42 | * as grammatical transformation, is done by the caller. |
43 | * |
44 | * @ingroup Language |
45 | */ |
46 | class LocalisationCache { |
47 | public const VERSION = 5; |
48 | |
49 | /** @var ServiceOptions */ |
50 | private $options; |
51 | |
52 | /** |
53 | * True if re-caching should only be done on an explicit call to recache(). |
54 | * Setting this reduces the overhead of cache freshness checking, which |
55 | * requires doing a stat() for every extension i18n file. |
56 | * |
57 | * @var bool |
58 | */ |
59 | private $manualRecache; |
60 | |
61 | /** |
62 | * The cache data. 2/3-d array, where the first key is the language code, |
63 | * the second key is the item key e.g. 'messages', and the optional third key is |
64 | * an item specific subkey index. Some items are not arrays, and so for those |
65 | * items, there are no subkeys. |
66 | * |
67 | * @var array<string,array> |
68 | */ |
69 | protected $data = []; |
70 | |
71 | /** |
72 | * The source language of cached data items. Only supports messages for now. |
73 | * |
74 | * @var array<string,array<string,array<string,string>>> |
75 | */ |
76 | protected $sourceLanguage = []; |
77 | |
78 | /** @var LCStore */ |
79 | private $store; |
80 | /** @var LoggerInterface */ |
81 | private $logger; |
82 | /** @var HookRunner */ |
83 | private $hookRunner; |
84 | /** @var callable[] See comment for parameter in constructor */ |
85 | private $clearStoreCallbacks; |
86 | /** @var LanguageNameUtils */ |
87 | private $langNameUtils; |
88 | |
89 | /** |
90 | * A 2-d associative array, code/key, where presence indicates that the item |
91 | * is loaded. Value arbitrary. |
92 | * |
93 | * For split items, if set, this indicates that all the subitems have been |
94 | * loaded. |
95 | * |
96 | * @var array<string,array<string,true>> |
97 | */ |
98 | private $loadedItems = []; |
99 | |
100 | /** |
101 | * A 3-d associative array, code/key/subkey, where presence indicates that |
102 | * the subitem is loaded. Only used for the split items, i.e. ,messages. |
103 | * |
104 | * @var array<string,array<string,array<string,true>>> |
105 | */ |
106 | private $loadedSubitems = []; |
107 | |
108 | /** |
109 | * An array where the presence of a key indicates that that language has been |
110 | * initialised. Initialisation includes checking for cache expiry and doing |
111 | * any necessary updates. |
112 | * |
113 | * @var array<string,true> |
114 | */ |
115 | private $initialisedLangs = []; |
116 | |
117 | /** |
118 | * An array mapping non-existent pseudo-languages to fallback languages. This |
119 | * is filled by initShallowFallback() when data is requested from a language |
120 | * that lacks a Messages*.php file. |
121 | * |
122 | * @var array<string,string> |
123 | */ |
124 | private $shallowFallbacks = []; |
125 | |
126 | /** |
127 | * An array where the keys are codes that have been re-cached by this instance. |
128 | * |
129 | * @var array<string,true> |
130 | */ |
131 | private $recachedLangs = []; |
132 | |
133 | /** |
134 | * An array indicating whether core data for a language has been loaded. |
135 | * If the entry for a language code $code is true, |
136 | * then {@link self::$data} is guaranteed to contain an array for $code, |
137 | * with at least an entry (possibly null) for each of the {@link self::CORE_ONLY_KEYS}, |
138 | * and all the core-only keys will be marked as loaded in {@link self::$loadedItems} too. |
139 | * Additionally, there will be a 'deps' entry for $code with the dependencies tracked so far. |
140 | * |
141 | * @var array<string,bool> |
142 | */ |
143 | private $coreDataLoaded = []; |
144 | |
145 | /** |
146 | * All item keys |
147 | */ |
148 | public const ALL_KEYS = [ |
149 | 'fallback', 'namespaceNames', 'bookstoreList', |
150 | 'magicWords', 'messages', 'rtl', |
151 | 'digitTransformTable', 'separatorTransformTable', |
152 | 'minimumGroupingDigits', 'fallback8bitEncoding', |
153 | 'linkPrefixExtension', 'linkTrail', 'linkPrefixCharset', |
154 | 'namespaceAliases', 'dateFormats', 'datePreferences', |
155 | 'datePreferenceMigrationMap', 'defaultDateFormat', |
156 | 'specialPageAliases', 'imageFiles', 'preloadedMessages', |
157 | 'namespaceGenderAliases', 'digitGroupingPattern', 'pluralRules', |
158 | 'pluralRuleTypes', 'compiledPluralRules', |
159 | ]; |
160 | |
161 | /** |
162 | * Keys for items that can only be set in the core message files, |
163 | * not in extensions. Assignments to these keys in extension messages files |
164 | * are silently ignored. |
165 | * |
166 | * @since 1.41 |
167 | */ |
168 | private const CORE_ONLY_KEYS = [ |
169 | 'fallback', 'rtl', 'digitTransformTable', 'separatorTransformTable', |
170 | 'minimumGroupingDigits', 'fallback8bitEncoding', 'linkPrefixExtension', |
171 | 'linkTrail', 'linkPrefixCharset', 'datePreferences', |
172 | 'datePreferenceMigrationMap', 'defaultDateFormat', 'digitGroupingPattern', |
173 | ]; |
174 | |
175 | /** |
176 | * ALL_KEYS - CORE_ONLY_KEYS. All of these can technically be set |
177 | * both in core and in extension messages files, |
178 | * though this is not necessarily useful for all these keys. |
179 | * Some of these keys are mergeable too. |
180 | * |
181 | * @since 1.41 |
182 | */ |
183 | private const ALL_EXCEPT_CORE_ONLY_KEYS = [ |
184 | 'namespaceNames', 'bookstoreList', 'magicWords', 'messages', |
185 | 'namespaceAliases', 'dateFormats', 'specialPageAliases', |
186 | 'imageFiles', 'preloadedMessages', 'namespaceGenderAliases', |
187 | 'pluralRules', 'pluralRuleTypes', 'compiledPluralRules', |
188 | ]; |
189 | |
190 | /** Keys for items which can be localized. */ |
191 | public const ALL_ALIAS_KEYS = [ 'specialPageAliases' ]; |
192 | |
193 | /** |
194 | * Keys for items which consist of associative arrays, which may be merged |
195 | * by a fallback sequence. |
196 | */ |
197 | private const MERGEABLE_MAP_KEYS = [ 'messages', 'namespaceNames', |
198 | 'namespaceAliases', 'dateFormats', 'imageFiles', 'preloadedMessages' |
199 | ]; |
200 | |
201 | /** |
202 | * Keys for items which contain an array of arrays of equivalent aliases |
203 | * for each subitem. The aliases may be merged by a fallback sequence. |
204 | */ |
205 | private const MERGEABLE_ALIAS_LIST_KEYS = [ 'specialPageAliases' ]; |
206 | |
207 | /** |
208 | * Keys for items which contain an associative array, and may be merged if |
209 | * the primary value contains the special array key "inherit". That array |
210 | * key is removed after the first merge. |
211 | */ |
212 | private const OPTIONAL_MERGE_KEYS = [ 'bookstoreList' ]; |
213 | |
214 | /** |
215 | * Keys for items that are formatted like $magicWords |
216 | */ |
217 | private const MAGIC_WORD_KEYS = [ 'magicWords' ]; |
218 | |
219 | /** |
220 | * Keys for items where the subitems are stored in the backend separately. |
221 | */ |
222 | private const SPLIT_KEYS = [ 'messages' ]; |
223 | |
224 | /** |
225 | * Keys for items that will be prefixed with its source language code, |
226 | * which should be stripped out when loading from cache. |
227 | */ |
228 | private const SOURCE_PREFIX_KEYS = [ 'messages' ]; |
229 | |
230 | /** |
231 | * Separator for the source language prefix. |
232 | */ |
233 | private const SOURCEPREFIX_SEPARATOR = ':'; |
234 | |
235 | /** |
236 | * Keys which are loaded automatically by initLanguage() |
237 | */ |
238 | private const PRELOADED_KEYS = [ 'dateFormats', 'namespaceNames' ]; |
239 | |
240 | private const PLURAL_FILES = [ |
241 | // Load CLDR plural rules |
242 | MW_INSTALL_PATH . '/languages/data/plurals.xml', |
243 | // Override or extend with MW-specific rules |
244 | MW_INSTALL_PATH . '/languages/data/plurals-mediawiki.xml', |
245 | ]; |
246 | |
247 | /** |
248 | * Associative array of cached plural rules. The key is the language code, |
249 | * the value is an array of plural rules for that language. |
250 | * |
251 | * @var array<string,array<int,string>>|null |
252 | */ |
253 | private static $pluralRules = null; |
254 | |
255 | /** |
256 | * Associative array of cached plural rule types. The key is the language |
257 | * code, the value is an array of plural rule types for that language. For |
258 | * example, $pluralRuleTypes['ar'] = ['zero', 'one', 'two', 'few', 'many']. |
259 | * The index for each rule type matches the index for the rule in |
260 | * $pluralRules, thus allowing correlation between the two. The reason we |
261 | * don't just use the type names as the keys in $pluralRules is because |
262 | * Language::convertPlural applies the rules based on numeric order (or |
263 | * explicit numeric parameter), not based on the name of the rule type. For |
264 | * example, {{plural:count|wordform1|wordform2|wordform3}}, rather than |
265 | * {{plural:count|one=wordform1|two=wordform2|many=wordform3}}. |
266 | * |
267 | * @var array<string,array<int,string>>|null |
268 | */ |
269 | private static $pluralRuleTypes = null; |
270 | |
271 | /** |
272 | * Return a suitable LCStore as specified by the given configuration. |
273 | * |
274 | * @since 1.34 |
275 | * @param array $conf In the format of $wgLocalisationCacheConf |
276 | * @param string|false|null $fallbackCacheDir In case 'storeDirectory' isn't specified |
277 | * @return LCStore |
278 | */ |
279 | public static function getStoreFromConf( array $conf, $fallbackCacheDir ): LCStore { |
280 | $storeArg = []; |
281 | $storeArg['directory'] = |
282 | $conf['storeDirectory'] ?: $fallbackCacheDir; |
283 | |
284 | if ( !empty( $conf['storeClass'] ) ) { |
285 | $storeClass = $conf['storeClass']; |
286 | } elseif ( $conf['store'] === 'files' || $conf['store'] === 'file' || |
287 | ( $conf['store'] === 'detect' && $storeArg['directory'] ) |
288 | ) { |
289 | $storeClass = LCStoreCDB::class; |
290 | } elseif ( $conf['store'] === 'db' || $conf['store'] === 'detect' ) { |
291 | $storeClass = LCStoreDB::class; |
292 | $storeArg['server'] = $conf['storeServer'] ?? []; |
293 | } elseif ( $conf['store'] === 'array' ) { |
294 | $storeClass = LCStoreStaticArray::class; |
295 | } else { |
296 | throw new ConfigException( |
297 | 'Please set $wgLocalisationCacheConf[\'store\'] to something sensible.' |
298 | ); |
299 | } |
300 | |
301 | return new $storeClass( $storeArg ); |
302 | } |
303 | |
304 | /** |
305 | * @internal For use by ServiceWiring |
306 | */ |
307 | public const CONSTRUCTOR_OPTIONS = [ |
308 | // True to treat all files as expired until they are regenerated by this object. |
309 | 'forceRecache', |
310 | 'manualRecache', |
311 | MainConfigNames::ExtensionMessagesFiles, |
312 | MainConfigNames::MessagesDirs, |
313 | MainConfigNames::TranslationAliasesDirs, |
314 | ]; |
315 | |
316 | /** |
317 | * For constructor parameters, @ref \MediaWiki\MainConfigSchema::LocalisationCacheConf. |
318 | * |
319 | * @internal Do not construct directly, use MediaWikiServices instead. |
320 | * @param ServiceOptions $options |
321 | * @param LCStore $store What backend to use for storage |
322 | * @param LoggerInterface $logger |
323 | * @param callable[] $clearStoreCallbacks To be called whenever the cache is cleared. Can be |
324 | * used to clear other caches that depend on this one, such as ResourceLoader's |
325 | * MessageBlobStore. |
326 | * @param LanguageNameUtils $langNameUtils |
327 | * @param HookContainer $hookContainer |
328 | */ |
329 | public function __construct( |
330 | ServiceOptions $options, |
331 | LCStore $store, |
332 | LoggerInterface $logger, |
333 | array $clearStoreCallbacks, |
334 | LanguageNameUtils $langNameUtils, |
335 | HookContainer $hookContainer |
336 | ) { |
337 | $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
338 | |
339 | $this->options = $options; |
340 | $this->store = $store; |
341 | $this->logger = $logger; |
342 | $this->clearStoreCallbacks = $clearStoreCallbacks; |
343 | $this->langNameUtils = $langNameUtils; |
344 | $this->hookRunner = new HookRunner( $hookContainer ); |
345 | |
346 | // Keep this separate from $this->options so that it can be mutable |
347 | $this->manualRecache = $options->get( 'manualRecache' ); |
348 | } |
349 | |
350 | /** |
351 | * Returns true if the given key is mergeable, that is, if it is an associative |
352 | * array which can be merged through a fallback sequence. |
353 | * @param string $key |
354 | * @return bool |
355 | */ |
356 | private static function isMergeableKey( string $key ): bool { |
357 | static $mergeableKeys; |
358 | $mergeableKeys ??= array_fill_keys( [ |
359 | ...self::MERGEABLE_MAP_KEYS, |
360 | ...self::MERGEABLE_ALIAS_LIST_KEYS, |
361 | ...self::OPTIONAL_MERGE_KEYS, |
362 | ...self::MAGIC_WORD_KEYS, |
363 | ], true ); |
364 | return isset( $mergeableKeys[$key] ); |
365 | } |
366 | |
367 | /** |
368 | * Get a cache item. |
369 | * |
370 | * Warning: this may be slow for split items (messages), since it will |
371 | * need to fetch all the subitems from the cache individually. |
372 | * @param string $code |
373 | * @param string $key |
374 | * @return mixed |
375 | */ |
376 | public function getItem( $code, $key ) { |
377 | if ( !isset( $this->loadedItems[$code][$key] ) ) { |
378 | $this->loadItem( $code, $key ); |
379 | } |
380 | |
381 | if ( $key === 'fallback' && isset( $this->shallowFallbacks[$code] ) ) { |
382 | return $this->shallowFallbacks[$code]; |
383 | } |
384 | |
385 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable |
386 | return $this->data[$code][$key]; |
387 | } |
388 | |
389 | /** |
390 | * Get a subitem, for instance a single message for a given language. |
391 | * @param string $code |
392 | * @param string $key |
393 | * @param string $subkey |
394 | * @return mixed|null |
395 | */ |
396 | public function getSubitem( $code, $key, $subkey ) { |
397 | if ( !isset( $this->loadedSubitems[$code][$key][$subkey] ) && |
398 | !isset( $this->loadedItems[$code][$key] ) |
399 | ) { |
400 | $this->loadSubitem( $code, $key, $subkey ); |
401 | } |
402 | |
403 | return $this->data[$code][$key][$subkey] ?? null; |
404 | } |
405 | |
406 | /** |
407 | * Get a subitem with its source language. Only supports messages for now. |
408 | * |
409 | * @since 1.41 |
410 | * @param string $code |
411 | * @param string $key |
412 | * @param string $subkey |
413 | * @return string[]|null Return [ subitem, sourceLanguage ] if the subitem is defined. |
414 | */ |
415 | public function getSubitemWithSource( $code, $key, $subkey ) { |
416 | $subitem = $this->getSubitem( $code, $key, $subkey ); |
417 | // Undefined in the backend. |
418 | if ( $subitem === null ) { |
419 | return null; |
420 | } |
421 | |
422 | // The source language should have been set, but to avoid a Phan error and to be double sure. |
423 | return [ $subitem, $this->sourceLanguage[$code][$key][$subkey] ?? $code ]; |
424 | } |
425 | |
426 | /** |
427 | * Get the list of subitem keys for a given item. |
428 | * |
429 | * This is faster than array_keys($lc->getItem(...)) for the items listed in |
430 | * self::SPLIT_KEYS. |
431 | * |
432 | * Will return null if the item is not found, or false if the item is not an |
433 | * array. |
434 | * |
435 | * @param string $code |
436 | * @param string $key |
437 | * @return bool|null|string|string[] |
438 | */ |
439 | public function getSubitemList( $code, $key ) { |
440 | if ( in_array( $key, self::SPLIT_KEYS ) ) { |
441 | return $this->getSubitem( $code, 'list', $key ); |
442 | } else { |
443 | $item = $this->getItem( $code, $key ); |
444 | if ( is_array( $item ) ) { |
445 | return array_keys( $item ); |
446 | } else { |
447 | return false; |
448 | } |
449 | } |
450 | } |
451 | |
452 | /** |
453 | * Load an item into the cache. |
454 | * |
455 | * @param string $code |
456 | * @param string $key |
457 | */ |
458 | private function loadItem( $code, $key ) { |
459 | if ( isset( $this->loadedItems[$code][$key] ) ) { |
460 | return; |
461 | } |
462 | |
463 | if ( |
464 | in_array( $key, self::CORE_ONLY_KEYS, true ) || |
465 | // "synthetic" keys added by loadCoreData based on "fallback" |
466 | $key === 'fallbackSequence' || |
467 | $key === 'originalFallbackSequence' |
468 | ) { |
469 | if ( $this->langNameUtils->isValidBuiltInCode( $code ) ) { |
470 | $this->loadCoreData( $code ); |
471 | return; |
472 | } |
473 | } |
474 | |
475 | if ( !isset( $this->initialisedLangs[$code] ) ) { |
476 | $this->initLanguage( $code ); |
477 | |
478 | // Check to see if initLanguage() loaded it for us |
479 | if ( isset( $this->loadedItems[$code][$key] ) ) { |
480 | return; |
481 | } |
482 | } |
483 | |
484 | if ( isset( $this->shallowFallbacks[$code] ) ) { |
485 | $this->loadItem( $this->shallowFallbacks[$code], $key ); |
486 | |
487 | return; |
488 | } |
489 | |
490 | if ( in_array( $key, self::SPLIT_KEYS ) ) { |
491 | $subkeyList = $this->getSubitem( $code, 'list', $key ); |
492 | foreach ( $subkeyList as $subkey ) { |
493 | if ( isset( $this->data[$code][$key][$subkey] ) ) { |
494 | continue; |
495 | } |
496 | $this->loadSubitem( $code, $key, $subkey ); |
497 | } |
498 | } else { |
499 | $this->data[$code][$key] = $this->store->get( $code, $key ); |
500 | } |
501 | |
502 | $this->loadedItems[$code][$key] = true; |
503 | } |
504 | |
505 | /** |
506 | * Load a subitem into the cache. |
507 | * |
508 | * @param string $code |
509 | * @param string $key |
510 | * @param string $subkey |
511 | */ |
512 | private function loadSubitem( $code, $key, $subkey ) { |
513 | if ( !in_array( $key, self::SPLIT_KEYS ) ) { |
514 | $this->loadItem( $code, $key ); |
515 | |
516 | return; |
517 | } |
518 | |
519 | if ( !isset( $this->initialisedLangs[$code] ) ) { |
520 | $this->initLanguage( $code ); |
521 | } |
522 | |
523 | // Check to see if initLanguage() loaded it for us |
524 | if ( isset( $this->loadedItems[$code][$key] ) || |
525 | isset( $this->loadedSubitems[$code][$key][$subkey] ) |
526 | ) { |
527 | return; |
528 | } |
529 | |
530 | if ( isset( $this->shallowFallbacks[$code] ) ) { |
531 | $this->loadSubitem( $this->shallowFallbacks[$code], $key, $subkey ); |
532 | |
533 | return; |
534 | } |
535 | |
536 | $value = $this->store->get( $code, "$key:$subkey" ); |
537 | if ( $value !== null && in_array( $key, self::SOURCE_PREFIX_KEYS ) ) { |
538 | [ |
539 | $this->sourceLanguage[$code][$key][$subkey], |
540 | $this->data[$code][$key][$subkey] |
541 | ] = explode( self::SOURCEPREFIX_SEPARATOR, $value, 2 ); |
542 | } else { |
543 | $this->data[$code][$key][$subkey] = $value; |
544 | } |
545 | |
546 | $this->loadedSubitems[$code][$key][$subkey] = true; |
547 | } |
548 | |
549 | /** |
550 | * Returns true if the cache identified by $code is missing or expired. |
551 | * |
552 | * @param string $code |
553 | * |
554 | * @return bool |
555 | */ |
556 | public function isExpired( $code ) { |
557 | if ( $this->options->get( 'forceRecache' ) && !isset( $this->recachedLangs[$code] ) ) { |
558 | $this->logger->debug( __METHOD__ . "($code): forced reload" ); |
559 | |
560 | return true; |
561 | } |
562 | |
563 | $deps = $this->store->get( $code, 'deps' ); |
564 | $keys = $this->store->get( $code, 'list' ); |
565 | $preload = $this->store->get( $code, 'preload' ); |
566 | // Different keys may expire separately for some stores |
567 | if ( $deps === null || $keys === null || $preload === null ) { |
568 | $this->logger->debug( __METHOD__ . "($code): cache missing, need to make one" ); |
569 | |
570 | return true; |
571 | } |
572 | |
573 | foreach ( $deps as $dep ) { |
574 | // Because we're unserializing stuff from cache, we |
575 | // could receive objects of classes that don't exist |
576 | // anymore (e.g., uninstalled extensions) |
577 | // When this happens, always expire the cache |
578 | if ( !$dep instanceof CacheDependency || $dep->isExpired() ) { |
579 | $this->logger->debug( __METHOD__ . "($code): cache for $code expired due to " . |
580 | get_class( $dep ) ); |
581 | |
582 | return true; |
583 | } |
584 | } |
585 | |
586 | return false; |
587 | } |
588 | |
589 | /** |
590 | * Initialise a language in this object. Rebuild the cache if necessary. |
591 | * |
592 | * @param string $code |
593 | */ |
594 | private function initLanguage( $code ) { |
595 | if ( isset( $this->initialisedLangs[$code] ) ) { |
596 | return; |
597 | } |
598 | |
599 | $this->initialisedLangs[$code] = true; |
600 | |
601 | # If the code is of the wrong form for a Messages*.php file, do a shallow fallback |
602 | if ( !$this->langNameUtils->isValidBuiltInCode( $code ) ) { |
603 | $this->initShallowFallback( $code, 'en' ); |
604 | |
605 | return; |
606 | } |
607 | |
608 | # Re-cache the data if necessary |
609 | if ( !$this->manualRecache && $this->isExpired( $code ) ) { |
610 | if ( $this->langNameUtils->isSupportedLanguage( $code ) ) { |
611 | $this->recache( $code ); |
612 | } elseif ( $code === 'en' ) { |
613 | throw new RuntimeException( 'MessagesEn.php is missing.' ); |
614 | } else { |
615 | $this->initShallowFallback( $code, 'en' ); |
616 | } |
617 | |
618 | return; |
619 | } |
620 | |
621 | # Preload some stuff |
622 | $preload = $this->getItem( $code, 'preload' ); |
623 | if ( $preload === null ) { |
624 | if ( $this->manualRecache ) { |
625 | // No Messages*.php file. Do shallow fallback to en. |
626 | if ( $code === 'en' ) { |
627 | throw new RuntimeException( 'No localisation cache found for English. ' . |
628 | 'Please run maintenance/rebuildLocalisationCache.php.' ); |
629 | } |
630 | $this->initShallowFallback( $code, 'en' ); |
631 | |
632 | return; |
633 | } else { |
634 | throw new RuntimeException( 'Invalid or missing localisation cache.' ); |
635 | } |
636 | } |
637 | |
638 | foreach ( self::SOURCE_PREFIX_KEYS as $key ) { |
639 | if ( !isset( $preload[$key] ) ) { |
640 | continue; |
641 | } |
642 | foreach ( $preload[$key] as $subkey => $value ) { |
643 | if ( $value !== null ) { |
644 | [ |
645 | $this->sourceLanguage[$code][$key][$subkey], |
646 | $preload[$key][$subkey] |
647 | ] = explode( self::SOURCEPREFIX_SEPARATOR, $value, 2 ); |
648 | } else { |
649 | $preload[$key][$subkey] = null; |
650 | } |
651 | } |
652 | } |
653 | |
654 | if ( isset( $this->data[$code] ) ) { |
655 | foreach ( $preload as $key => $value ) { |
656 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- see isset() above |
657 | $this->mergeItem( $key, $this->data[$code][$key], $value ); |
658 | } |
659 | } else { |
660 | $this->data[$code] = $preload; |
661 | } |
662 | foreach ( $preload as $key => $item ) { |
663 | if ( in_array( $key, self::SPLIT_KEYS ) ) { |
664 | foreach ( $item as $subkey => $subitem ) { |
665 | $this->loadedSubitems[$code][$key][$subkey] = true; |
666 | } |
667 | } else { |
668 | $this->loadedItems[$code][$key] = true; |
669 | } |
670 | } |
671 | } |
672 | |
673 | /** |
674 | * Create a fallback from one language to another, without creating a |
675 | * complete persistent cache. |
676 | * |
677 | * @param string $primaryCode |
678 | * @param string $fallbackCode |
679 | */ |
680 | private function initShallowFallback( $primaryCode, $fallbackCode ) { |
681 | $this->data[$primaryCode] =& $this->data[$fallbackCode]; |
682 | $this->loadedItems[$primaryCode] =& $this->loadedItems[$fallbackCode]; |
683 | $this->loadedSubitems[$primaryCode] =& $this->loadedSubitems[$fallbackCode]; |
684 | $this->shallowFallbacks[$primaryCode] = $fallbackCode; |
685 | $this->coreDataLoaded[$primaryCode] =& $this->coreDataLoaded[$fallbackCode]; |
686 | } |
687 | |
688 | /** |
689 | * Read a PHP file containing localisation data. |
690 | * |
691 | * @param string $_fileName |
692 | * @param string $_fileType |
693 | * @return array |
694 | */ |
695 | protected function readPHPFile( $_fileName, $_fileType ) { |
696 | include $_fileName; |
697 | |
698 | $data = []; |
699 | if ( $_fileType == 'core' ) { |
700 | foreach ( self::ALL_KEYS as $key ) { |
701 | // Not all keys are set in language files, so |
702 | // check they exist first |
703 | if ( isset( $$key ) ) { |
704 | $data[$key] = $$key; |
705 | } |
706 | } |
707 | } elseif ( $_fileType == 'extension' ) { |
708 | foreach ( self::ALL_EXCEPT_CORE_ONLY_KEYS as $key ) { |
709 | if ( isset( $$key ) ) { |
710 | $data[$key] = $$key; |
711 | } |
712 | } |
713 | } elseif ( $_fileType == 'aliases' ) { |
714 | // @phan-suppress-next-line PhanImpossibleCondition May be set in the included file |
715 | if ( isset( $aliases ) ) { |
716 | $data['aliases'] = $aliases; |
717 | } |
718 | } else { |
719 | throw new InvalidArgumentException( __METHOD__ . ": Invalid file type: $_fileType" ); |
720 | } |
721 | |
722 | return $data; |
723 | } |
724 | |
725 | /** |
726 | * Read a JSON file containing localisation messages. |
727 | * |
728 | * @param string $fileName Name of file to read |
729 | * @return array Array with a 'messages' key, or empty array if the file doesn't exist |
730 | */ |
731 | private function readJSONFile( $fileName ) { |
732 | if ( !is_readable( $fileName ) ) { |
733 | return []; |
734 | } |
735 | |
736 | $json = file_get_contents( $fileName ); |
737 | if ( $json === false ) { |
738 | return []; |
739 | } |
740 | |
741 | $data = FormatJson::decode( $json, true ); |
742 | if ( $data === null ) { |
743 | throw new RuntimeException( __METHOD__ . ": Invalid JSON file: $fileName" ); |
744 | } |
745 | |
746 | // Remove keys starting with '@'; they are reserved for metadata and non-message data |
747 | foreach ( $data as $key => $unused ) { |
748 | if ( $key === '' || $key[0] === '@' ) { |
749 | unset( $data[$key] ); |
750 | } |
751 | } |
752 | |
753 | return $data; |
754 | } |
755 | |
756 | /** |
757 | * Get the compiled plural rules for a given language from the XML files. |
758 | * |
759 | * @since 1.20 |
760 | * @param string $code |
761 | * @return array<int,string>|null |
762 | */ |
763 | private function getCompiledPluralRules( $code ) { |
764 | $rules = $this->getPluralRules( $code ); |
765 | if ( $rules === null ) { |
766 | return null; |
767 | } |
768 | try { |
769 | $compiledRules = Evaluator::compile( $rules ); |
770 | } catch ( CLDRPluralRuleError $e ) { |
771 | $this->logger->debug( $e->getMessage() ); |
772 | |
773 | return []; |
774 | } |
775 | |
776 | return $compiledRules; |
777 | } |
778 | |
779 | /** |
780 | * Get the plural rules for a given language from the XML files. |
781 | * |
782 | * Cached. |
783 | * |
784 | * @since 1.20 |
785 | * @param string $code |
786 | * @return array<int,string>|null |
787 | */ |
788 | private function getPluralRules( $code ) { |
789 | if ( self::$pluralRules === null ) { |
790 | self::loadPluralFiles(); |
791 | } |
792 | return self::$pluralRules[$code] ?? null; |
793 | } |
794 | |
795 | /** |
796 | * Get the plural rule types for a given language from the XML files. |
797 | * |
798 | * Cached. |
799 | * |
800 | * @since 1.22 |
801 | * @param string $code |
802 | * @return array<int,string>|null |
803 | */ |
804 | private function getPluralRuleTypes( $code ) { |
805 | if ( self::$pluralRuleTypes === null ) { |
806 | self::loadPluralFiles(); |
807 | } |
808 | return self::$pluralRuleTypes[$code] ?? null; |
809 | } |
810 | |
811 | /** |
812 | * Load the plural XML files. |
813 | */ |
814 | private static function loadPluralFiles() { |
815 | foreach ( self::PLURAL_FILES as $fileName ) { |
816 | self::loadPluralFile( $fileName ); |
817 | } |
818 | } |
819 | |
820 | /** |
821 | * Load a plural XML file with the given filename, compile the relevant |
822 | * rules, and save the compiled rules in a process-local cache. |
823 | * |
824 | * @param string $fileName |
825 | */ |
826 | private static function loadPluralFile( $fileName ) { |
827 | // Use file_get_contents instead of DOMDocument::load (T58439) |
828 | $xml = file_get_contents( $fileName ); |
829 | if ( !$xml ) { |
830 | throw new RuntimeException( "Unable to read plurals file $fileName" ); |
831 | } |
832 | $doc = new DOMDocument; |
833 | $doc->loadXML( $xml ); |
834 | $rulesets = $doc->getElementsByTagName( "pluralRules" ); |
835 | foreach ( $rulesets as $ruleset ) { |
836 | $codes = $ruleset->getAttribute( 'locales' ); |
837 | $rules = []; |
838 | $ruleTypes = []; |
839 | $ruleElements = $ruleset->getElementsByTagName( "pluralRule" ); |
840 | foreach ( $ruleElements as $elt ) { |
841 | $ruleType = $elt->getAttribute( 'count' ); |
842 | if ( $ruleType === 'other' ) { |
843 | // Don't record "other" rules, which have an empty condition |
844 | continue; |
845 | } |
846 | $rules[] = $elt->nodeValue; |
847 | $ruleTypes[] = $ruleType; |
848 | } |
849 | foreach ( explode( ' ', $codes ) as $code ) { |
850 | self::$pluralRules[$code] = $rules; |
851 | self::$pluralRuleTypes[$code] = $ruleTypes; |
852 | } |
853 | } |
854 | } |
855 | |
856 | /** |
857 | * Read the data from the source files for a given language, and register |
858 | * the relevant dependencies in the $deps array. |
859 | * |
860 | * @param string $code |
861 | * @param array &$deps |
862 | * @return array |
863 | */ |
864 | private function readSourceFilesAndRegisterDeps( $code, &$deps ) { |
865 | // This reads in the PHP i18n file with non-messages l10n data |
866 | $fileName = $this->langNameUtils->getMessagesFileName( $code ); |
867 | if ( !is_file( $fileName ) ) { |
868 | $data = []; |
869 | } else { |
870 | $deps[] = new FileDependency( $fileName ); |
871 | $data = $this->readPHPFile( $fileName, 'core' ); |
872 | } |
873 | |
874 | return $data; |
875 | } |
876 | |
877 | /** |
878 | * Read and compile the plural data for a given language, |
879 | * and register the relevant dependencies in the $deps array. |
880 | * |
881 | * @param string $code |
882 | * @param array &$deps |
883 | * @return array |
884 | */ |
885 | private function readPluralFilesAndRegisterDeps( $code, &$deps ) { |
886 | $data = [ |
887 | // Load CLDR plural rules for JavaScript |
888 | 'pluralRules' => $this->getPluralRules( $code ), |
889 | // And for PHP |
890 | 'compiledPluralRules' => $this->getCompiledPluralRules( $code ), |
891 | // Load plural rule types |
892 | 'pluralRuleTypes' => $this->getPluralRuleTypes( $code ), |
893 | ]; |
894 | |
895 | foreach ( self::PLURAL_FILES as $fileName ) { |
896 | $deps[] = new FileDependency( $fileName ); |
897 | } |
898 | |
899 | return $data; |
900 | } |
901 | |
902 | /** |
903 | * Merge two localisation values, a primary and a fallback, overwriting the |
904 | * primary value in place. |
905 | * |
906 | * @param string $key |
907 | * @param mixed &$value |
908 | * @param mixed $fallbackValue |
909 | */ |
910 | private function mergeItem( $key, &$value, $fallbackValue ) { |
911 | if ( $value !== null ) { |
912 | if ( $fallbackValue !== null ) { |
913 | if ( in_array( $key, self::MERGEABLE_MAP_KEYS ) ) { |
914 | $value += $fallbackValue; |
915 | } elseif ( in_array( $key, self::MERGEABLE_ALIAS_LIST_KEYS ) ) { |
916 | $value = array_merge_recursive( $value, $fallbackValue ); |
917 | } elseif ( in_array( $key, self::OPTIONAL_MERGE_KEYS ) ) { |
918 | if ( !empty( $value['inherit'] ) ) { |
919 | $value = array_merge( $fallbackValue, $value ); |
920 | } |
921 | |
922 | unset( $value['inherit'] ); |
923 | } elseif ( in_array( $key, self::MAGIC_WORD_KEYS ) ) { |
924 | $this->mergeMagicWords( $value, $fallbackValue ); |
925 | } |
926 | } |
927 | } else { |
928 | $value = $fallbackValue; |
929 | } |
930 | } |
931 | |
932 | /** |
933 | * @param array &$value |
934 | * @param array $fallbackValue |
935 | */ |
936 | private function mergeMagicWords( array &$value, array $fallbackValue ): void { |
937 | foreach ( $fallbackValue as $magicName => $fallbackInfo ) { |
938 | if ( !isset( $value[$magicName] ) ) { |
939 | $value[$magicName] = $fallbackInfo; |
940 | } else { |
941 | $value[$magicName] = [ |
942 | $fallbackInfo[0], |
943 | ...array_unique( [ |
944 | // First value is 1 if the magic word is case-sensitive, 0 if not |
945 | ...array_slice( $value[$magicName], 1 ), |
946 | ...array_slice( $fallbackInfo, 1 ), |
947 | ] ) |
948 | ]; |
949 | } |
950 | } |
951 | } |
952 | |
953 | /** |
954 | * Gets the combined list of messages dirs from |
955 | * core and extensions |
956 | * |
957 | * @since 1.25 |
958 | * @return array |
959 | */ |
960 | public function getMessagesDirs() { |
961 | global $IP; |
962 | |
963 | return [ |
964 | 'core' => "$IP/languages/i18n", |
965 | 'exif' => "$IP/languages/i18n/exif", |
966 | 'api' => "$IP/includes/api/i18n", |
967 | 'rest' => "$IP/includes/Rest/i18n", |
968 | 'oojs-ui' => "$IP/resources/lib/ooui/i18n", |
969 | 'paramvalidator' => "$IP/includes/libs/ParamValidator/i18n", |
970 | ] + $this->options->get( MainConfigNames::MessagesDirs ); |
971 | } |
972 | |
973 | /** |
974 | * Load the core localisation data for a given language code, |
975 | * without extensions, using only the process cache. |
976 | * See {@link self::$coreDataLoaded} for what this guarantees. |
977 | * |
978 | * In addition to the core-only keys, |
979 | * {@link self::$data} may contain additional entries for $code, |
980 | * but those must not be used outside of {@link self::recache()} |
981 | * (and accordingly, they are not marked as loaded yet). |
982 | */ |
983 | private function loadCoreData( string $code ) { |
984 | if ( !$code ) { |
985 | throw new InvalidArgumentException( "Invalid language code requested" ); |
986 | } |
987 | if ( $this->coreDataLoaded[$code] ?? false ) { |
988 | return; |
989 | } |
990 | |
991 | $coreData = array_fill_keys( self::CORE_ONLY_KEYS, null ); |
992 | $deps = []; |
993 | |
994 | # Load the primary localisation from the source file |
995 | $data = $this->readSourceFilesAndRegisterDeps( $code, $deps ); |
996 | $this->logger->debug( __METHOD__ . ": got localisation for $code from source" ); |
997 | |
998 | # Merge primary localisation |
999 | foreach ( $data as $key => $value ) { |
1000 | $this->mergeItem( $key, $coreData[ $key ], $value ); |
1001 | } |
1002 | |
1003 | # Fill in the fallback if it's not there already |
1004 | // @phan-suppress-next-line PhanSuspiciousValueComparison |
1005 | if ( ( $coreData['fallback'] === null || $coreData['fallback'] === false ) && $code === 'en' ) { |
1006 | $coreData['fallback'] = false; |
1007 | $coreData['originalFallbackSequence'] = $coreData['fallbackSequence'] = []; |
1008 | } else { |
1009 | if ( $coreData['fallback'] !== null ) { |
1010 | $coreData['fallbackSequence'] = array_map( 'trim', explode( ',', $coreData['fallback'] ) ); |
1011 | } else { |
1012 | $coreData['fallbackSequence'] = []; |
1013 | } |
1014 | $len = count( $coreData['fallbackSequence'] ); |
1015 | |
1016 | # Before we add the 'en' fallback for messages, keep a copy of |
1017 | # the original fallback sequence |
1018 | $coreData['originalFallbackSequence'] = $coreData['fallbackSequence']; |
1019 | |
1020 | # Ensure that the sequence ends at 'en' for messages |
1021 | if ( !$len || $coreData['fallbackSequence'][$len - 1] !== 'en' ) { |
1022 | $coreData['fallbackSequence'][] = 'en'; |
1023 | } |
1024 | } |
1025 | |
1026 | foreach ( $coreData['fallbackSequence'] as $fbCode ) { |
1027 | // load core fallback data |
1028 | $fbData = $this->readSourceFilesAndRegisterDeps( $fbCode, $deps ); |
1029 | foreach ( self::CORE_ONLY_KEYS as $key ) { |
1030 | // core-only keys are not mergeable, only set if not present in core data yet |
1031 | if ( isset( $fbData[$key] ) && !isset( $coreData[$key] ) ) { |
1032 | $coreData[$key] = $fbData[$key]; |
1033 | } |
1034 | } |
1035 | } |
1036 | |
1037 | $coreData['deps'] = $deps; |
1038 | foreach ( $coreData as $key => $item ) { |
1039 | $this->data[$code][$key] ??= null; |
1040 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- we just set a default null |
1041 | $this->mergeItem( $key, $this->data[$code][$key], $item ); |
1042 | if ( |
1043 | in_array( $key, self::CORE_ONLY_KEYS, true ) || |
1044 | // "synthetic" keys based on "fallback" (see above) |
1045 | $key === 'fallbackSequence' || |
1046 | $key === 'originalFallbackSequence' |
1047 | ) { |
1048 | // only mark core-only keys as loaded; |
1049 | // we may have loaded additional ones from the source file, |
1050 | // but they are not fully loaded yet, since recache() |
1051 | // may have to merge in additional values from fallback languages |
1052 | $this->loadedItems[$code][$key] = true; |
1053 | } |
1054 | } |
1055 | |
1056 | $this->coreDataLoaded[$code] = true; |
1057 | } |
1058 | |
1059 | /** |
1060 | * Load localisation data for a given language for both core and extensions |
1061 | * and save it to the persistent cache store and the process cache. |
1062 | * |
1063 | * @param string $code |
1064 | */ |
1065 | public function recache( $code ) { |
1066 | if ( !$code ) { |
1067 | throw new InvalidArgumentException( "Invalid language code requested" ); |
1068 | } |
1069 | $this->recachedLangs[ $code ] = true; |
1070 | |
1071 | # Initial values |
1072 | $initialData = array_fill_keys( self::ALL_KEYS, null ); |
1073 | $this->data[$code] = []; |
1074 | $this->loadedItems[$code] = []; |
1075 | $this->loadedSubitems[$code] = []; |
1076 | $this->coreDataLoaded[$code] = false; |
1077 | $this->loadCoreData( $code ); |
1078 | $coreData = $this->data[$code]; |
1079 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- guaranteed by loadCoreData() |
1080 | $deps = $coreData['deps']; |
1081 | $coreData += $this->readPluralFilesAndRegisterDeps( $code, $deps ); |
1082 | |
1083 | $codeSequence = array_merge( [ $code ], $coreData['fallbackSequence'] ); |
1084 | $messageDirs = $this->getMessagesDirs(); |
1085 | $translationAliasesDirs = $this->options->get( MainConfigNames::TranslationAliasesDirs ); |
1086 | |
1087 | # Load non-JSON localisation data for extensions |
1088 | $extensionData = array_fill_keys( $codeSequence, $initialData ); |
1089 | foreach ( $this->options->get( MainConfigNames::ExtensionMessagesFiles ) as $extension => $fileName ) { |
1090 | if ( isset( $messageDirs[$extension] ) || isset( $translationAliasesDirs[$extension] ) ) { |
1091 | # This extension has JSON message data; skip the PHP shim |
1092 | continue; |
1093 | } |
1094 | |
1095 | $data = $this->readPHPFile( $fileName, 'extension' ); |
1096 | $used = false; |
1097 | |
1098 | foreach ( $data as $key => $item ) { |
1099 | foreach ( $codeSequence as $csCode ) { |
1100 | if ( isset( $item[$csCode] ) ) { |
1101 | // Keep the behaviour the same as for json messages. |
1102 | // TODO: Consider deprecating using a PHP file for messages. |
1103 | if ( in_array( $key, self::SOURCE_PREFIX_KEYS ) ) { |
1104 | foreach ( $item[$csCode] as $subkey => $_ ) { |
1105 | $this->sourceLanguage[$code][$key][$subkey] ??= $csCode; |
1106 | } |
1107 | } |
1108 | $this->mergeItem( $key, $extensionData[$csCode][$key], $item[$csCode] ); |
1109 | $used = true; |
1110 | } |
1111 | } |
1112 | } |
1113 | |
1114 | if ( $used ) { |
1115 | $deps[] = new FileDependency( $fileName ); |
1116 | } |
1117 | } |
1118 | |
1119 | # Load the localisation data for each fallback, then merge it into the full array |
1120 | $allData = $initialData; |
1121 | foreach ( $codeSequence as $csCode ) { |
1122 | $csData = $initialData; |
1123 | |
1124 | # Load core messages and the extension localisations. |
1125 | foreach ( $messageDirs as $dirs ) { |
1126 | foreach ( (array)$dirs as $dir ) { |
1127 | $fileName = "$dir/$csCode.json"; |
1128 | $messages = $this->readJSONFile( $fileName ); |
1129 | |
1130 | foreach ( $messages as $subkey => $_ ) { |
1131 | $this->sourceLanguage[$code]['messages'][$subkey] ??= $csCode; |
1132 | } |
1133 | $this->mergeItem( 'messages', $csData['messages'], $messages ); |
1134 | |
1135 | $deps[] = new FileDependency( $fileName ); |
1136 | } |
1137 | } |
1138 | |
1139 | foreach ( $translationAliasesDirs as $dirs ) { |
1140 | foreach ( (array)$dirs as $dir ) { |
1141 | $fileName = "$dir/$csCode.json"; |
1142 | $data = $this->readJSONFile( $fileName ); |
1143 | |
1144 | foreach ( $data as $key => $item ) { |
1145 | // We allow the key in the JSON to be specified in PascalCase similar to key definitions in |
1146 | // extension.json, but eventually they are stored in camelCase |
1147 | $normalizedKey = lcfirst( $key ); |
1148 | |
1149 | if ( $normalizedKey === '@metadata' ) { |
1150 | // Don't store @metadata information in extension data. |
1151 | continue; |
1152 | } |
1153 | |
1154 | if ( !in_array( $normalizedKey, self::ALL_ALIAS_KEYS ) ) { |
1155 | throw new UnexpectedValueException( |
1156 | "Invalid key: \"$key\" for " . MainConfigNames::TranslationAliasesDirs . ". " . |
1157 | 'Valid keys: ' . implode( ', ', self::ALL_ALIAS_KEYS ) |
1158 | ); |
1159 | } |
1160 | |
1161 | $this->mergeItem( $normalizedKey, $extensionData[$csCode][$normalizedKey], $item ); |
1162 | } |
1163 | |
1164 | $deps[] = new FileDependency( $fileName ); |
1165 | } |
1166 | } |
1167 | |
1168 | # Merge non-JSON extension data |
1169 | if ( isset( $extensionData[$csCode] ) ) { |
1170 | foreach ( $extensionData[$csCode] as $key => $item ) { |
1171 | $this->mergeItem( $key, $csData[$key], $item ); |
1172 | } |
1173 | } |
1174 | |
1175 | if ( $csCode === $code ) { |
1176 | # Merge core data into extension data |
1177 | foreach ( $coreData as $key => $item ) { |
1178 | $this->mergeItem( $key, $csData[$key], $item ); |
1179 | } |
1180 | } else { |
1181 | # Load the secondary localisation from the source file to |
1182 | # avoid infinite cycles on cyclic fallbacks |
1183 | $fbData = $this->readSourceFilesAndRegisterDeps( $csCode, $deps ); |
1184 | $fbData += $this->readPluralFilesAndRegisterDeps( $csCode, $deps ); |
1185 | # Only merge the keys that make sense to merge |
1186 | foreach ( self::ALL_KEYS as $key ) { |
1187 | if ( !isset( $fbData[ $key ] ) ) { |
1188 | continue; |
1189 | } |
1190 | |
1191 | if ( !isset( $coreData[ $key ] ) || self::isMergeableKey( $key ) ) { |
1192 | $this->mergeItem( $key, $csData[ $key ], $fbData[ $key ] ); |
1193 | } |
1194 | } |
1195 | } |
1196 | |
1197 | # Allow extensions an opportunity to adjust the data for this fallback |
1198 | $this->hookRunner->onLocalisationCacheRecacheFallback( $this, $csCode, $csData ); |
1199 | |
1200 | # Merge the data for this fallback into the final array |
1201 | if ( $csCode === $code ) { |
1202 | $allData = $csData; |
1203 | } else { |
1204 | foreach ( self::ALL_KEYS as $key ) { |
1205 | if ( !isset( $csData[$key] ) ) { |
1206 | continue; |
1207 | } |
1208 | |
1209 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable |
1210 | if ( $allData[$key] === null || self::isMergeableKey( $key ) ) { |
1211 | $this->mergeItem( $key, $allData[$key], $csData[$key] ); |
1212 | } |
1213 | } |
1214 | } |
1215 | } |
1216 | |
1217 | if ( !isset( $allData['rtl'] ) ) { |
1218 | throw new RuntimeException( __METHOD__ . ': Localisation data failed validation check! ' . |
1219 | 'Check that your languages/messages/MessagesEn.php file is intact.' ); |
1220 | } |
1221 | |
1222 | // Add cache dependencies for any referenced configs |
1223 | // We use the keys prefixed with 'wg' for historical reasons. |
1224 | $deps['wgExtensionMessagesFiles'] = |
1225 | new MainConfigDependency( MainConfigNames::ExtensionMessagesFiles ); |
1226 | $deps['wgMessagesDirs'] = |
1227 | new MainConfigDependency( MainConfigNames::MessagesDirs ); |
1228 | $deps['version'] = new ConstantDependency( self::class . '::VERSION' ); |
1229 | |
1230 | # Add dependencies to the cache entry |
1231 | $allData['deps'] = $deps; |
1232 | |
1233 | # Replace spaces with underscores in namespace names |
1234 | $allData['namespaceNames'] = str_replace( ' ', '_', $allData['namespaceNames'] ); |
1235 | |
1236 | # And do the same for special page aliases. $page is an array. |
1237 | foreach ( $allData['specialPageAliases'] as &$page ) { |
1238 | $page = str_replace( ' ', '_', $page ); |
1239 | } |
1240 | # Decouple the reference to prevent accidental damage |
1241 | unset( $page ); |
1242 | |
1243 | # If there were no plural rules, return an empty array |
1244 | $allData['pluralRules'] ??= []; |
1245 | $allData['compiledPluralRules'] ??= []; |
1246 | # If there were no plural rule types, return an empty array |
1247 | $allData['pluralRuleTypes'] ??= []; |
1248 | |
1249 | # Set the list keys |
1250 | $allData['list'] = []; |
1251 | foreach ( self::SPLIT_KEYS as $key ) { |
1252 | $allData['list'][$key] = array_keys( $allData[$key] ); |
1253 | } |
1254 | # Run hooks |
1255 | $unused = true; // Used to be $purgeBlobs, removed in 1.34 |
1256 | $this->hookRunner->onLocalisationCacheRecache( $this, $code, $allData, $unused ); |
1257 | |
1258 | # Save to the process cache and register the items loaded |
1259 | $this->data[$code] = $allData; |
1260 | $this->loadedItems[$code] = []; |
1261 | $this->loadedSubitems[$code] = []; |
1262 | foreach ( $allData as $key => $item ) { |
1263 | $this->loadedItems[$code][$key] = true; |
1264 | } |
1265 | |
1266 | # Prefix each item with its source language code before save |
1267 | foreach ( self::SOURCE_PREFIX_KEYS as $key ) { |
1268 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable |
1269 | foreach ( $allData[$key] as $subKey => $value ) { |
1270 | // The source language should have been set, but to avoid Phan error and be double sure. |
1271 | $allData[$key][$subKey] = ( $this->sourceLanguage[$code][$key][$subKey] ?? $code ) . |
1272 | self::SOURCEPREFIX_SEPARATOR . $value; |
1273 | } |
1274 | } |
1275 | |
1276 | # Set the preload key |
1277 | $allData['preload'] = $this->buildPreload( $allData ); |
1278 | |
1279 | # Save to the persistent cache |
1280 | $this->store->startWrite( $code ); |
1281 | foreach ( $allData as $key => $value ) { |
1282 | if ( in_array( $key, self::SPLIT_KEYS ) ) { |
1283 | foreach ( $value as $subkey => $subvalue ) { |
1284 | $this->store->set( "$key:$subkey", $subvalue ); |
1285 | } |
1286 | } else { |
1287 | $this->store->set( $key, $value ); |
1288 | } |
1289 | } |
1290 | $this->store->finishWrite(); |
1291 | |
1292 | # Clear out the MessageBlobStore |
1293 | # HACK: If using a null (i.e., disabled) storage backend, we |
1294 | # can't write to the MessageBlobStore either |
1295 | if ( !$this->store instanceof LCStoreNull ) { |
1296 | foreach ( $this->clearStoreCallbacks as $callback ) { |
1297 | $callback(); |
1298 | } |
1299 | } |
1300 | } |
1301 | |
1302 | /** |
1303 | * Build the preload item from the given pre-cache data. |
1304 | * |
1305 | * The preload item will be loaded automatically, improving performance |
1306 | * for the commonly requested items it contains. |
1307 | * |
1308 | * @param array $data |
1309 | * @return array |
1310 | */ |
1311 | private function buildPreload( $data ) { |
1312 | $preload = [ 'messages' => [] ]; |
1313 | foreach ( self::PRELOADED_KEYS as $key ) { |
1314 | $preload[$key] = $data[$key]; |
1315 | } |
1316 | |
1317 | foreach ( $data['preloadedMessages'] as $subkey ) { |
1318 | $subitem = $data['messages'][$subkey] ?? null; |
1319 | $preload['messages'][$subkey] = $subitem; |
1320 | } |
1321 | |
1322 | return $preload; |
1323 | } |
1324 | |
1325 | /** |
1326 | * Unload the data for a given language from the object cache. |
1327 | * |
1328 | * Reduces memory usage. |
1329 | * |
1330 | * @param string $code |
1331 | */ |
1332 | public function unload( $code ) { |
1333 | unset( $this->data[$code] ); |
1334 | unset( $this->loadedItems[$code] ); |
1335 | unset( $this->loadedSubitems[$code] ); |
1336 | unset( $this->initialisedLangs[$code] ); |
1337 | unset( $this->shallowFallbacks[$code] ); |
1338 | unset( $this->sourceLanguage[$code] ); |
1339 | unset( $this->coreDataLoaded[$code] ); |
1340 | |
1341 | foreach ( $this->shallowFallbacks as $shallowCode => $fbCode ) { |
1342 | if ( $fbCode === $code ) { |
1343 | $this->unload( $shallowCode ); |
1344 | } |
1345 | } |
1346 | } |
1347 | |
1348 | /** |
1349 | * Unload all data |
1350 | */ |
1351 | public function unloadAll() { |
1352 | foreach ( $this->initialisedLangs as $lang => $unused ) { |
1353 | $this->unload( $lang ); |
1354 | } |
1355 | } |
1356 | |
1357 | /** |
1358 | * Disable the storage backend |
1359 | */ |
1360 | public function disableBackend() { |
1361 | $this->store = new LCStoreNull; |
1362 | $this->manualRecache = false; |
1363 | } |
1364 | } |