MediaWiki master
LocalisationCache.php
Go to the documentation of this file.
1<?php
7namespace MediaWiki\Language;
8
10use CLDRPluralRuleParser\Error as CLDRPluralRuleError;
11use CLDRPluralRuleParser\Evaluator;
13use DOMDocument;
15use InvalidArgumentException;
24use Psr\Log\LoggerInterface;
25use RuntimeException;
26use UnexpectedValueException;
27
45 public const VERSION = 5;
46
48 private $options;
49
57 private $manualRecache;
58
67 protected $data = [];
68
74 protected $sourceLanguage = [];
75
77 private $store;
79 private $logger;
81 private $hookRunner;
83 private $clearStoreCallbacks;
85 private $langNameUtils;
86
96 private $loadedItems = [];
97
104 private $loadedSubitems = [];
105
113 private $initialisedLangs = [];
114
122 private $shallowFallbacks = [];
123
128 private $fallbackCodes = [];
129
135 private $recachedLangs = [];
136
147 private $coreDataLoaded = [];
148
152 public const ALL_KEYS = [
153 'fallback', 'namespaceNames', 'bookstoreList',
154 'magicWords', 'messages', 'rtl',
155 'digitTransformTable', 'separatorTransformTable',
156 'minimumGroupingDigits', 'numberingSystem', 'fallback8bitEncoding',
157 'linkPrefixExtension', 'linkTrail', 'linkPrefixCharset',
158 'namespaceAliases', 'dateFormats', 'jsDateFormats', 'datePreferences',
159 'datePreferenceMigrationMap', 'defaultDateFormat',
160 'specialPageAliases', 'imageFiles', 'preloadedMessages',
161 'namespaceGenderAliases', 'digitGroupingPattern', 'pluralRules',
162 'pluralRuleTypes', 'compiledPluralRules', 'formalityIndex'
163 ];
164
172 private const CORE_ONLY_KEYS = [
173 'fallback', 'rtl', 'digitTransformTable', 'separatorTransformTable',
174 'minimumGroupingDigits', 'numberingSystem',
175 'fallback8bitEncoding', 'linkPrefixExtension',
176 'linkTrail', 'linkPrefixCharset', 'datePreferences',
177 'datePreferenceMigrationMap', 'defaultDateFormat', 'digitGroupingPattern',
178 'formalityIndex',
179 ];
180
189 private const ALL_EXCEPT_CORE_ONLY_KEYS = [
190 'namespaceNames', 'bookstoreList', 'magicWords', 'messages',
191 'namespaceAliases', 'dateFormats', 'jsDateFormats', 'specialPageAliases',
192 'imageFiles', 'preloadedMessages', 'namespaceGenderAliases',
193 'pluralRules', 'pluralRuleTypes', 'compiledPluralRules',
194 ];
195
197 public const ALL_ALIAS_KEYS = [ 'specialPageAliases' ];
198
203 private const MERGEABLE_MAP_KEYS = [ 'messages', 'namespaceNames',
204 'namespaceAliases', 'dateFormats', 'jsDateFormats', 'imageFiles', 'preloadedMessages'
205 ];
206
211 private const MERGEABLE_ALIAS_LIST_KEYS = [ 'specialPageAliases' ];
212
218 private const OPTIONAL_MERGE_KEYS = [ 'bookstoreList' ];
219
223 private const MAGIC_WORD_KEYS = [ 'magicWords' ];
224
228 private const SPLIT_KEYS = [ 'messages' ];
229
234 private const SOURCE_PREFIX_KEYS = [ 'messages' ];
235
239 private const SOURCEPREFIX_SEPARATOR = ':';
240
244 private const PRELOADED_KEYS = [ 'dateFormats', 'namespaceNames' ];
245
249 private const META_KEYS = [ 'deps', 'list', 'preload' ];
250
251 private const PLURAL_FILES = [
252 // Load CLDR plural rules
253 MW_INSTALL_PATH . '/languages/data/plurals.xml',
254 // Override or extend with MW-specific rules
255 MW_INSTALL_PATH . '/languages/data/plurals-mediawiki.xml',
256 ];
257
264 private static $pluralRules = null;
265
280 private static $pluralRuleTypes = null;
281
290 public static function getStoreFromConf( array $conf, $fallbackCacheDir ): LCStore {
291 $storeArg = [
292 'directory' => $conf['storeDirectory'] ?: $fallbackCacheDir,
293 ];
294
295 // Custom classes are supported. For core builtins, short names are stable (since 1.23).
296 $storeClass = match ( $conf['storeClass'] ) {
297 'LCStoreCDB' => LCStoreCDB::class,
298 'LCStoreDB' => LCStoreDB::class,
299 'LCStoreNull' => LCStoreNull::class,
300 'LCStoreStaticArray' => LCStoreStaticArray::class,
301 default => $conf['storeClass'],
302 };
303 if ( !$storeClass ) {
304 if (
305 $conf['store'] === 'files'
306 || $conf['store'] === 'file'
307 || ( $conf['store'] === 'detect' && $storeArg['directory'] )
308 ) {
309 $storeClass = LCStoreCDB::class;
310 } elseif ( $conf['store'] === 'db' || $conf['store'] === 'detect' ) {
311 $storeClass = LCStoreDB::class;
312 $storeArg['server'] = $conf['storeServer'] ?? [];
313 } elseif ( $conf['store'] === 'array' ) {
314 $storeClass = LCStoreStaticArray::class;
315 } else {
316 throw new ConfigException(
317 'Please set $wgLocalisationCacheConf[\'store\'] to something sensible.'
318 );
319 }
320 }
321
322 return new $storeClass( $storeArg );
323 }
324
328 public const CONSTRUCTOR_OPTIONS = [
329 // True to treat all files as expired until they are regenerated by this object.
330 'forceRecache',
331 'manualRecache',
335 ];
336
350 public function __construct(
351 ServiceOptions $options,
352 LCStore $store,
353 LoggerInterface $logger,
354 array $clearStoreCallbacks,
355 LanguageNameUtils $langNameUtils,
356 HookContainer $hookContainer
357 ) {
358 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
359
360 $this->options = $options;
361 $this->store = $store;
362 $this->logger = $logger;
363 $this->clearStoreCallbacks = $clearStoreCallbacks;
364 $this->langNameUtils = $langNameUtils;
365 $this->hookRunner = new HookRunner( $hookContainer );
366
367 // Keep this separate from $this->options so that it can be mutable
368 $this->manualRecache = $options->get( 'manualRecache' );
369 }
370
377 private static function isMergeableKey( string $key ): bool {
378 static $mergeableKeys;
379 $mergeableKeys ??= array_fill_keys( [
380 ...self::MERGEABLE_MAP_KEYS,
381 ...self::MERGEABLE_ALIAS_LIST_KEYS,
382 ...self::OPTIONAL_MERGE_KEYS,
383 ...self::MAGIC_WORD_KEYS,
384 ], true );
385 return isset( $mergeableKeys[$key] );
386 }
387
397 public function getItem( $code, $key ) {
398 if ( !isset( $this->loadedItems[$code][$key] ) ) {
399 $this->loadItem( $code, $key );
400 }
401
402 if ( $key === 'fallback' && isset( $this->shallowFallbacks[$code] ) ) {
403 return $this->shallowFallbacks[$code];
404 }
405
406 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
407 return $this->data[$code][$key];
408 }
409
417 public function getSubitem( $code, $key, $subkey ) {
418 if ( !isset( $this->loadedSubitems[$code][$key][$subkey] ) &&
419 !isset( $this->loadedItems[$code][$key] )
420 ) {
421 $this->loadSubitem( $code, $key, $subkey );
422 }
423
424 return $this->data[$code][$key][$subkey] ?? null;
425 }
426
436 public function getSubitemWithSource( $code, $key, $subkey ) {
437 $subitem = $this->getSubitem( $code, $key, $subkey );
438 // Undefined in the backend.
439 if ( $subitem === null ) {
440 return null;
441 }
442
443 // The source language should have been set, but to avoid a Phan error and to be double sure.
444 return [ $subitem, $this->sourceLanguage[$code][$key][$subkey] ?? $code ];
445 }
446
460 public function getSubitemList( $code, $key ) {
461 if ( in_array( $key, self::SPLIT_KEYS ) ) {
462 return $this->getSubitem( $code, 'list', $key );
463 } else {
464 $item = $this->getItem( $code, $key );
465 if ( is_array( $item ) ) {
466 return array_keys( $item );
467 } else {
468 return false;
469 }
470 }
471 }
472
479 private function loadItem( $code, $key ) {
480 if ( isset( $this->loadedItems[$code][$key] ) ) {
481 return;
482 }
483
484 if (
485 in_array( $key, self::CORE_ONLY_KEYS, true ) ||
486 // "synthetic" keys added by loadCoreData based on "fallback"
487 $key === 'fallbackSequence' ||
488 $key === 'originalFallbackSequence'
489 ) {
490 if ( $this->langNameUtils->isValidBuiltInCode( $code ) ) {
491 $this->loadCoreData( $code );
492 return;
493 }
494 }
495
496 if ( !isset( $this->initialisedLangs[$code] ) ) {
497 $this->initLanguage( $code );
498
499 // Check to see if initLanguage() loaded it for us
500 if ( isset( $this->loadedItems[$code][$key] ) ) {
501 return;
502 }
503 }
504
505 if ( isset( $this->shallowFallbacks[$code] ) ) {
506 $this->loadItem( $this->shallowFallbacks[$code], $key );
507
508 return;
509 }
510
511 if ( in_array( $key, self::SPLIT_KEYS ) ) {
512 $subkeyList = $this->getSubitem( $code, 'list', $key );
513 foreach ( $subkeyList as $subkey ) {
514 if ( isset( $this->data[$code][$key][$subkey] ) ) {
515 continue;
516 }
517 $this->loadSubitem( $code, $key, $subkey );
518 }
519 } else {
520 $this->data[$code][$key] = $this->getFromStore( $code, $key );
521 }
522
523 $this->loadedItems[$code][$key] = true;
524 }
525
532 private function getFromStore( string $code, string $key ) {
533 if ( $this->store->lateFallback() ) {
534 $result = null;
535 foreach ( $this->getFallbackCodes( $code ) as $langCode ) {
536 $value = $this->store->get( $langCode, $key );
537 $this->mergeItem( $key, $result, $value );
538 if ( in_array( $key, self::META_KEYS ) ) {
539 break;
540 }
541 if ( is_string( $result ) ) {
542 // No need to do merges or look further
543 break;
544 }
545 }
546 return $result;
547 }
548 return $this->store->get( $code, $key );
549 }
550
557 protected function getFallbackCodes( string $code ): array {
558 if ( !array_key_exists( $code, $this->fallbackCodes ) ) {
559 $this->fallbackCodes[$code] = [
560 $code,
561 ...MediaWikiServices::getInstance()->getLanguageFallback()->getAll( $code )
562 ];
563 }
564 return $this->fallbackCodes[$code];
565 }
566
574 private function loadSubitem( $code, $key, $subkey ) {
575 if ( !in_array( $key, self::SPLIT_KEYS ) ) {
576 $this->loadItem( $code, $key );
577
578 return;
579 }
580
581 if ( !isset( $this->initialisedLangs[$code] ) ) {
582 $this->initLanguage( $code );
583 }
584
585 // Check to see if initLanguage() loaded it for us
586 if ( isset( $this->loadedItems[$code][$key] ) ||
587 isset( $this->loadedSubitems[$code][$key][$subkey] )
588 ) {
589 return;
590 }
591
592 if ( isset( $this->shallowFallbacks[$code] ) ) {
593 $this->loadSubitem( $this->shallowFallbacks[$code], $key, $subkey );
594
595 return;
596 }
597
598 $value = $this->getFromStore( $code, "$key:$subkey" );
599 if ( $value !== null && in_array( $key, self::SOURCE_PREFIX_KEYS ) ) {
600 [
601 $this->sourceLanguage[$code][$key][$subkey],
602 $this->data[$code][$key][$subkey]
603 ] = explode( self::SOURCEPREFIX_SEPARATOR, $value, 2 );
604 } else {
605 $this->data[$code][$key][$subkey] = $value;
606 }
607
608 $this->loadedSubitems[$code][$key][$subkey] = true;
609 }
610
618 public function isExpired( $code ) {
619 if ( $this->options->get( 'forceRecache' ) && !isset( $this->recachedLangs[$code] ) ) {
620 $this->logger->debug( __METHOD__ . "($code): forced reload" );
621
622 return true;
623 }
624
625 $deps = $this->getFromStore( $code, 'deps' );
626 $keys = $this->getFromStore( $code, 'list' );
627 $preload = $this->getFromStore( $code, 'preload' );
628 // Different keys may expire separately for some stores
629 if ( $deps === null || $keys === null || $preload === null ) {
630 $this->logger->debug( __METHOD__ . "($code): cache missing, need to make one" );
631
632 return true;
633 }
634
635 foreach ( $deps as $dep ) {
636 // Because we're unserializing stuff from cache, we
637 // could receive objects of classes that don't exist
638 // anymore (e.g., uninstalled extensions)
639 // When this happens, always expire the cache
640 if ( !$dep instanceof CacheDependency || $dep->isExpired() ) {
641 $this->logger->debug( __METHOD__ . "($code): cache for $code expired due to " .
642 get_class( $dep ) );
643
644 return true;
645 }
646 }
647
648 return false;
649 }
650
656 private function initLanguage( $langCode ) {
657 foreach ( array_reverse( $this->getFallbackCodes( $langCode ) ) as $code ) {
658 if ( isset( $this->initialisedLangs[$code] ) ) {
659 continue;
660 }
661
662 $this->initialisedLangs[$code] = true;
663
664 # If the code is of the wrong form for a Messages*.php file, do a shallow fallback
665 if ( !$this->langNameUtils->isValidBuiltInCode( $code ) ) {
666 $this->initShallowFallback( $code, 'en' );
667
668 continue;
669 }
670
671 # Re-cache the data if necessary
672 if ( !$this->manualRecache && $this->isExpired( $code ) ) {
673 if ( $this->langNameUtils->isSupportedLanguage( $code ) ) {
674 $this->recache( $code );
675 } elseif ( $code === 'en' ) {
676 throw new RuntimeException( 'MessagesEn.php is missing.' );
677 } else {
678 $this->initShallowFallback( $code, 'en' );
679 }
680
681 continue;
682 }
683
684 # Preload some stuff
685 $preload = $this->getItem( $code, 'preload' );
686 if ( $preload === null ) {
687 if ( $this->manualRecache ) {
688 // No Messages*.php file. Do shallow fallback to en.
689 if ( $code === 'en' ) {
690 throw new RuntimeException( 'No localisation cache found for English. ' .
691 'Please run maintenance/rebuildLocalisationCache.php.' );
692 }
693 $this->initShallowFallback( $code, 'en' );
694
695 break;
696 } else {
697 throw new RuntimeException( 'Invalid or missing localisation cache.' );
698 }
699 }
700
701 foreach ( self::SOURCE_PREFIX_KEYS as $key ) {
702 if ( !isset( $preload[$key] ) ) {
703 continue;
704 }
705 foreach ( $preload[$key] as $subkey => $value ) {
706 if ( $value !== null ) {
707 [
708 $this->sourceLanguage[$code][$key][$subkey],
709 $preload[$key][$subkey]
710 ] = explode( self::SOURCEPREFIX_SEPARATOR, $value, 2 );
711 } else {
712 $preload[$key][$subkey] = null;
713 }
714 }
715 }
716
717 if ( isset( $this->data[$code] ) ) {
718 foreach ( $preload as $key => $value ) {
719 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- see isset() above
720 $this->mergeItem( $key, $this->data[$code][$key], $value );
721 }
722 } else {
723 $this->data[$code] = $preload;
724 }
725 foreach ( $preload as $key => $item ) {
726 if ( in_array( $key, self::SPLIT_KEYS ) ) {
727 foreach ( $item as $subkey => $subitem ) {
728 $this->loadedSubitems[$code][$key][$subkey] = true;
729 }
730 } else {
731 $this->loadedItems[$code][$key] = true;
732 }
733 }
734 }
735 }
736
744 private function initShallowFallback( $primaryCode, $fallbackCode ) {
745 $this->data[$primaryCode] =& $this->data[$fallbackCode];
746 $this->loadedItems[$primaryCode] =& $this->loadedItems[$fallbackCode];
747 $this->loadedSubitems[$primaryCode] =& $this->loadedSubitems[$fallbackCode];
748 $this->shallowFallbacks[$primaryCode] = $fallbackCode;
749 $this->coreDataLoaded[$primaryCode] =& $this->coreDataLoaded[$fallbackCode];
750 }
751
759 protected function readPHPFile( $_fileName, $_fileType ) {
760 include $_fileName;
761
762 $data = [];
763 if ( $_fileType == 'core' ) {
764 foreach ( self::ALL_KEYS as $key ) {
765 // Not all keys are set in language files, so
766 // check they exist first
767 // @phan-suppress-next-line MediaWikiNoIssetIfDefined May be set in the included file
768 if ( isset( $$key ) ) {
769 $data[$key] = $$key;
770 }
771 }
772 } elseif ( $_fileType == 'extension' ) {
773 foreach ( self::ALL_EXCEPT_CORE_ONLY_KEYS as $key ) {
774 // @phan-suppress-next-line MediaWikiNoIssetIfDefined May be set in the included file
775 if ( isset( $$key ) ) {
776 $data[$key] = $$key;
777 }
778 }
779 } elseif ( $_fileType == 'aliases' ) {
780 // @phan-suppress-next-line PhanImpossibleCondition May be set in the included file
781 if ( isset( $aliases ) ) {
782 $data['aliases'] = $aliases;
783 }
784 } else {
785 throw new InvalidArgumentException( __METHOD__ . ": Invalid file type: $_fileType" );
786 }
787
788 return $data;
789 }
790
797 private function readJSONFile( $fileName ) {
798 if ( !is_readable( $fileName ) ) {
799 return [];
800 }
801
802 $json = file_get_contents( $fileName );
803 if ( $json === false ) {
804 return [];
805 }
806
807 $data = FormatJson::decode( $json, true );
808 if ( $data === null ) {
809 throw new RuntimeException( __METHOD__ . ": Invalid JSON file: $fileName" );
810 }
811
812 // Remove keys starting with '@'; they are reserved for metadata and non-message data
813 foreach ( $data as $key => $unused ) {
814 if ( $key === '' || $key[0] === '@' ) {
815 unset( $data[$key] );
816 }
817 }
818
819 return $data;
820 }
821
829 private function getCompiledPluralRules( $code ) {
830 $rules = $this->getPluralRules( $code );
831 if ( $rules === null ) {
832 return null;
833 }
834 try {
835 $compiledRules = Evaluator::compile( $rules );
836 } catch ( CLDRPluralRuleError $e ) {
837 $this->logger->debug( $e->getMessage() );
838
839 return [];
840 }
841
842 return $compiledRules;
843 }
844
854 private function getPluralRules( $code ) {
855 if ( self::$pluralRules === null ) {
856 self::loadPluralFiles();
857 }
858 return self::$pluralRules[$code] ?? null;
859 }
860
870 private function getPluralRuleTypes( $code ) {
871 if ( self::$pluralRuleTypes === null ) {
872 self::loadPluralFiles();
873 }
874 return self::$pluralRuleTypes[$code] ?? null;
875 }
876
880 private static function loadPluralFiles() {
881 foreach ( self::PLURAL_FILES as $fileName ) {
882 self::loadPluralFile( $fileName );
883 }
884 }
885
892 private static function loadPluralFile( $fileName ) {
893 // Use file_get_contents instead of DOMDocument::load (T58439)
894 $xml = file_get_contents( $fileName );
895 if ( !$xml ) {
896 throw new RuntimeException( "Unable to read plurals file $fileName" );
897 }
898 $doc = new DOMDocument;
899 $doc->loadXML( $xml );
900 $rulesets = $doc->getElementsByTagName( "pluralRules" );
901 foreach ( $rulesets as $ruleset ) {
902 $codes = $ruleset->getAttribute( 'locales' );
903 $rules = [];
904 $ruleTypes = [];
905 $ruleElements = $ruleset->getElementsByTagName( "pluralRule" );
906 foreach ( $ruleElements as $elt ) {
907 $ruleType = $elt->getAttribute( 'count' );
908 if ( $ruleType === 'other' ) {
909 // Don't record "other" rules, which have an empty condition
910 continue;
911 }
912 $rules[] = $elt->nodeValue;
913 $ruleTypes[] = $ruleType;
914 }
915 foreach ( explode( ' ', $codes ) as $code ) {
916 self::$pluralRules[$code] = $rules;
917 self::$pluralRuleTypes[$code] = $ruleTypes;
918 }
919 }
920 }
921
930 private function readSourceFilesAndRegisterDeps( $code, &$deps ) {
931 // This reads in the PHP i18n file with non-messages l10n data
932 $fileName = $this->langNameUtils->getMessagesFileName( $code );
933 if ( !is_file( $fileName ) ) {
934 $data = [];
935 } else {
936 $deps[] = new FileDependency( $fileName );
937 $data = $this->readPHPFile( $fileName, 'core' );
938 }
939
940 return $data;
941 }
942
951 private function readPluralFilesAndRegisterDeps( $code, &$deps ) {
952 $data = [
953 // Load CLDR plural rules for JavaScript
954 'pluralRules' => $this->getPluralRules( $code ),
955 // And for PHP
956 'compiledPluralRules' => $this->getCompiledPluralRules( $code ),
957 // Load plural rule types
958 'pluralRuleTypes' => $this->getPluralRuleTypes( $code ),
959 ];
960
961 foreach ( self::PLURAL_FILES as $fileName ) {
962 $deps[] = new FileDependency( $fileName );
963 }
964
965 return $data;
966 }
967
976 private function mergeItem( $key, &$value, $fallbackValue ) {
977 if ( $value !== null ) {
978 if ( $fallbackValue !== null ) {
979 if ( in_array( $key, self::MERGEABLE_MAP_KEYS ) ) {
980 $value += $fallbackValue;
981 } elseif ( in_array( $key, self::MERGEABLE_ALIAS_LIST_KEYS ) ) {
982 $value = array_merge_recursive( $value, $fallbackValue );
983 } elseif ( in_array( $key, self::OPTIONAL_MERGE_KEYS ) ) {
984 if ( !empty( $value['inherit'] ) ) {
985 $value = array_merge( $fallbackValue, $value );
986 }
987
988 unset( $value['inherit'] );
989 } elseif ( in_array( $key, self::MAGIC_WORD_KEYS ) ) {
990 $this->mergeMagicWords( $value, $fallbackValue );
991 }
992 }
993 } else {
994 $value = $fallbackValue;
995 }
996 }
997
998 private function mergeMagicWords( array &$value, array $fallbackValue ): void {
999 foreach ( $fallbackValue as $magicName => $fallbackInfo ) {
1000 if ( !isset( $value[$magicName] ) ) {
1001 $value[$magicName] = $fallbackInfo;
1002 } else {
1003 $value[$magicName] = [
1004 $fallbackInfo[0],
1005 ...array_unique( [
1006 // First value is 1 if the magic word is case-sensitive, 0 if not
1007 ...array_slice( $value[$magicName], 1 ),
1008 ...array_slice( $fallbackInfo, 1 ),
1009 ] )
1010 ];
1011 }
1012 }
1013 }
1014
1022 public function getMessagesDirs() {
1023 global $IP;
1024
1025 return [
1026 'core' => "$IP/languages/i18n",
1027 'botpasswords' => "$IP/languages/i18n/botpasswords",
1028 'codex' => "$IP/languages/i18n/codex",
1029 'datetime' => "$IP/languages/i18n/datetime",
1030 'exif' => "$IP/languages/i18n/exif",
1031 'languageconverter' => "$IP/languages/i18n/languageconverter",
1032 'interwiki' => "$IP/languages/i18n/interwiki",
1033 'preferences' => "$IP/languages/i18n/preferences",
1034 'userrights' => "$IP/languages/i18n/userrights",
1035
1036 'nontranslatable' => "$IP/languages/i18n/nontranslatable",
1037
1038 'api' => "$IP/includes/Api/i18n",
1039 'rest' => "$IP/includes/Rest/i18n",
1040 'oojs-ui' => "$IP/resources/lib/ooui/i18n",
1041 'paramvalidator' => "$IP/includes/libs/ParamValidator/i18n",
1042 'installer' => "$IP/includes/Installer/i18n",
1043 ] + $this->options->get( MainConfigNames::MessagesDirs );
1044 }
1045
1056 private function loadCoreData( string $code ) {
1057 if ( !$code ) {
1058 throw new InvalidArgumentException( "Invalid language code requested" );
1059 }
1060 if ( $this->coreDataLoaded[$code] ?? false ) {
1061 return;
1062 }
1063
1064 $coreData = array_fill_keys( self::CORE_ONLY_KEYS, null );
1065 $deps = [];
1066
1067 # Load the primary localisation from the source file
1068 $data = $this->readSourceFilesAndRegisterDeps( $code, $deps );
1069 $this->logger->debug( __METHOD__ . ": got localisation for $code from source" );
1070
1071 # Merge primary localisation
1072 foreach ( $data as $key => $value ) {
1073 $this->mergeItem( $key, $coreData[ $key ], $value );
1074 }
1075
1076 # Fill in the fallback if it's not there already
1077 // @phan-suppress-next-line PhanRedundantValueComparison
1078 if ( ( $coreData['fallback'] === null || $coreData['fallback'] === false ) && $code === 'en' ) {
1079 $coreData['fallback'] = false;
1080 $coreData['originalFallbackSequence'] = $coreData['fallbackSequence'] = [];
1081 } else {
1082 if ( $coreData['fallback'] !== null ) {
1083 $coreData['fallbackSequence'] = array_map( 'trim', explode( ',', $coreData['fallback'] ) );
1084 } else {
1085 $coreData['fallbackSequence'] = [];
1086 }
1087 $len = count( $coreData['fallbackSequence'] );
1088
1089 # Before we add the 'en' fallback for messages, keep a copy of
1090 # the original fallback sequence
1091 $coreData['originalFallbackSequence'] = $coreData['fallbackSequence'];
1092
1093 # Ensure that the sequence ends at 'en' for messages
1094 if ( !$len || $coreData['fallbackSequence'][$len - 1] !== 'en' ) {
1095 $coreData['fallbackSequence'][] = 'en';
1096 }
1097 }
1098
1099 foreach ( $coreData['fallbackSequence'] as $fbCode ) {
1100 // load core fallback data
1101 $fbData = $this->readSourceFilesAndRegisterDeps( $fbCode, $deps );
1102 foreach ( self::CORE_ONLY_KEYS as $key ) {
1103 // core-only keys are not mergeable, only set if not present in core data yet
1104 if ( isset( $fbData[$key] ) && !isset( $coreData[$key] ) ) {
1105 $coreData[$key] = $fbData[$key];
1106 }
1107 }
1108 }
1109
1110 $coreData['deps'] = $deps;
1111 foreach ( $coreData as $key => $item ) {
1112 $this->data[$code][$key] ??= null;
1113 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- we just set a default null
1114 $this->mergeItem( $key, $this->data[$code][$key], $item );
1115 if (
1116 in_array( $key, self::CORE_ONLY_KEYS, true ) ||
1117 // "synthetic" keys based on "fallback" (see above)
1118 $key === 'fallbackSequence' ||
1119 $key === 'originalFallbackSequence'
1120 ) {
1121 // only mark core-only keys as loaded;
1122 // we may have loaded additional ones from the source file,
1123 // but they are not fully loaded yet, since recache()
1124 // may have to merge in additional values from fallback languages
1125 $this->loadedItems[$code][$key] = true;
1126 }
1127 }
1128
1129 $this->coreDataLoaded[$code] = true;
1130 }
1131
1138 public function recache( $code ) {
1139 if ( !$code ) {
1140 throw new InvalidArgumentException( "Invalid language code requested" );
1141 }
1142 $this->recachedLangs[ $code ] = true;
1143
1144 # Initial values
1145 $initialData = array_fill_keys( self::ALL_KEYS, null );
1146 $this->data[$code] = [];
1147 $this->loadedItems[$code] = [];
1148 $this->loadedSubitems[$code] = [];
1149 $this->coreDataLoaded[$code] = false;
1150 $this->loadCoreData( $code );
1151 $coreData = $this->data[$code];
1152 $deps = $coreData['deps'];
1153 $coreData += $this->readPluralFilesAndRegisterDeps( $code, $deps );
1154
1155 if ( $this->store->lateFallback() ) {
1156 // This LCStore can handle multiple queries efficiently and
1157 // requests to merge fallback languages at read time.
1158 $codeSequence = [ $code ];
1159 } else {
1160 // Our LCStore prefers to cache pre-combined data with all
1161 // the fallback paths filled out to reduce query count.
1162 $codeSequence = [ $code, ...$coreData['fallbackSequence'] ];
1163 }
1164 $messageDirs = $this->getMessagesDirs();
1165 $translationAliasesDirs = $this->options->get( MainConfigNames::TranslationAliasesDirs );
1166
1167 # Load non-JSON localisation data for extensions
1168 $extensionData = array_fill_keys( $codeSequence, $initialData );
1169 foreach ( $this->options->get( MainConfigNames::ExtensionMessagesFiles ) as $extension => $fileName ) {
1170 if ( isset( $messageDirs[$extension] ) || isset( $translationAliasesDirs[$extension] ) ) {
1171 # This extension has JSON message data; skip the PHP shim
1172 continue;
1173 }
1174
1175 $data = $this->readPHPFile( $fileName, 'extension' );
1176 $used = false;
1177
1178 foreach ( $data as $key => $item ) {
1179 foreach ( $codeSequence as $csCode ) {
1180 if ( isset( $item[$csCode] ) ) {
1181 // Keep the behaviour the same as for json messages.
1182 // TODO: Consider deprecating using a PHP file for messages.
1183 if ( in_array( $key, self::SOURCE_PREFIX_KEYS ) ) {
1184 foreach ( $item[$csCode] as $subkey => $_ ) {
1185 $this->sourceLanguage[$code][$key][$subkey] ??= $csCode;
1186 }
1187 }
1188 $this->mergeItem( $key, $extensionData[$csCode][$key], $item[$csCode] );
1189 $used = true;
1190 }
1191 }
1192 }
1193
1194 if ( $used ) {
1195 $deps[] = new FileDependency( $fileName );
1196 }
1197 }
1198
1199 # Load the localisation data for each fallback, then merge it into the full array
1200 $allData = $initialData;
1201 foreach ( $codeSequence as $csCode ) {
1202 $csData = $initialData;
1203
1204 # Load core messages and the extension localisations.
1205 foreach ( $messageDirs as $dirs ) {
1206 foreach ( (array)$dirs as $dir ) {
1207 $fileName = "$dir/$csCode.json";
1208 $messages = $this->readJSONFile( $fileName );
1209
1210 foreach ( $messages as $subkey => $_ ) {
1211 $this->sourceLanguage[$code]['messages'][$subkey] ??= $csCode;
1212 }
1213 $this->mergeItem( 'messages', $csData['messages'], $messages );
1214
1215 $deps[] = new FileDependency( $fileName );
1216 }
1217 }
1218
1219 foreach ( $translationAliasesDirs as $dirs ) {
1220 foreach ( (array)$dirs as $dir ) {
1221 $fileName = "$dir/$csCode.json";
1222 $data = $this->readJSONFile( $fileName );
1223
1224 foreach ( $data as $key => $item ) {
1225 // We allow the key in the JSON to be specified in PascalCase similar to key definitions in
1226 // extension.json, but eventually they are stored in camelCase
1227 $normalizedKey = lcfirst( $key );
1228
1229 if ( $normalizedKey === '@metadata' ) {
1230 // Don't store @metadata information in extension data.
1231 continue;
1232 }
1233
1234 if ( !in_array( $normalizedKey, self::ALL_ALIAS_KEYS ) ) {
1235 throw new UnexpectedValueException(
1236 "Invalid key: \"$key\" for " . MainConfigNames::TranslationAliasesDirs . ". " .
1237 'Valid keys: ' . implode( ', ', self::ALL_ALIAS_KEYS )
1238 );
1239 }
1240
1241 $this->mergeItem( $normalizedKey, $extensionData[$csCode][$normalizedKey], $item );
1242 }
1243
1244 $deps[] = new FileDependency( $fileName );
1245 }
1246 }
1247
1248 # Merge non-JSON extension data
1249 if ( isset( $extensionData[$csCode] ) ) {
1250 foreach ( $extensionData[$csCode] as $key => $item ) {
1251 $this->mergeItem( $key, $csData[$key], $item );
1252 }
1253 }
1254
1255 if ( $csCode === $code ) {
1256 # Merge core data into extension data
1257 foreach ( $coreData as $key => $item ) {
1258 $this->mergeItem( $key, $csData[$key], $item );
1259 }
1260 } else {
1261 # Load the secondary localisation from the source file to
1262 # avoid infinite cycles on cyclic fallbacks
1263 $fbData = $this->readSourceFilesAndRegisterDeps( $csCode, $deps );
1264 $fbData += $this->readPluralFilesAndRegisterDeps( $csCode, $deps );
1265 # Only merge the keys that make sense to merge
1266 foreach ( self::ALL_KEYS as $key ) {
1267 if ( !isset( $fbData[ $key ] ) ) {
1268 continue;
1269 }
1270
1271 if ( !isset( $coreData[ $key ] ) || self::isMergeableKey( $key ) ) {
1272 $this->mergeItem( $key, $csData[ $key ], $fbData[ $key ] );
1273 }
1274 }
1275 }
1276
1277 # Allow extensions an opportunity to adjust the data for this fallback
1278 $this->hookRunner->onLocalisationCacheRecacheFallback( $this, $csCode, $csData );
1279
1280 # Merge the data for this fallback into the final array
1281 if ( $csCode === $code ) {
1282 $allData = $csData;
1283 } else {
1284 foreach ( self::ALL_KEYS as $key ) {
1285 if ( !isset( $csData[$key] ) ) {
1286 continue;
1287 }
1288
1289 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1290 if ( $allData[$key] === null || self::isMergeableKey( $key ) ) {
1291 $this->mergeItem( $key, $allData[$key], $csData[$key] );
1292 }
1293 }
1294 }
1295 }
1296
1297 if ( !isset( $allData['rtl'] ) ) {
1298 throw new RuntimeException( __METHOD__ . ': Localisation data failed validation check! ' .
1299 'Check that your languages/messages/MessagesEn.php file is intact.' );
1300 }
1301
1302 // Add cache dependencies for any referenced configs
1303 // We use the keys prefixed with 'wg' for historical reasons.
1304 $deps['wgExtensionMessagesFiles'] =
1305 new MainConfigDependency( MainConfigNames::ExtensionMessagesFiles );
1306 $deps['wgMessagesDirs'] =
1307 new MainConfigDependency( MainConfigNames::MessagesDirs );
1308 $deps['version'] = new ConstantDependency( self::class . '::VERSION' );
1309
1310 # Add dependencies to the cache entry
1311 $allData['deps'] = $deps;
1312
1313 # Replace spaces with underscores in namespace names
1314 if ( isset( $allData['namespaceNames'] ) ) {
1315 $allData['namespaceNames'] = str_replace( ' ', '_', $allData['namespaceNames'] );
1316 }
1317
1318 # And do the same for special page aliases. $page is an array.
1319 if ( isset( $allData['specialPageAliases'] ) ) {
1320 foreach ( $allData['specialPageAliases'] as &$page ) {
1321 $page = str_replace( ' ', '_', $page );
1322 }
1323 }
1324 # Decouple the reference to prevent accidental damage
1325 unset( $page );
1326
1327 # If there were no plural rules, return an empty array
1328 $allData['pluralRules'] ??= [];
1329 $allData['compiledPluralRules'] ??= [];
1330 # If there were no plural rule types, return an empty array
1331 $allData['pluralRuleTypes'] ??= [];
1332
1333 # Set the list keys
1334 $allData['list'] = [];
1335 foreach ( self::SPLIT_KEYS as $key ) {
1336 $allData['list'][$key] = array_keys( $allData[$key] );
1337 }
1338 # Run hooks
1339 $unused = true; // Used to be $purgeBlobs, removed in 1.34
1340 $this->hookRunner->onLocalisationCacheRecache( $this, $code, $allData, $unused );
1341
1342 if ( $this->store->lateFallback() ) {
1343 // Our in-process cache stores merged data, so let it be reloaded
1344 // from the new cache as backend declares it's cheap to do so.
1345 } else {
1346 // Save to the process cache and register the items loaded
1347 $this->data[$code] = $allData;
1348 $this->loadedItems[$code] = [];
1349 $this->loadedSubitems[$code] = [];
1350 foreach ( $allData as $key => $item ) {
1351 $this->loadedItems[$code][$key] = true;
1352 }
1353 }
1354
1355 # Prefix each item with its source language code before save
1356 foreach ( self::SOURCE_PREFIX_KEYS as $key ) {
1357 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1358 foreach ( $allData[$key] as $subKey => $value ) {
1359 // The source language should have been set, but to avoid Phan error and be double sure.
1360 $allData[$key][$subKey] = ( $this->sourceLanguage[$code][$key][$subKey] ?? $code ) .
1361 self::SOURCEPREFIX_SEPARATOR . $value;
1362 }
1363 }
1364
1365 # Set the preload key
1366 $allData['preload'] = $this->buildPreload( $allData );
1367
1368 # Save to the persistent cache
1369 $this->store->startWrite( $code );
1370 foreach ( $allData as $key => $value ) {
1371 if ( in_array( $key, self::SPLIT_KEYS ) ) {
1372 foreach ( $value as $subkey => $subvalue ) {
1373 $this->store->set( "$key:$subkey", $subvalue );
1374 }
1375 } else {
1376 $this->store->set( $key, $value );
1377 }
1378 }
1379 $this->store->finishWrite();
1380
1381 # Clear out the MessageBlobStore
1382 # HACK: If using a null (i.e., disabled) storage backend, we
1383 # can't write to the MessageBlobStore either
1384 if ( !$this->store instanceof LCStoreNull ) {
1385 foreach ( $this->clearStoreCallbacks as $callback ) {
1386 $callback();
1387 }
1388 }
1389 }
1390
1400 private function buildPreload( $data ) {
1401 $preload = [ 'messages' => [] ];
1402 foreach ( self::PRELOADED_KEYS as $key ) {
1403 if ( isset( $data[$key] ) ) {
1404 $preload[$key] = $data[$key];
1405 }
1406 }
1407
1408 foreach ( $data['preloadedMessages'] ?? [] as $subkey ) {
1409 if ( isset( $data['messages'][$subkey] ) ) {
1410 $preload['messages'][$subkey] = $data['messages'][$subkey];
1411 }
1412 }
1413
1414 return $preload;
1415 }
1416
1424 public function unload( $code ) {
1425 unset( $this->data[$code] );
1426 unset( $this->loadedItems[$code] );
1427 unset( $this->loadedSubitems[$code] );
1428 unset( $this->initialisedLangs[$code] );
1429 unset( $this->shallowFallbacks[$code] );
1430 unset( $this->sourceLanguage[$code] );
1431 unset( $this->coreDataLoaded[$code] );
1432
1433 foreach ( $this->shallowFallbacks as $shallowCode => $fbCode ) {
1434 if ( $fbCode === $code ) {
1435 $this->unload( $shallowCode );
1436 }
1437 }
1438 }
1439
1443 public function unloadAll() {
1444 foreach ( $this->initialisedLangs as $lang => $unused ) {
1445 $this->unload( $lang );
1446 }
1447 }
1448
1452 public function disableBackend() {
1453 $this->store = new LCStoreNull;
1454 $this->manualRecache = false;
1455 }
1456}
1457
1459class_alias( LocalisationCache::class, 'LocalisationCache' );
if(!defined('MEDIAWIKI')) if(!defined( 'MW_ENTRY_POINT')) global $IP
Environment checks.
Definition Setup.php:90
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:69
Base class to represent dependencies for LocalisationCache entries.
isExpired()
Returns true if the dependency is expired, false otherwise.
Depend on a PHP constant.
Depend on a file.
Depend on a MediaWiki configuration variable from the global config.
Exceptions for config failures.
A class for passing options to services.
assertRequiredOptions(array $expectedKeys)
Assert that the list of options provided in this instance exactly match $expectedKeys,...
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
JSON formatter wrapper class.
Null store backend, used to avoid DB errors during MediaWiki installation.
A service that provides utilities to do with language names and codes.
Caching for the contents of localisation files.
getFallbackCodes(string $code)
Get the set of language codes, including the current language code and any fallbacks to read from in ...
static getStoreFromConf(array $conf, $fallbackCacheDir)
Return a suitable LCStore as specified by the given configuration.
recache( $code)
Load localisation data for a given language for both core and extensions and save it to the persisten...
disableBackend()
Disable the storage backend.
getSubitemList( $code, $key)
Get the list of subitem keys for a given item.
getMessagesDirs()
Gets the combined list of messages dirs from core and extensions.
readPHPFile( $_fileName, $_fileType)
Read a PHP file containing localisation data.
array< string, array< string, array< string, string > > > $sourceLanguage
The source language of cached data items.
isExpired( $code)
Returns true if the cache identified by $code is missing or expired.
__construct(ServiceOptions $options, LCStore $store, LoggerInterface $logger, array $clearStoreCallbacks, LanguageNameUtils $langNameUtils, HookContainer $hookContainer)
For constructor parameters, \MediaWiki\MainConfigSchema::LocalisationCacheConf.
const ALL_ALIAS_KEYS
Keys for items which can be localized.
array< string, array > $data
The cache data.
unload( $code)
Unload the data for a given language from the object cache.
getSubitem( $code, $key, $subkey)
Get a subitem, for instance a single message for a given language.
getItem( $code, $key)
Get a cache item.
getSubitemWithSource( $code, $key, $subkey)
Get a subitem with its source language.
A class containing constants representing the names of configuration variables.
const MessagesDirs
Name constant for the MessagesDirs setting, for use with Config::get()
const ExtensionMessagesFiles
Name constant for the ExtensionMessagesFiles setting, for use with Config::get()
const TranslationAliasesDirs
Name constant for the TranslationAliasesDirs setting, for use with Config::get()
Service locator for MediaWiki core services.
Interface for the persistence layer of LocalisationCache.
Definition LCStore.php:28