Completed
Branch master (5cbada)
by
unknown
28:59
created

LocalisationCache::getItem()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 6
nc 4
nop 2
dl 0
loc 11
rs 9.2
c 0
b 0
f 0
1
<?php
2
/**
3
 * Cache of the contents of localisation files.
4
 *
5
 * This program is free software; you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation; either version 2 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU General Public License along
16
 * with this program; if not, write to the Free Software Foundation, Inc.,
17
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
 * http://www.gnu.org/copyleft/gpl.html
19
 *
20
 * @file
21
 */
22
23
use Cdb\Reader as CdbReader;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, CdbReader.

Let’s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let’s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
24
use Cdb\Writer as CdbWriter;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, CdbWriter.

Let’s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let’s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
25
use CLDRPluralRuleParser\Evaluator;
26
use MediaWiki\MediaWikiServices;
27
28
/**
29
 * Class for caching the contents of localisation files, Messages*.php
30
 * and *.i18n.php.
31
 *
32
 * An instance of this class is available using Language::getLocalisationCache().
33
 *
34
 * The values retrieved from here are merged, containing items from extension
35
 * files, core messages files and the language fallback sequence (e.g. zh-cn ->
36
 * zh-hans -> en ). Some common errors are corrected, for example namespace
37
 * names with spaces instead of underscores, but heavyweight processing, such
38
 * as grammatical transformation, is done by the caller.
39
 */
