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