MediaWiki 1.44.0
LocalisationCache.php
Go to the documentation of this file.
1<?php
21use CLDRPluralRuleParser\Error as CLDRPluralRuleError;
22use CLDRPluralRuleParser\Evaluator;
30use Psr\Log\LoggerInterface;
31
49 public const VERSION = 5;
50
52 private $options;
53
61 private $manualRecache;
62
71 protected $data = [];
72
78 protected $sourceLanguage = [];
79
81 private $store;
83 private $logger;
85 private $hookRunner;
87 private $clearStoreCallbacks;
89 private $langNameUtils;
90
100 private $loadedItems = [];
101
108 private $loadedSubitems = [];
109
117 private $initialisedLangs = [];
118
126 private $shallowFallbacks = [];
127
133 private $recachedLangs = [];
134
145 private $coreDataLoaded = [];
146
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
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
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
195 public const ALL_ALIAS_KEYS = [ 'specialPageAliases' ];
196
201 private const MERGEABLE_MAP_KEYS = [ 'messages', 'namespaceNames',
202 'namespaceAliases', 'dateFormats', 'jsDateFormats', 'imageFiles', 'preloadedMessages'
203 ];
204
209 private const MERGEABLE_ALIAS_LIST_KEYS = [ 'specialPageAliases' ];
210
216 private const OPTIONAL_MERGE_KEYS = [ 'bookstoreList' ];
217
221 private const MAGIC_WORD_KEYS = [ 'magicWords' ];
222
226 private const SPLIT_KEYS = [ 'messages' ];
227
232 private const SOURCE_PREFIX_KEYS = [ 'messages' ];
233
237 private const SOURCEPREFIX_SEPARATOR = ':';
238
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
257 private static $pluralRules = null;
258
273 private static $pluralRuleTypes = null;
274
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
794 private function getPluralRules( $code ) {
795 if ( self::$pluralRules === null ) {
796 self::loadPluralFiles();
797 }
798 return self::$pluralRules[$code] ?? null;
799 }
800
810 private function getPluralRuleTypes( $code ) {
811 if ( self::$pluralRuleTypes === null ) {
812 self::loadPluralFiles();
813 }
814 return self::$pluralRuleTypes[$code] ?? null;
815 }
816
820 private static function loadPluralFiles() {
821 foreach ( self::PLURAL_FILES as $fileName ) {
822 self::loadPluralFile( $fileName );
823 }
824 }
825
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
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
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
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
962 public function getMessagesDirs() {
963 global $IP;
964
965 return [
966 'core' => "$IP/languages/i18n",
967 'codex' => "$IP/languages/i18n/codex",
968 'datetime' => "$IP/languages/i18n/datetime",
969 'exif' => "$IP/languages/i18n/exif",
970 'preferences' => "$IP/languages/i18n/preferences",
971 'api' => "$IP/includes/api/i18n",
972 'rest' => "$IP/includes/Rest/i18n",
973 'oojs-ui' => "$IP/resources/lib/ooui/i18n",
974 'paramvalidator' => "$IP/includes/libs/ParamValidator/i18n",
975 'installer' => "$IP/includes/installer/i18n",
976 ] + $this->options->get( MainConfigNames::MessagesDirs );
977 }
978
989 private function loadCoreData( string $code ) {
990 if ( !$code ) {
991 throw new InvalidArgumentException( "Invalid language code requested" );
992 }
993 if ( $this->coreDataLoaded[$code] ?? false ) {
994 return;
995 }
996
997 $coreData = array_fill_keys( self::CORE_ONLY_KEYS, null );
998 $deps = [];
999
1000 # Load the primary localisation from the source file
1001 $data = $this->readSourceFilesAndRegisterDeps( $code, $deps );
1002 $this->logger->debug( __METHOD__ . ": got localisation for $code from source" );
1003
1004 # Merge primary localisation
1005 foreach ( $data as $key => $value ) {
1006 $this->mergeItem( $key, $coreData[ $key ], $value );
1007 }
1008
1009 # Fill in the fallback if it's not there already
1010 // @phan-suppress-next-line PhanSuspiciousValueComparison
1011 if ( ( $coreData['fallback'] === null || $coreData['fallback'] === false ) && $code === 'en' ) {
1012 $coreData['fallback'] = false;
1013 $coreData['originalFallbackSequence'] = $coreData['fallbackSequence'] = [];
1014 } else {
1015 if ( $coreData['fallback'] !== null ) {
1016 $coreData['fallbackSequence'] = array_map( 'trim', explode( ',', $coreData['fallback'] ) );
1017 } else {
1018 $coreData['fallbackSequence'] = [];
1019 }
1020 $len = count( $coreData['fallbackSequence'] );
1021
1022 # Before we add the 'en' fallback for messages, keep a copy of
1023 # the original fallback sequence
1024 $coreData['originalFallbackSequence'] = $coreData['fallbackSequence'];
1025
1026 # Ensure that the sequence ends at 'en' for messages
1027 if ( !$len || $coreData['fallbackSequence'][$len - 1] !== 'en' ) {
1028 $coreData['fallbackSequence'][] = 'en';
1029 }
1030 }
1031
1032 foreach ( $coreData['fallbackSequence'] as $fbCode ) {
1033 // load core fallback data
1034 $fbData = $this->readSourceFilesAndRegisterDeps( $fbCode, $deps );
1035 foreach ( self::CORE_ONLY_KEYS as $key ) {
1036 // core-only keys are not mergeable, only set if not present in core data yet
1037 if ( isset( $fbData[$key] ) && !isset( $coreData[$key] ) ) {
1038 $coreData[$key] = $fbData[$key];
1039 }
1040 }
1041 }
1042
1043 $coreData['deps'] = $deps;
1044 foreach ( $coreData as $key => $item ) {
1045 $this->data[$code][$key] ??= null;
1046 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- we just set a default null
1047 $this->mergeItem( $key, $this->data[$code][$key], $item );
1048 if (
1049 in_array( $key, self::CORE_ONLY_KEYS, true ) ||
1050 // "synthetic" keys based on "fallback" (see above)
1051 $key === 'fallbackSequence' ||
1052 $key === 'originalFallbackSequence'
1053 ) {
1054 // only mark core-only keys as loaded;
1055 // we may have loaded additional ones from the source file,
1056 // but they are not fully loaded yet, since recache()
1057 // may have to merge in additional values from fallback languages
1058 $this->loadedItems[$code][$key] = true;
1059 }
1060 }
1061
1062 $this->coreDataLoaded[$code] = true;
1063 }
1064
1071 public function recache( $code ) {
1072 if ( !$code ) {
1073 throw new InvalidArgumentException( "Invalid language code requested" );
1074 }
1075 $this->recachedLangs[ $code ] = true;
1076
1077 # Initial values
1078 $initialData = array_fill_keys( self::ALL_KEYS, null );
1079 $this->data[$code] = [];
1080 $this->loadedItems[$code] = [];
1081 $this->loadedSubitems[$code] = [];
1082 $this->coreDataLoaded[$code] = false;
1083 $this->loadCoreData( $code );
1084 $coreData = $this->data[$code];
1085 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- guaranteed by loadCoreData()
1086 $deps = $coreData['deps'];
1087 $coreData += $this->readPluralFilesAndRegisterDeps( $code, $deps );
1088
1089 $codeSequence = array_merge( [ $code ], $coreData['fallbackSequence'] );
1090 $messageDirs = $this->getMessagesDirs();
1091 $translationAliasesDirs = $this->options->get( MainConfigNames::TranslationAliasesDirs );
1092
1093 # Load non-JSON localisation data for extensions
1094 $extensionData = array_fill_keys( $codeSequence, $initialData );
1095 foreach ( $this->options->get( MainConfigNames::ExtensionMessagesFiles ) as $extension => $fileName ) {
1096 if ( isset( $messageDirs[$extension] ) || isset( $translationAliasesDirs[$extension] ) ) {
1097 # This extension has JSON message data; skip the PHP shim
1098 continue;
1099 }
1100
1101 $data = $this->readPHPFile( $fileName, 'extension' );
1102 $used = false;
1103
1104 foreach ( $data as $key => $item ) {
1105 foreach ( $codeSequence as $csCode ) {
1106 if ( isset( $item[$csCode] ) ) {
1107 // Keep the behaviour the same as for json messages.
1108 // TODO: Consider deprecating using a PHP file for messages.
1109 if ( in_array( $key, self::SOURCE_PREFIX_KEYS ) ) {
1110 foreach ( $item[$csCode] as $subkey => $_ ) {
1111 $this->sourceLanguage[$code][$key][$subkey] ??= $csCode;
1112 }
1113 }
1114 $this->mergeItem( $key, $extensionData[$csCode][$key], $item[$csCode] );
1115 $used = true;
1116 }
1117 }
1118 }
1119
1120 if ( $used ) {
1121 $deps[] = new FileDependency( $fileName );
1122 }
1123 }
1124
1125 # Load the localisation data for each fallback, then merge it into the full array
1126 $allData = $initialData;
1127 foreach ( $codeSequence as $csCode ) {
1128 $csData = $initialData;
1129
1130 # Load core messages and the extension localisations.
1131 foreach ( $messageDirs as $dirs ) {
1132 foreach ( (array)$dirs as $dir ) {
1133 $fileName = "$dir/$csCode.json";
1134 $messages = $this->readJSONFile( $fileName );
1135
1136 foreach ( $messages as $subkey => $_ ) {
1137 $this->sourceLanguage[$code]['messages'][$subkey] ??= $csCode;
1138 }
1139 $this->mergeItem( 'messages', $csData['messages'], $messages );
1140
1141 $deps[] = new FileDependency( $fileName );
1142 }
1143 }
1144
1145 foreach ( $translationAliasesDirs as $dirs ) {
1146 foreach ( (array)$dirs as $dir ) {
1147 $fileName = "$dir/$csCode.json";
1148 $data = $this->readJSONFile( $fileName );
1149
1150 foreach ( $data as $key => $item ) {
1151 // We allow the key in the JSON to be specified in PascalCase similar to key definitions in
1152 // extension.json, but eventually they are stored in camelCase
1153 $normalizedKey = lcfirst( $key );
1154
1155 if ( $normalizedKey === '@metadata' ) {
1156 // Don't store @metadata information in extension data.
1157 continue;
1158 }
1159
1160 if ( !in_array( $normalizedKey, self::ALL_ALIAS_KEYS ) ) {
1161 throw new UnexpectedValueException(
1162 "Invalid key: \"$key\" for " . MainConfigNames::TranslationAliasesDirs . ". " .
1163 'Valid keys: ' . implode( ', ', self::ALL_ALIAS_KEYS )
1164 );
1165 }
1166
1167 $this->mergeItem( $normalizedKey, $extensionData[$csCode][$normalizedKey], $item );
1168 }
1169
1170 $deps[] = new FileDependency( $fileName );
1171 }
1172 }
1173
1174 # Merge non-JSON extension data
1175 if ( isset( $extensionData[$csCode] ) ) {
1176 foreach ( $extensionData[$csCode] as $key => $item ) {
1177 $this->mergeItem( $key, $csData[$key], $item );
1178 }
1179 }
1180
1181 if ( $csCode === $code ) {
1182 # Merge core data into extension data
1183 foreach ( $coreData as $key => $item ) {
1184 $this->mergeItem( $key, $csData[$key], $item );
1185 }
1186 } else {
1187 # Load the secondary localisation from the source file to
1188 # avoid infinite cycles on cyclic fallbacks
1189 $fbData = $this->readSourceFilesAndRegisterDeps( $csCode, $deps );
1190 $fbData += $this->readPluralFilesAndRegisterDeps( $csCode, $deps );
1191 # Only merge the keys that make sense to merge
1192 foreach ( self::ALL_KEYS as $key ) {
1193 if ( !isset( $fbData[ $key ] ) ) {
1194 continue;
1195 }
1196
1197 if ( !isset( $coreData[ $key ] ) || self::isMergeableKey( $key ) ) {
1198 $this->mergeItem( $key, $csData[ $key ], $fbData[ $key ] );
1199 }
1200 }
1201 }
1202
1203 # Allow extensions an opportunity to adjust the data for this fallback
1204 $this->hookRunner->onLocalisationCacheRecacheFallback( $this, $csCode, $csData );
1205
1206 # Merge the data for this fallback into the final array
1207 if ( $csCode === $code ) {
1208 $allData = $csData;
1209 } else {
1210 foreach ( self::ALL_KEYS as $key ) {
1211 if ( !isset( $csData[$key] ) ) {
1212 continue;
1213 }
1214
1215 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1216 if ( $allData[$key] === null || self::isMergeableKey( $key ) ) {
1217 $this->mergeItem( $key, $allData[$key], $csData[$key] );
1218 }
1219 }
1220 }
1221 }
1222
1223 if ( !isset( $allData['rtl'] ) ) {
1224 throw new RuntimeException( __METHOD__ . ': Localisation data failed validation check! ' .
1225 'Check that your languages/messages/MessagesEn.php file is intact.' );
1226 }
1227
1228 // Add cache dependencies for any referenced configs
1229 // We use the keys prefixed with 'wg' for historical reasons.
1230 $deps['wgExtensionMessagesFiles'] =
1231 new MainConfigDependency( MainConfigNames::ExtensionMessagesFiles );
1232 $deps['wgMessagesDirs'] =
1233 new MainConfigDependency( MainConfigNames::MessagesDirs );
1234 $deps['version'] = new ConstantDependency( self::class . '::VERSION' );
1235
1236 # Add dependencies to the cache entry
1237 $allData['deps'] = $deps;
1238
1239 # Replace spaces with underscores in namespace names
1240 $allData['namespaceNames'] = str_replace( ' ', '_', $allData['namespaceNames'] );
1241
1242 # And do the same for special page aliases. $page is an array.
1243 foreach ( $allData['specialPageAliases'] as &$page ) {
1244 $page = str_replace( ' ', '_', $page );
1245 }
1246 # Decouple the reference to prevent accidental damage
1247 unset( $page );
1248
1249 # If there were no plural rules, return an empty array
1250 $allData['pluralRules'] ??= [];
1251 $allData['compiledPluralRules'] ??= [];
1252 # If there were no plural rule types, return an empty array
1253 $allData['pluralRuleTypes'] ??= [];
1254
1255 # Set the list keys
1256 $allData['list'] = [];
1257 foreach ( self::SPLIT_KEYS as $key ) {
1258 $allData['list'][$key] = array_keys( $allData[$key] );
1259 }
1260 # Run hooks
1261 $unused = true; // Used to be $purgeBlobs, removed in 1.34
1262 $this->hookRunner->onLocalisationCacheRecache( $this, $code, $allData, $unused );
1263
1264 # Save to the process cache and register the items loaded
1265 $this->data[$code] = $allData;
1266 $this->loadedItems[$code] = [];
1267 $this->loadedSubitems[$code] = [];
1268 foreach ( $allData as $key => $item ) {
1269 $this->loadedItems[$code][$key] = true;
1270 }
1271
1272 # Prefix each item with its source language code before save
1273 foreach ( self::SOURCE_PREFIX_KEYS as $key ) {
1274 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1275 foreach ( $allData[$key] as $subKey => $value ) {
1276 // The source language should have been set, but to avoid Phan error and be double sure.
1277 $allData[$key][$subKey] = ( $this->sourceLanguage[$code][$key][$subKey] ?? $code ) .
1278 self::SOURCEPREFIX_SEPARATOR . $value;
1279 }
1280 }
1281
1282 # Set the preload key
1283 $allData['preload'] = $this->buildPreload( $allData );
1284
1285 # Save to the persistent cache
1286 $this->store->startWrite( $code );
1287 foreach ( $allData as $key => $value ) {
1288 if ( in_array( $key, self::SPLIT_KEYS ) ) {
1289 foreach ( $value as $subkey => $subvalue ) {
1290 $this->store->set( "$key:$subkey", $subvalue );
1291 }
1292 } else {
1293 $this->store->set( $key, $value );
1294 }
1295 }
1296 $this->store->finishWrite();
1297
1298 # Clear out the MessageBlobStore
1299 # HACK: If using a null (i.e., disabled) storage backend, we
1300 # can't write to the MessageBlobStore either
1301 if ( !$this->store instanceof LCStoreNull ) {
1302 foreach ( $this->clearStoreCallbacks as $callback ) {
1303 $callback();
1304 }
1305 }
1306 }
1307
1317 private function buildPreload( $data ) {
1318 $preload = [ 'messages' => [] ];
1319 foreach ( self::PRELOADED_KEYS as $key ) {
1320 $preload[$key] = $data[$key];
1321 }
1322
1323 foreach ( $data['preloadedMessages'] as $subkey ) {
1324 $subitem = $data['messages'][$subkey] ?? null;
1325 $preload['messages'][$subkey] = $subitem;
1326 }
1327
1328 return $preload;
1329 }
1330
1338 public function unload( $code ) {
1339 unset( $this->data[$code] );
1340 unset( $this->loadedItems[$code] );
1341 unset( $this->loadedSubitems[$code] );
1342 unset( $this->initialisedLangs[$code] );
1343 unset( $this->shallowFallbacks[$code] );
1344 unset( $this->sourceLanguage[$code] );
1345 unset( $this->coreDataLoaded[$code] );
1346
1347 foreach ( $this->shallowFallbacks as $shallowCode => $fbCode ) {
1348 if ( $fbCode === $code ) {
1349 $this->unload( $shallowCode );
1350 }
1351 }
1352 }
1353
1357 public function unloadAll() {
1358 foreach ( $this->initialisedLangs as $lang => $unused ) {
1359 $this->unload( $lang );
1360 }
1361 }
1362
1366 public function disableBackend() {
1367 $this->store = new LCStoreNull;
1368 $this->manualRecache = false;
1369 }
1370}
if(!defined( 'MEDIAWIKI')) if(ini_get('mbstring.func_overload')) if(!defined( 'MW_ENTRY_POINT')) global $IP
Environment checks.
Definition Setup.php:109
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.
Null store backend, used to avoid DB errors during MediaWiki installation.
Caching for the contents of localisation files.
getSubitemWithSource( $code, $key, $subkey)
Get a subitem with its source language.
array< string, array< string, array< string, string > > > $sourceLanguage
The source language of cached data items.
readPHPFile( $_fileName, $_fileType)
Read a PHP file containing localisation data.
const ALL_ALIAS_KEYS
Keys for items which can be localized.
unload( $code)
Unload the data for a given language from the object cache.
unloadAll()
Unload all data.
disableBackend()
Disable the storage backend.
getSubitemList( $code, $key)
Get the list of subitem keys for a given item.
__construct(ServiceOptions $options, LCStore $store, LoggerInterface $logger, array $clearStoreCallbacks, LanguageNameUtils $langNameUtils, HookContainer $hookContainer)
For constructor parameters, \MediaWiki\MainConfigSchema::LocalisationCacheConf.
recache( $code)
Load localisation data for a given language for both core and extensions and save it to the persisten...
isExpired( $code)
Returns true if the cache identified by $code is missing or expired.
getSubitem( $code, $key, $subkey)
Get a subitem, for instance a single message for a given language.
array< string, array > $data
The cache data.
static getStoreFromConf(array $conf, $fallbackCacheDir)
Return a suitable LCStore as specified by the given configuration.
const ALL_KEYS
All item keys.
getMessagesDirs()
Gets the combined list of messages dirs from core and extensions.
getItem( $code, $key)
Get a cache item.
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.
A service that provides utilities to do with language names and codes.
A class containing constants representing the names of configuration variables.
Interface for the persistence layer of LocalisationCache.
Definition LCStore.php:40