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