40
class LocalisationCache {
41
	const VERSION = 4;
42
43
	/** Configuration associative array */
44
	private $conf;
45
46
	/**
47
	 * True if recaching should only be done on an explicit call to recache().
48
	 * Setting this reduces the overhead of cache freshness checking, which
49
	 * requires doing a stat() for every extension i18n file.
50
	 */
51
	private $manualRecache = false;
52
53
	/**
54
	 * True to treat all files as expired until they are regenerated by this object.
55
	 */
56
	private $forceRecache = false;
57
58
	/**
59
	 * The cache data. 3-d array, where the first key is the language code,
60
	 * the second key is the item key e.g. 'messages', and the third key is
61
	 * an item specific subkey index. Some items are not arrays and so for those
62
	 * items, there are no subkeys.
63
	 */
64
	protected $data = [];
65
66
	/**
67
	 * The persistent store object. An instance of LCStore.
68
	 *
69
	 * @var LCStore
70
	 */
71
	private $store;
72
73
	/**
74
	 * A 2-d associative array, code/key, where presence indicates that the item
75
	 * is loaded. Value arbitrary.
76
	 *
77
	 * For split items, if set, this indicates that all of the subitems have been
78
	 * loaded.
79
	 */
80
	private $loadedItems = [];
81
82
	/**
83
	 * A 3-d associative array, code/key/subkey, where presence indicates that
84
	 * the subitem is loaded. Only used for the split items, i.e. messages.
85
	 */
86
	private $loadedSubitems = [];
87
88
	/**
89
	 * An array where presence of a key indicates that that language has been
90
	 * initialised. Initialisation includes checking for cache expiry and doing
91
	 * any necessary updates.
92
	 */
93
	private $initialisedLangs = [];
94
95
	/**
96
	 * An array mapping non-existent pseudo-languages to fallback languages. This
97
	 * is filled by initShallowFallback() when data is requested from a language
98
	 * that lacks a Messages*.php file.
99
	 */
100
	private $shallowFallbacks = [];
101
102
	/**
103
	 * An array where the keys are codes that have been recached by this instance.
104
	 */
105
	private $recachedLangs = [];
106
107
	/**
108
	 * All item keys
109
	 */
110
	static public $allKeys = [
111
		'fallback', 'namespaceNames', 'bookstoreList',
112
		'magicWords', 'messages', 'rtl', 'capitalizeAllNouns', 'digitTransformTable',
113
		'separatorTransformTable', 'fallback8bitEncoding', 'linkPrefixExtension',
114
		'linkTrail', 'linkPrefixCharset', 'namespaceAliases',
115
		'dateFormats', 'datePreferences', 'datePreferenceMigrationMap',
116
		'defaultDateFormat', 'extraUserToggles', 'specialPageAliases',
117
		'imageFiles', 'preloadedMessages', 'namespaceGenderAliases',
118
		'digitGroupingPattern', 'pluralRules', 'pluralRuleTypes', 'compiledPluralRules',
119
	];
120
121
	/**
122
	 * Keys for items which consist of associative arrays, which may be merged
123
	 * by a fallback sequence.
124
	 */
125
	static public $mergeableMapKeys = [ 'messages', 'namespaceNames',
126
		'namespaceAliases', 'dateFormats', 'imageFiles', 'preloadedMessages'
127
	];
128
129
	/**
130
	 * Keys for items which are a numbered array.
131
	 */
132
	static public $mergeableListKeys = [ 'extraUserToggles' ];
133
134
	/**
135
	 * Keys for items which contain an array of arrays of equivalent aliases
136
	 * for each subitem. The aliases may be merged by a fallback sequence.
137
	 */
138
	static public $mergeableAliasListKeys = [ 'specialPageAliases' ];
139
140
	/**
141
	 * Keys for items which contain an associative array, and may be merged if
142
	 * the primary value contains the special array key "inherit". That array
143
	 * key is removed after the first merge.
144
	 */
145
	static public $optionalMergeKeys = [ 'bookstoreList' ];
146
147
	/**
148
	 * Keys for items that are formatted like $magicWords
149
	 */
150
	static public $magicWordKeys = [ 'magicWords' ];
151
152
	/**
153
	 * Keys for items where the subitems are stored in the backend separately.
154
	 */
155
	static public $splitKeys = [ 'messages' ];
156
157
	/**
158
	 * Keys which are loaded automatically by initLanguage()
159
	 */
160
	static public $preloadedKeys = [ 'dateFormats', 'namespaceNames' ];
161
162
	/**
163
	 * Associative array of cached plural rules. The key is the language code,
164
	 * the value is an array of plural rules for that language.
165
	 */
166
	private $pluralRules = null;
167
168
	/**
169
	 * Associative array of cached plural rule types. The key is the language
170
	 * code, the value is an array of plural rule types for that language. For
171
	 * example, $pluralRuleTypes['ar'] = ['zero', 'one', 'two', 'few', 'many'].
172
	 * The index for each rule type matches the index for the rule in
173
	 * $pluralRules, thus allowing correlation between the two. The reason we
174
	 * don't just use the type names as the keys in $pluralRules is because
175
	 * Language::convertPlural applies the rules based on numeric order (or
176
	 * explicit numeric parameter), not based on the name of the rule type. For
177
	 * example, {{plural:count|wordform1|wordform2|wordform3}}, rather than
178
	 * {{plural:count|one=wordform1|two=wordform2|many=wordform3}}.
179
	 */
180
	private $pluralRuleTypes = null;
181
182
	private $mergeableKeys = null;
183
184
	/**
185
	 * Constructor.
186
	 * For constructor parameters, see the documentation in DefaultSettings.php
187
	 * for $wgLocalisationCacheConf.
188
	 *
189
	 * @param array $conf
190
	 * @throws MWException
191
	 */
192
	function __construct( $conf ) {
193
		global $wgCacheDirectory;
194
195
		$this->conf = $conf;
196
		$storeConf = [];
197
		if ( !empty( $conf['storeClass'] ) ) {
198
			$storeClass = $conf['storeClass'];
199
		} else {
200
			switch ( $conf['store'] ) {
201
				case 'files':
202
				case 'file':
203
					$storeClass = 'LCStoreCDB';
204
					break;
205
				case 'db':
206
					$storeClass = 'LCStoreDB';
207
					break;
208
				case 'array':
209
					$storeClass = 'LCStoreStaticArray';
210
					break;
211
				case 'detect':
212
					if ( !empty( $conf['storeDirectory'] ) ) {
213
						$storeClass = 'LCStoreCDB';
214
					} else {
215
						$cacheDir = $wgCacheDirectory ?: wfTempDir();
216
						if ( $cacheDir ) {
217
							$storeConf['directory'] = $cacheDir;
218
							$storeClass = 'LCStoreCDB';
219
						} else {
220
							$storeClass = 'LCStoreDB';
221
						}
222
					}
223
					break;
224
				default:
225
					throw new MWException(
226
						'Please set $wgLocalisationCacheConf[\'store\'] to something sensible.' );
227
			}
228
		}
229
230
		wfDebugLog( 'caches', get_class( $this ) . ": using store $storeClass" );
231
		if ( !empty( $conf['storeDirectory'] ) ) {
232
			$storeConf['directory'] = $conf['storeDirectory'];
233
		}
234
235
		$this->store = new $storeClass( $storeConf );
236
		foreach ( [ 'manualRecache', 'forceRecache' ] as $var ) {
237
			if ( isset( $conf[$var] ) ) {
238
				$this->$var = $conf[$var];
239
			}
240
		}
241
	}
242
243
	/**
244
	 * Returns true if the given key is mergeable, that is, if it is an associative
245
	 * array which can be merged through a fallback sequence.
246
	 * @param string $key
247
	 * @return bool
248
	 */
249
	public function isMergeableKey( $key ) {
250
		if ( $this->mergeableKeys === null ) {
251
			$this->mergeableKeys = array_flip( array_merge(
252
				self::$mergeableMapKeys,
253
				self::$mergeableListKeys,
254
				self::$mergeableAliasListKeys,
255
				self::$optionalMergeKeys,
256
				self::$magicWordKeys
257
			) );
258
		}
259
260
		return isset( $this->mergeableKeys[$key] );
261
	}
262
263
	/**
264
	 * Get a cache item.
265
	 *
266
	 * Warning: this may be slow for split items (messages), since it will
267
	 * need to fetch all of the subitems from the cache individually.
268
	 * @param string $code
269
	 * @param string $key
270
	 * @return mixed
271
	 */
272
	public function getItem( $code, $key ) {
273
		if ( !isset( $this->loadedItems[$code][$key] ) ) {
274
			$this->loadItem( $code, $key );
275
		}
276
277
		if ( $key === 'fallback' && isset( $this->shallowFallbacks[$code] ) ) {
278
			return $this->shallowFallbacks[$code];
279
		}
280
281
		return $this->data[$code][$key];
282
	}
283
284
	/**
285
	 * Get a subitem, for instance a single message for a given language.
286
	 * @param string $code
287
	 * @param string $key
288
	 * @param string $subkey
289
	 * @return mixed|null
290
	 */
291
	public function getSubitem( $code, $key, $subkey ) {
292
		if ( !isset( $this->loadedSubitems[$code][$key][$subkey] ) &&
293
			!isset( $this->loadedItems[$code][$key] )
294
		) {
295
			$this->loadSubitem( $code, $key, $subkey );
296
		}
297
298
		if ( isset( $this->data[$code][$key][$subkey] ) ) {
299
			return $this->data[$code][$key][$subkey];
300
		} else {
301
			return null;
302
		}
303
	}
304
305
	/**
306
	 * Get the list of subitem keys for a given item.
307
	 *
308
	 * This is faster than array_keys($lc->getItem(...)) for the items listed in
309
	 * self::$splitKeys.
310
	 *
311
	 * Will return null if the item is not found, or false if the item is not an
312
	 * array.
313
	 * @param string $code
314
	 * @param string $key
315
	 * @return bool|null|string
316
	 */
317
	public function getSubitemList( $code, $key ) {
318
		if ( in_array( $key, self::$splitKeys ) ) {
319
			return $this->getSubitem( $code, 'list', $key );
320
		} else {
321
			$item = $this->getItem( $code, $key );
322
			if ( is_array( $item ) ) {
323
				return array_keys( $item );
324
			} else {
325
				return false;
326
			}
327
		}
328
	}
329
330
	/**
331
	 * Load an item into the cache.
332
	 * @param string $code
333
	 * @param string $key
334
	 */
335
	protected function loadItem( $code, $key ) {
336
		if ( !isset( $this->initialisedLangs[$code] ) ) {
337
			$this->initLanguage( $code );
338
		}
339
340
		// Check to see if initLanguage() loaded it for us
341
		if ( isset( $this->loadedItems[$code][$key] ) ) {
342
			return;
343
		}
344
345
		if ( isset( $this->shallowFallbacks[$code] ) ) {
346
			$this->loadItem( $this->shallowFallbacks[$code], $key );
347
348
			return;
349
		}
350
351
		if ( in_array( $key, self::$splitKeys ) ) {
352
			$subkeyList = $this->getSubitem( $code, 'list', $key );
353
			foreach ( $subkeyList as $subkey ) {
354
				if ( isset( $this->data[$code][$key][$subkey] ) ) {
355
					continue;
356
				}
357
				$this->data[$code][$key][$subkey] = $this->getSubitem( $code, $key, $subkey );
358
			}
359
		} else {
360
			$this->data[$code][$key] = $this->store->get( $code, $key );
361
		}
362
363
		$this->loadedItems[$code][$key] = true;
364
	}
365
366
	/**
367
	 * Load a subitem into the cache
368
	 * @param string $code
369
	 * @param string $key
370
	 * @param string $subkey
371
	 */
372
	protected function loadSubitem( $code, $key, $subkey ) {
373
		if ( !in_array( $key, self::$splitKeys ) ) {
374
			$this->loadItem( $code, $key );
375
376
			return;
377
		}
378
379
		if ( !isset( $this->initialisedLangs[$code] ) ) {
380
			$this->initLanguage( $code );
381
		}
382
383
		// Check to see if initLanguage() loaded it for us
384
		if ( isset( $this->loadedItems[$code][$key] ) ||
385
			isset( $this->loadedSubitems[$code][$key][$subkey] )
386
		) {
387
			return;
388
		}
389
390
		if ( isset( $this->shallowFallbacks[$code] ) ) {
391
			$this->loadSubitem( $this->shallowFallbacks[$code], $key, $subkey );
392
393
			return;
394
		}
395
396
		$value = $this->store->get( $code, "$key:$subkey" );
397
		$this->data[$code][$key][$subkey] = $value;
398
		$this->loadedSubitems[$code][$key][$subkey] = true;
399
	}
400
401
	/**
402
	 * Returns true if the cache identified by $code is missing or expired.
403
	 *
404
	 * @param string $code
405
	 *
406
	 * @return bool
407
	 */
408
	public function isExpired( $code ) {
409
		if ( $this->forceRecache && !isset( $this->recachedLangs[$code] ) ) {
410
			wfDebug( __METHOD__ . "($code): forced reload\n" );
411
412
			return true;
413
		}
414
415
		$deps = $this->store->get( $code, 'deps' );
416
		$keys = $this->store->get( $code, 'list' );
417
		$preload = $this->store->get( $code, 'preload' );
418
		// Different keys may expire separately for some stores
419
		if ( $deps === null || $keys === null || $preload === null ) {
420
			wfDebug( __METHOD__ . "($code): cache missing, need to make one\n" );
421
422
			return true;
423
		}
424
425
		foreach ( $deps as $dep ) {
426
			// Because we're unserializing stuff from cache, we
427
			// could receive objects of classes that don't exist
428
			// anymore (e.g. uninstalled extensions)
429
			// When this happens, always expire the cache
430
			if ( !$dep instanceof CacheDependency || $dep->isExpired() ) {
431
				wfDebug( __METHOD__ . "($code): cache for $code expired due to " .
432
					get_class( $dep ) . "\n" );
433
434
				return true;
435
			}
436
		}
437
438
		return false;
439
	}
440
441
	/**
442
	 * Initialise a language in this object. Rebuild the cache if necessary.
443
	 * @param string $code
444
	 * @throws MWException
445
	 */
446
	protected function initLanguage( $code ) {
447
		if ( isset( $this->initialisedLangs[$code] ) ) {
448
			return;
449
		}
450
451
		$this->initialisedLangs[$code] = true;
452
453
		# If the code is of the wrong form for a Messages*.php file, do a shallow fallback
454
		if ( !Language::isValidBuiltInCode( $code ) ) {
455
			$this->initShallowFallback( $code, 'en' );
456
457
			return;
458
		}
459
460
		# Recache the data if necessary
461
		if ( !$this->manualRecache && $this->isExpired( $code ) ) {
462
			if ( Language::isSupportedLanguage( $code ) ) {
463
				$this->recache( $code );
464
			} elseif ( $code === 'en' ) {
465
				throw new MWException( 'MessagesEn.php is missing.' );
466
			} else {
467
				$this->initShallowFallback( $code, 'en' );
468
			}
469
470
			return;
471
		}
472
473
		# Preload some stuff
474
		$preload = $this->getItem( $code, 'preload' );
475
		if ( $preload === null ) {
476
			if ( $this->manualRecache ) {
477
				// No Messages*.php file. Do shallow fallback to en.
478
				if ( $code === 'en' ) {
479
					throw new MWException( 'No localisation cache found for English. ' .
480
						'Please run maintenance/rebuildLocalisationCache.php.' );
481
				}
482
				$this->initShallowFallback( $code, 'en' );
483
484
				return;
485
			} else {
486
				throw new MWException( 'Invalid or missing localisation cache.' );
487
			}
488
		}
489
		$this->data[$code] = $preload;
490
		foreach ( $preload as $key => $item ) {
491
			if ( in_array( $key, self::$splitKeys ) ) {
492
				foreach ( $item as $subkey => $subitem ) {
493
					$this->loadedSubitems[$code][$key][$subkey] = true;
494
				}
495
			} else {
496
				$this->loadedItems[$code][$key] = true;
497
			}
498
		}
499
	}
500
501
	/**
502
	 * Create a fallback from one language to another, without creating a
503
	 * complete persistent cache.
504
	 * @param string $primaryCode
505
	 * @param string $fallbackCode
506
	 */
507
	public function initShallowFallback( $primaryCode, $fallbackCode ) {
508
		$this->data[$primaryCode] =& $this->data[$fallbackCode];
509
		$this->loadedItems[$primaryCode] =& $this->loadedItems[$fallbackCode];
510
		$this->loadedSubitems[$primaryCode] =& $this->loadedSubitems[$fallbackCode];
511
		$this->shallowFallbacks[$primaryCode] = $fallbackCode;
512
	}
513
514
	/**
515
	 * Read a PHP file containing localisation data.
516
	 * @param string $_fileName
517
	 * @param string $_fileType
518
	 * @throws MWException
519
	 * @return array
520
	 */
521
	protected function readPHPFile( $_fileName, $_fileType ) {
522
		// Disable APC caching
523
		MediaWiki\suppressWarnings();
524
		$_apcEnabled = ini_set( 'apc.cache_by_default', '0' );
525
		MediaWiki\restoreWarnings();
526
527
		include $_fileName;
528
529
		MediaWiki\suppressWarnings();
530
		ini_set( 'apc.cache_by_default', $_apcEnabled );
531
		MediaWiki\restoreWarnings();
532
533
		if ( $_fileType == 'core' || $_fileType == 'extension' ) {
534
			$data = compact( self::$allKeys );
535
		} elseif ( $_fileType == 'aliases' ) {
536
			$data = compact( 'aliases' );
537
		} else {
538
			throw new MWException( __METHOD__ . ": Invalid file type: $_fileType" );
539
		}
540
541
		return $data;
542
	}
543
544
	/**
545
	 * Read a JSON file containing localisation messages.
546
	 * @param string $fileName Name of file to read
547
	 * @throws MWException If there is a syntax error in the JSON file
548
	 * @return array Array with a 'messages' key, or empty array if the file doesn't exist
549
	 */
550
	public function readJSONFile( $fileName ) {
551
552
		if ( !is_readable( $fileName ) ) {
553
			return [];
554
		}
555
556
		$json = file_get_contents( $fileName );
557
		if ( $json === false ) {
558
			return [];
559
		}
560
561
		$data = FormatJson::decode( $json, true );
562
		if ( $data === null ) {
563
564
			throw new MWException( __METHOD__ . ": Invalid JSON file: $fileName" );
565
		}
566
567
		// Remove keys starting with '@', they're reserved for metadata and non-message data
568
		foreach ( $data as $key => $unused ) {
569
			if ( $key === '' || $key[0] === '@' ) {
570
				unset( $data[$key] );
571
			}
572
		}
573
574
		// The JSON format only supports messages, none of the other variables, so wrap the data
575
		return [ 'messages' => $data ];
576
	}
577
578
	/**
579
	 * Get the compiled plural rules for a given language from the XML files.
580
	 * @since 1.20
581
	 * @param string $code
582
	 * @return array|null
583
	 */
584
	public function getCompiledPluralRules( $code ) {
585
		$rules = $this->getPluralRules( $code );
586
		if ( $rules === null ) {
587
			return null;
588
		}
589
		try {
590
			$compiledRules = Evaluator::compile( $rules );
591
		} catch ( CLDRPluralRuleError $e ) {
0 ignored issues
show
Bug introduced by
The class CLDRPluralRuleError does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
592
			wfDebugLog( 'l10n', $e->getMessage() );
593
594
			return [];
595
		}
596
597
		return $compiledRules;
598
	}
599
600
	/**
601
	 * Get the plural rules for a given language from the XML files.
602
	 * Cached.
603
	 * @since 1.20
604
	 * @param string $code
605
	 * @return array|null
606
	 */
607 View Code Duplication
	public function getPluralRules( $code ) {
608
		if ( $this->pluralRules === null ) {
609
			$this->loadPluralFiles();
610
		}
611
		if ( !isset( $this->pluralRules[$code] ) ) {
612
			return null;
613
		} else {
614
			return $this->pluralRules[$code];
615
		}
616
	}
617
618
	/**
619
	 * Get the plural rule types for a given language from the XML files.
620
	 * Cached.
621
	 * @since 1.22
622
	 * @param string $code
623
	 * @return array|null
624
	 */
625 View Code Duplication
	public function getPluralRuleTypes( $code ) {
626
		if ( $this->pluralRuleTypes === null ) {
627
			$this->loadPluralFiles();
628
		}
629
		if ( !isset( $this->pluralRuleTypes[$code] ) ) {
630
			return null;
631
		} else {
632
			return $this->pluralRuleTypes[$code];
633
		}
634
	}
635
636
	/**
637
	 * Load the plural XML files.
638
	 */
639
	protected function loadPluralFiles() {
640
		global $IP;
641
		$cldrPlural = "$IP/languages/data/plurals.xml";
642
		$mwPlural = "$IP/languages/data/plurals-mediawiki.xml";
643
		// Load CLDR plural rules
644
		$this->loadPluralFile( $cldrPlural );
645
		if ( file_exists( $mwPlural ) ) {
646
			// Override or extend
647
			$this->loadPluralFile( $mwPlural );
648
		}
649
	}
650
651
	/**
652
	 * Load a plural XML file with the given filename, compile the relevant
653
	 * rules, and save the compiled rules in a process-local cache.
654
	 *
655
	 * @param string $fileName
656
	 * @throws MWException
657
	 */
658
	protected function loadPluralFile( $fileName ) {
659
		// Use file_get_contents instead of DOMDocument::load (T58439)
660
		$xml = file_get_contents( $fileName );
661
		if ( !$xml ) {
662
			throw new MWException( "Unable to read plurals file $fileName" );
663
		}
664
		$doc = new DOMDocument;
665
		$doc->loadXML( $xml );
666
		$rulesets = $doc->getElementsByTagName( "pluralRules" );
667
		foreach ( $rulesets as $ruleset ) {
668
			$codes = $ruleset->getAttribute( 'locales' );
669
			$rules = [];
670
			$ruleTypes = [];
671
			$ruleElements = $ruleset->getElementsByTagName( "pluralRule" );
672
			foreach ( $ruleElements as $elt ) {
673
				$ruleType = $elt->getAttribute( 'count' );
674
				if ( $ruleType === 'other' ) {
675
					// Don't record "other" rules, which have an empty condition
676
					continue;
677
				}
678
				$rules[] = $elt->nodeValue;
679
				$ruleTypes[] = $ruleType;
680
			}
681
			foreach ( explode( ' ', $codes ) as $code ) {
682
				$this->pluralRules[$code] = $rules;
683
				$this->pluralRuleTypes[$code] = $ruleTypes;
684
			}
685
		}
686
	}
687
688
	/**
689
	 * Read the data from the source files for a given language, and register
690
	 * the relevant dependencies in the $deps array. If the localisation
691
	 * exists, the data array is returned, otherwise false is returned.
692
	 *
693
	 * @param string $code
694
	 * @param array $deps
695
	 * @return array
696
	 */
697
	protected function readSourceFilesAndRegisterDeps( $code, &$deps ) {
698
		global $IP;
699
700
		// This reads in the PHP i18n file with non-messages l10n data
701
		$fileName = Language::getMessagesFileName( $code );
702
		if ( !file_exists( $fileName ) ) {
703
			$data = [];
704
		} else {
705
			$deps[] = new FileDependency( $fileName );
706
			$data = $this->readPHPFile( $fileName, 'core' );
707
		}
708
709
		# Load CLDR plural rules for JavaScript
710
		$data['pluralRules'] = $this->getPluralRules( $code );
711
		# And for PHP
712
		$data['compiledPluralRules'] = $this->getCompiledPluralRules( $code );
713
		# Load plural rule types
714
		$data['pluralRuleTypes'] = $this->getPluralRuleTypes( $code );
715
716
		$deps['plurals'] = new FileDependency( "$IP/languages/data/plurals.xml" );
717
		$deps['plurals-mw'] = new FileDependency( "$IP/languages/data/plurals-mediawiki.xml" );
718
719
		return $data;
720
	}
721
722
	/**
723
	 * Merge two localisation values, a primary and a fallback, overwriting the
724
	 * primary value in place.
725
	 * @param string $key
726
	 * @param mixed $value
727
	 * @param mixed $fallbackValue
728
	 */
729
	protected function mergeItem( $key, &$value, $fallbackValue ) {
730
		if ( !is_null( $value ) ) {
731
			if ( !is_null( $fallbackValue ) ) {
732
				if ( in_array( $key, self::$mergeableMapKeys ) ) {
733
					$value = $value + $fallbackValue;
734
				} elseif ( in_array( $key, self::$mergeableListKeys ) ) {
735
					$value = array_unique( array_merge( $fallbackValue, $value ) );
736
				} elseif ( in_array( $key, self::$mergeableAliasListKeys ) ) {
737
					$value = array_merge_recursive( $value, $fallbackValue );
738
				} elseif ( in_array( $key, self::$optionalMergeKeys ) ) {
739
					if ( !empty( $value['inherit'] ) ) {
740
						$value = array_merge( $fallbackValue, $value );
741
					}
742
743
					if ( isset( $value['inherit'] ) ) {
744
						unset( $value['inherit'] );
745
					}
746
				} elseif ( in_array( $key, self::$magicWordKeys ) ) {
747
					$this->mergeMagicWords( $value, $fallbackValue );
748
				}
749
			}
750
		} else {
751
			$value = $fallbackValue;
752
		}
753
	}
754
755
	/**
756
	 * @param mixed $value
757
	 * @param mixed $fallbackValue
758
	 */
759
	protected function mergeMagicWords( &$value, $fallbackValue ) {
760
		foreach ( $fallbackValue as $magicName => $fallbackInfo ) {
761
			if ( !isset( $value[$magicName] ) ) {
762
				$value[$magicName] = $fallbackInfo;
763
			} else {
764
				$oldSynonyms = array_slice( $fallbackInfo, 1 );
765
				$newSynonyms = array_slice( $value[$magicName], 1 );
766
				$synonyms = array_values( array_unique( array_merge(
767
					$newSynonyms, $oldSynonyms ) ) );
768
				$value[$magicName] = array_merge( [ $fallbackInfo[0] ], $synonyms );
769
			}
770
		}
771
	}
772
773
	/**
774
	 * Given an array mapping language code to localisation value, such as is
775
	 * found in extension *.i18n.php files, iterate through a fallback sequence
776
	 * to merge the given data with an existing primary value.
777
	 *
778
	 * Returns true if any data from the extension array was used, false
779
	 * otherwise.
780
	 * @param array $codeSequence
781
	 * @param string $key
782
	 * @param mixed $value
783
	 * @param mixed $fallbackValue
784
	 * @return bool
785
	 */
786
	protected function mergeExtensionItem( $codeSequence, $key, &$value, $fallbackValue ) {
787
		$used = false;
788
		foreach ( $codeSequence as $code ) {
789
			if ( isset( $fallbackValue[$code] ) ) {
790
				$this->mergeItem( $key, $value, $fallbackValue[$code] );
791
				$used = true;
792
			}
793
		}
794
795
		return $used;
796
	}
797
798
	/**
799
	 * Gets the combined list of messages dirs from
800
	 * core and extensions
801
	 *
802
	 * @since 1.25
803
	 * @return array
804
	 */
805
	public function getMessagesDirs() {
806
		global $IP;
807
808
		$config = MediaWikiServices::getInstance()->getMainConfig();
809
		$messagesDirs = $config->get( 'MessagesDirs' );
810
		return [
811
			'core' => "$IP/languages/i18n",
812
			'api' => "$IP/includes/api/i18n",
813
			'oojs-ui' => "$IP/resources/lib/oojs-ui/i18n",
814
		] + $messagesDirs;
815
	}
816
817
	/**
818
	 * Load localisation data for a given language for both core and extensions
819
	 * and save it to the persistent cache store and the process cache
820
	 * @param string $code
821
	 * @throws MWException
822
	 */
823
	public function recache( $code ) {
824
		global $wgExtensionMessagesFiles;
825
826
		if ( !$code ) {
827
			throw new MWException( "Invalid language code requested" );
828
		}
829
		$this->recachedLangs[$code] = true;
830
831
		# Initial values
832
		$initialData = array_fill_keys( self::$allKeys, null );
833
		$coreData = $initialData;
834
		$deps = [];
835
836
		# Load the primary localisation from the source file
837
		$data = $this->readSourceFilesAndRegisterDeps( $code, $deps );
838
		if ( $data === false ) {
839
			wfDebug( __METHOD__ . ": no localisation file for $code, using fallback to en\n" );
840
			$coreData['fallback'] = 'en';
841
		} else {
842
			wfDebug( __METHOD__ . ": got localisation for $code from source\n" );
843
844
			# Merge primary localisation
845
			foreach ( $data as $key => $value ) {
846
				$this->mergeItem( $key, $coreData[$key], $value );
847
			}
848
		}
849
850
		# Fill in the fallback if it's not there already
851
		if ( is_null( $coreData['fallback'] ) ) {
852
			$coreData['fallback'] = $code === 'en' ? false : 'en';
853
		}
854
		if ( $coreData['fallback'] === false ) {
855
			$coreData['fallbackSequence'] = [];
856
		} else {
857
			$coreData['fallbackSequence'] = array_map( 'trim', explode( ',', $coreData['fallback'] ) );
858
			$len = count( $coreData['fallbackSequence'] );
859
860
			# Ensure that the sequence ends at en
861
			if ( $coreData['fallbackSequence'][$len - 1] !== 'en' ) {
862
				$coreData['fallbackSequence'][] = 'en';
863
			}
864
		}
865
866
		$codeSequence = array_merge( [ $code ], $coreData['fallbackSequence'] );
867
		$messageDirs = $this->getMessagesDirs();
868
869
		# Load non-JSON localisation data for extensions
870
		$extensionData = array_fill_keys( $codeSequence, $initialData );
871
		foreach ( $wgExtensionMessagesFiles as $extension => $fileName ) {
872
			if ( isset( $messageDirs[$extension] ) ) {
873
				# This extension has JSON message data; skip the PHP shim
874
				continue;
875
			}
876
877
			$data = $this->readPHPFile( $fileName, 'extension' );
878
			$used = false;
879
880
			foreach ( $data as $key => $item ) {
881
				foreach ( $codeSequence as $csCode ) {
882 View Code Duplication
					if ( isset( $item[$csCode] ) ) {
883
						$this->mergeItem( $key, $extensionData[$csCode][$key], $item[$csCode] );
884
						$used = true;
885
					}
886
				}
887
			}
888
889
			if ( $used ) {
890
				$deps[] = new FileDependency( $fileName );
891
			}
892
		}
893
894
		# Load the localisation data for each fallback, then merge it into the full array
895
		$allData = $initialData;
896
		foreach ( $codeSequence as $csCode ) {
897
			$csData = $initialData;
898
899
			# Load core messages and the extension localisations.
900
			foreach ( $messageDirs as $dirs ) {
901
				foreach ( (array)$dirs as $dir ) {
902
					$fileName = "$dir/$csCode.json";
903
					$data = $this->readJSONFile( $fileName );
904
905
					foreach ( $data as $key => $item ) {
906
						$this->mergeItem( $key, $csData[$key], $item );
907
					}
908
909
					$deps[] = new FileDependency( $fileName );
910
				}
911
			}
912
913
			# Merge non-JSON extension data
914 View Code Duplication
			if ( isset( $extensionData[$csCode] ) ) {
915
				foreach ( $extensionData[$csCode] as $key => $item ) {
916
					$this->mergeItem( $key, $csData[$key], $item );
917
				}
918
			}
919
920
			if ( $csCode === $code ) {
921
				# Merge core data into extension data
922
				foreach ( $coreData as $key => $item ) {
923
					$this->mergeItem( $key, $csData[$key], $item );
924
				}
925 View Code Duplication
			} else {
926
				# Load the secondary localisation from the source file to
927
				# avoid infinite cycles on cyclic fallbacks
928
				$fbData = $this->readSourceFilesAndRegisterDeps( $csCode, $deps );
929
				if ( $fbData !== false ) {
930
					# Only merge the keys that make sense to merge
931
					foreach ( self::$allKeys as $key ) {
932
						if ( !isset( $fbData[$key] ) ) {
933
							continue;
934
						}
935
936
						if ( is_null( $coreData[$key] ) || $this->isMergeableKey( $key ) ) {
937
							$this->mergeItem( $key, $csData[$key], $fbData[$key] );
938
						}
939
					}
940
				}
941
			}
942
943
			# Allow extensions an opportunity to adjust the data for this
944
			# fallback
945
			Hooks::run( 'LocalisationCacheRecacheFallback', [ $this, $csCode, &$csData ] );
946
947
			# Merge the data for this fallback into the final array
948 View Code Duplication
			if ( $csCode === $code ) {
949
				$allData = $csData;
950
			} else {
951
				foreach ( self::$allKeys as $key ) {
952
					if ( !isset( $csData[$key] ) ) {
953
						continue;
954
					}
955
956
					if ( is_null( $allData[$key] ) || $this->isMergeableKey( $key ) ) {
957
						$this->mergeItem( $key, $allData[$key], $csData[$key] );
958
					}
959
				}
960
			}
961
		}
962
963
		# Add cache dependencies for any referenced globals
964
		$deps['wgExtensionMessagesFiles'] = new GlobalDependency( 'wgExtensionMessagesFiles' );
965
		// The 'MessagesDirs' config setting is used in LocalisationCache::getMessagesDirs().
966
		// We use the key 'wgMessagesDirs' for historical reasons.
967
		$deps['wgMessagesDirs'] = new MainConfigDependency( 'MessagesDirs' );
968
		$deps['version'] = new ConstantDependency( 'LocalisationCache::VERSION' );
969
970
		# Add dependencies to the cache entry
971
		$allData['deps'] = $deps;
972
973
		# Replace spaces with underscores in namespace names
974
		$allData['namespaceNames'] = str_replace( ' ', '_', $allData['namespaceNames'] );
975
976
		# And do the same for special page aliases. $page is an array.
977
		foreach ( $allData['specialPageAliases'] as &$page ) {
978
			$page = str_replace( ' ', '_', $page );
979
		}
980
		# Decouple the reference to prevent accidental damage
981
		unset( $page );
982
983
		# If there were no plural rules, return an empty array
984
		if ( $allData['pluralRules'] === null ) {
985
			$allData['pluralRules'] = [];
986
		}
987
		if ( $allData['compiledPluralRules'] === null ) {
988
			$allData['compiledPluralRules'] = [];
989
		}
990
		# If there were no plural rule types, return an empty array
991
		if ( $allData['pluralRuleTypes'] === null ) {
992
			$allData['pluralRuleTypes'] = [];
993
		}
994
995
		# Set the list keys
996
		$allData['list'] = [];
997
		foreach ( self::$splitKeys as $key ) {
998
			$allData['list'][$key] = array_keys( $allData[$key] );
999
		}
1000
		# Run hooks
1001
		$purgeBlobs = true;
1002
		Hooks::run( 'LocalisationCacheRecache', [ $this, $code, &$allData, &$purgeBlobs ] );
1003
1004
		if ( is_null( $allData['namespaceNames'] ) ) {
1005
			throw new MWException( __METHOD__ . ': Localisation data failed sanity check! ' .
1006
				'Check that your languages/messages/MessagesEn.php file is intact.' );
1007
		}
1008
1009
		# Set the preload key
1010
		$allData['preload'] = $this->buildPreload( $allData );
1011
1012
		# Save to the process cache and register the items loaded
1013
		$this->data[$code] = $allData;
1014
		foreach ( $allData as $key => $item ) {
1015
			$this->loadedItems[$code][$key] = true;
1016
		}
1017
1018
		# Save to the persistent cache
1019
		$this->store->startWrite( $code );
1020
		foreach ( $allData as $key => $value ) {
1021
			if ( in_array( $key, self::$splitKeys ) ) {
1022
				foreach ( $value as $subkey => $subvalue ) {
1023
					$this->store->set( "$key:$subkey", $subvalue );
1024
				}
1025
			} else {
1026
				$this->store->set( $key, $value );
1027
			}
1028
		}
1029
		$this->store->finishWrite();
1030
1031
		# Clear out the MessageBlobStore
1032
		# HACK: If using a null (i.e. disabled) storage backend, we
1033
		# can't write to the MessageBlobStore either
1034
		if ( $purgeBlobs && !$this->store instanceof LCStoreNull ) {
1035
			$blobStore = new MessageBlobStore();
1036
			$blobStore->clear();
1037
		}
1038
1039
	}
1040
1041
	/**
1042
	 * Build the preload item from the given pre-cache data.
1043
	 *
1044
	 * The preload item will be loaded automatically, improving performance
1045
	 * for the commonly-requested items it contains.
1046
	 * @param array $data
1047
	 * @return array
1048
	 */
1049
	protected function buildPreload( $data ) {
1050
		$preload = [ 'messages' => [] ];
1051
		foreach ( self::$preloadedKeys as $key ) {
1052
			$preload[$key] = $data[$key];
1053
		}
1054
1055
		foreach ( $data['preloadedMessages'] as $subkey ) {
1056
			if ( isset( $data['messages'][$subkey] ) ) {
1057
				$subitem = $data['messages'][$subkey];
1058
			} else {
1059
				$subitem = null;
1060
			}
1061
			$preload['messages'][$subkey] = $subitem;
1062
		}
1063
1064
		return $preload;
1065
	}
1066
1067
	/**
1068
	 * Unload the data for a given language from the object cache.
1069
	 * Reduces memory usage.
1070
	 * @param string $code
1071
	 */
1072
	public function unload( $code ) {
1073
		unset( $this->data[$code] );
1074
		unset( $this->loadedItems[$code] );
1075
		unset( $this->loadedSubitems[$code] );
1076
		unset( $this->initialisedLangs[$code] );
1077
		unset( $this->shallowFallbacks[$code] );
1078
1079
		foreach ( $this->shallowFallbacks as $shallowCode => $fbCode ) {
1080
			if ( $fbCode === $code ) {
1081
				$this->unload( $shallowCode );
1082
			}
1083
		}
1084
	}
1085
1086
	/**
1087
	 * Unload all data
1088
	 */
1089
	public function unloadAll() {
1090
		foreach ( $this->initialisedLangs as $lang => $unused ) {
1091
			$this->unload( $lang );
1092
		}
1093
	}
1094
1095
	/**
1096
	 * Disable the storage backend
1097
	 */
1098
	public function disableBackend() {
1099
		$this->store = new LCStoreNull;
1100
		$this->manualRecache = false;
1101
	}
1102
1103
}
1104