MediaWiki master
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', 'fallback8bitEncoding',
155 'linkPrefixExtension', 'linkTrail', 'linkPrefixCharset',
156 'namespaceAliases', 'dateFormats', 'datePreferences',
157 'datePreferenceMigrationMap', 'defaultDateFormat',
158 'specialPageAliases', 'imageFiles', 'preloadedMessages',
159 'namespaceGenderAliases', 'digitGroupingPattern', 'pluralRules',
160 'pluralRuleTypes', 'compiledPluralRules', 'formalityIndex',
161 ];
162
170 private const CORE_ONLY_KEYS = [
171 'fallback', 'rtl', 'digitTransformTable', 'separatorTransformTable',
172 'minimumGroupingDigits', 'fallback8bitEncoding', 'linkPrefixExtension',
173 'linkTrail', 'linkPrefixCharset', 'datePreferences',
174 'datePreferenceMigrationMap', 'defaultDateFormat', 'digitGroupingPattern',
175 'formalityIndex',
176 ];
177
186 private const ALL_EXCEPT_CORE_ONLY_KEYS = [
187 'namespaceNames', 'bookstoreList', 'magicWords', 'messages',
188 'namespaceAliases', 'dateFormats', 'specialPageAliases',
189 'imageFiles', 'preloadedMessages', 'namespaceGenderAliases',
190 'pluralRules', 'pluralRuleTypes', 'compiledPluralRules',
191 ];
192
194 public const ALL_ALIAS_KEYS = [ 'specialPageAliases' ];
195
200 private const MERGEABLE_MAP_KEYS = [ 'messages', 'namespaceNames',
201 'namespaceAliases', 'dateFormats', 'imageFiles', 'preloadedMessages'
202 ];
203
208 private const MERGEABLE_ALIAS_LIST_KEYS = [ 'specialPageAliases' ];
209
215 private const OPTIONAL_MERGE_KEYS = [ 'bookstoreList' ];
216
220 private const MAGIC_WORD_KEYS = [ 'magicWords' ];
221
225 private const SPLIT_KEYS = [ 'messages' ];
226
231 private const SOURCE_PREFIX_KEYS = [ 'messages' ];
232
236 private const SOURCEPREFIX_SEPARATOR = ':';
237
241 private const PRELOADED_KEYS = [ 'dateFormats', 'namespaceNames' ];
242
243 private const PLURAL_FILES = [
244 // Load CLDR plural rules
245 MW_INSTALL_PATH . '/languages/data/plurals.xml',
246 // Override or extend with MW-specific rules
247 MW_INSTALL_PATH . '/languages/data/plurals-mediawiki.xml',
248 ];
249
256 private static $pluralRules = null;
257
272 private static $pluralRuleTypes = null;
273
282 public static function getStoreFromConf( array $conf, $fallbackCacheDir ): LCStore {
283 $storeArg = [];
284 $storeArg['directory'] =
285 $conf['storeDirectory'] ?: $fallbackCacheDir;
286
287 if ( !empty( $conf['storeClass'] ) ) {
288 $storeClass = $conf['storeClass'];
289 } elseif ( $conf['store'] === 'files' || $conf['store'] === 'file' ||
290 ( $conf['store'] === 'detect' && $storeArg['directory'] )
291 ) {
292 $storeClass = LCStoreCDB::class;
293 } elseif ( $conf['store'] === 'db' || $conf['store'] === 'detect' ) {
294 $storeClass = LCStoreDB::class;
295 $storeArg['server'] = $conf['storeServer'] ?? [];
296 } elseif ( $conf['store'] === 'array' ) {
297 $storeClass = LCStoreStaticArray::class;
298 } else {
299 throw new ConfigException(
300 'Please set $wgLocalisationCacheConf[\'store\'] to something sensible.'
301 );
302 }
303
304 return new $storeClass( $storeArg );
305 }
306
310 public const CONSTRUCTOR_OPTIONS = [
311 // True to treat all files as expired until they are regenerated by this object.
312 'forceRecache',
313 'manualRecache',
314 MainConfigNames::ExtensionMessagesFiles,
315 MainConfigNames::MessagesDirs,
316 MainConfigNames::TranslationAliasesDirs,
317 ];
318
332 public function __construct(
333 ServiceOptions $options,
334 LCStore $store,
335 LoggerInterface $logger,
336 array $clearStoreCallbacks,
337 LanguageNameUtils $langNameUtils,
338 HookContainer $hookContainer
339 ) {
340 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
341
342 $this->options = $options;
343 $this->store = $store;
344 $this->logger = $logger;
345 $this->clearStoreCallbacks = $clearStoreCallbacks;
346 $this->langNameUtils = $langNameUtils;
347 $this->hookRunner = new HookRunner( $hookContainer );
348
349 // Keep this separate from $this->options so that it can be mutable
350 $this->manualRecache = $options->get( 'manualRecache' );
351 }
352
359 private static function isMergeableKey( string $key ): bool {
360 static $mergeableKeys;
361 $mergeableKeys ??= array_fill_keys( [
362 ...self::MERGEABLE_MAP_KEYS,
363 ...self::MERGEABLE_ALIAS_LIST_KEYS,
364 ...self::OPTIONAL_MERGE_KEYS,
365 ...self::MAGIC_WORD_KEYS,
366 ], true );
367 return isset( $mergeableKeys[$key] );
368 }
369
379 public function getItem( $code, $key ) {
380 if ( !isset( $this->loadedItems[$code][$key] ) ) {
381 $this->loadItem( $code, $key );
382 }
383
384 if ( $key === 'fallback' && isset( $this->shallowFallbacks[$code] ) ) {
385 return $this->shallowFallbacks[$code];
386 }
387
388 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
389 return $this->data[$code][$key];
390 }
391
399 public function getSubitem( $code, $key, $subkey ) {
400 if ( !isset( $this->loadedSubitems[$code][$key][$subkey] ) &&
401 !isset( $this->loadedItems[$code][$key] )
402 ) {
403 $this->loadSubitem( $code, $key, $subkey );
404 }
405
406 return $this->data[$code][$key][$subkey] ?? null;
407 }
408
418 public function getSubitemWithSource( $code, $key, $subkey ) {
419 $subitem = $this->getSubitem( $code, $key, $subkey );
420 // Undefined in the backend.
421 if ( $subitem === null ) {
422 return null;
423 }
424
425 // The source language should have been set, but to avoid a Phan error and to be double sure.
426 return [ $subitem, $this->sourceLanguage[$code][$key][$subkey] ?? $code ];
427 }
428
442 public function getSubitemList( $code, $key ) {
443 if ( in_array( $key, self::SPLIT_KEYS ) ) {
444 return $this->getSubitem( $code, 'list', $key );
445 } else {
446 $item = $this->getItem( $code, $key );
447 if ( is_array( $item ) ) {
448 return array_keys( $item );
449 } else {
450 return false;
451 }
452 }
453 }
454
461 private function loadItem( $code, $key ) {
462 if ( isset( $this->loadedItems[$code][$key] ) ) {
463 return;
464 }
465
466 if (
467 in_array( $key, self::CORE_ONLY_KEYS, true ) ||
468 // "synthetic" keys added by loadCoreData based on "fallback"
469 $key === 'fallbackSequence' ||
470 $key === 'originalFallbackSequence'
471 ) {
472 if ( $this->langNameUtils->isValidBuiltInCode( $code ) ) {
473 $this->loadCoreData( $code );
474 return;
475 }
476 }
477
478 if ( !isset( $this->initialisedLangs[$code] ) ) {
479 $this->initLanguage( $code );
480
481 // Check to see if initLanguage() loaded it for us
482 if ( isset( $this->loadedItems[$code][$key] ) ) {
483 return;
484 }
485 }
486
487 if ( isset( $this->shallowFallbacks[$code] ) ) {
488 $this->loadItem( $this->shallowFallbacks[$code], $key );
489
490 return;
491 }
492
493 if ( in_array( $key, self::SPLIT_KEYS ) ) {
494 $subkeyList = $this->getSubitem( $code, 'list', $key );
495 foreach ( $subkeyList as $subkey ) {
496 if ( isset( $this->data[$code][$key][$subkey] ) ) {
497 continue;
498 }
499 $this->loadSubitem( $code, $key, $subkey );
500 }
501 } else {
502 $this->data[$code][$key] = $this->store->get( $code, $key );
503 }
504
505 $this->loadedItems[$code][$key] = true;
506 }
507
515 private function loadSubitem( $code, $key, $subkey ) {
516 if ( !in_array( $key, self::SPLIT_KEYS ) ) {
517 $this->loadItem( $code, $key );
518
519 return;
520 }
521
522 if ( !isset( $this->initialisedLangs[$code] ) ) {
523 $this->initLanguage( $code );
524 }
525
526 // Check to see if initLanguage() loaded it for us
527 if ( isset( $this->loadedItems[$code][$key] ) ||
528 isset( $this->loadedSubitems[$code][$key][$subkey] )
529 ) {
530 return;
531 }
532
533 if ( isset( $this->shallowFallbacks[$code] ) ) {
534 $this->loadSubitem( $this->shallowFallbacks[$code], $key, $subkey );
535
536 return;
537 }
538
539 $value = $this->store->get( $code, "$key:$subkey" );
540 if ( $value !== null && in_array( $key, self::SOURCE_PREFIX_KEYS ) ) {
541 [
542 $this->sourceLanguage[$code][$key][$subkey],
543 $this->data[$code][$key][$subkey]
544 ] = explode( self::SOURCEPREFIX_SEPARATOR, $value, 2 );
545 } else {
546 $this->data[$code][$key][$subkey] = $value;
547 }
548
549 $this->loadedSubitems[$code][$key][$subkey] = true;
550 }
551
559 public function isExpired( $code ) {
560 if ( $this->options->get( 'forceRecache' ) && !isset( $this->recachedLangs[$code] ) ) {
561 $this->logger->debug( __METHOD__ . "($code): forced reload" );
562
563 return true;
564 }
565
566 $deps = $this->store->get( $code, 'deps' );
567 $keys = $this->store->get( $code, 'list' );
568 $preload = $this->store->get( $code, 'preload' );
569 // Different keys may expire separately for some stores
570 if ( $deps === null || $keys === null || $preload === null ) {
571 $this->logger->debug( __METHOD__ . "($code): cache missing, need to make one" );
572
573 return true;
574 }
575
576 foreach ( $deps as $dep ) {
577 // Because we're unserializing stuff from cache, we
578 // could receive objects of classes that don't exist
579 // anymore (e.g., uninstalled extensions)
580 // When this happens, always expire the cache
581 if ( !$dep instanceof CacheDependency || $dep->isExpired() ) {
582 $this->logger->debug( __METHOD__ . "($code): cache for $code expired due to " .
583 get_class( $dep ) );
584
585 return true;
586 }
587 }
588
589 return false;
590 }
591
597 private function initLanguage( $code ) {
598 if ( isset( $this->initialisedLangs[$code] ) ) {
599 return;
600 }
601
602 $this->initialisedLangs[$code] = true;
603
604 # If the code is of the wrong form for a Messages*.php file, do a shallow fallback
605 if ( !$this->langNameUtils->isValidBuiltInCode( $code ) ) {
606 $this->initShallowFallback( $code, 'en' );
607
608 return;
609 }
610
611 # Re-cache the data if necessary
612 if ( !$this->manualRecache && $this->isExpired( $code ) ) {
613 if ( $this->langNameUtils->isSupportedLanguage( $code ) ) {
614 $this->recache( $code );
615 } elseif ( $code === 'en' ) {
616 throw new RuntimeException( 'MessagesEn.php is missing.' );
617 } else {
618 $this->initShallowFallback( $code, 'en' );
619 }
620
621 return;
622 }
623
624 # Preload some stuff
625 $preload = $this->getItem( $code, 'preload' );
626 if ( $preload === null ) {
627 if ( $this->manualRecache ) {
628 // No Messages*.php file. Do shallow fallback to en.
629 if ( $code === 'en' ) {
630 throw new RuntimeException( 'No localisation cache found for English. ' .
631 'Please run maintenance/rebuildLocalisationCache.php.' );
632 }
633 $this->initShallowFallback( $code, 'en' );
634
635 return;
636 } else {
637 throw new RuntimeException( 'Invalid or missing localisation cache.' );
638 }
639 }
640
641 foreach ( self::SOURCE_PREFIX_KEYS as $key ) {
642 if ( !isset( $preload[$key] ) ) {
643 continue;
644 }
645 foreach ( $preload[$key] as $subkey => $value ) {
646 if ( $value !== null ) {
647 [
648 $this->sourceLanguage[$code][$key][$subkey],
649 $preload[$key][$subkey]
650 ] = explode( self::SOURCEPREFIX_SEPARATOR, $value, 2 );
651 } else {
652 $preload[$key][$subkey] = null;
653 }
654 }
655 }
656
657 if ( isset( $this->data[$code] ) ) {
658 foreach ( $preload as $key => $value ) {
659 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- see isset() above
660 $this->mergeItem( $key, $this->data[$code][$key], $value );
661 }
662 } else {
663 $this->data[$code] = $preload;
664 }
665 foreach ( $preload as $key => $item ) {
666 if ( in_array( $key, self::SPLIT_KEYS ) ) {
667 foreach ( $item as $subkey => $subitem ) {
668 $this->loadedSubitems[$code][$key][$subkey] = true;
669 }
670 } else {
671 $this->loadedItems[$code][$key] = true;
672 }
673 }
674 }
675
683 private function initShallowFallback( $primaryCode, $fallbackCode ) {
684 $this->data[$primaryCode] =& $this->data[$fallbackCode];
685 $this->loadedItems[$primaryCode] =& $this->loadedItems[$fallbackCode];
686 $this->loadedSubitems[$primaryCode] =& $this->loadedSubitems[$fallbackCode];
687 $this->shallowFallbacks[$primaryCode] = $fallbackCode;
688 $this->coreDataLoaded[$primaryCode] =& $this->coreDataLoaded[$fallbackCode];
689 }
690
698 protected function readPHPFile( $_fileName, $_fileType ) {
699 include $_fileName;
700
701 $data = [];
702 if ( $_fileType == 'core' ) {
703 foreach ( self::ALL_KEYS as $key ) {
704 // Not all keys are set in language files, so
705 // check they exist first
706 // @phan-suppress-next-line MediaWikiNoIssetIfDefined May be set in the included file
707 if ( isset( $$key ) ) {
708 $data[$key] = $$key;
709 }
710 }
711 } elseif ( $_fileType == 'extension' ) {
712 foreach ( self::ALL_EXCEPT_CORE_ONLY_KEYS as $key ) {
713 // @phan-suppress-next-line MediaWikiNoIssetIfDefined May be set in the included file
714 if ( isset( $$key ) ) {
715 $data[$key] = $$key;
716 }
717 }
718 } elseif ( $_fileType == 'aliases' ) {
719 // @phan-suppress-next-line PhanImpossibleCondition May be set in the included file
720 if ( isset( $aliases ) ) {
721 $data['aliases'] = $aliases;
722 }
723 } else {
724 throw new InvalidArgumentException( __METHOD__ . ": Invalid file type: $_fileType" );
725 }
726
727 return $data;
728 }
729
736 private function readJSONFile( $fileName ) {
737 if ( !is_readable( $fileName ) ) {
738 return [];
739 }
740
741 $json = file_get_contents( $fileName );
742 if ( $json === false ) {
743 return [];
744 }
745
746 $data = FormatJson::decode( $json, true );
747 if ( $data === null ) {
748 throw new RuntimeException( __METHOD__ . ": Invalid JSON file: $fileName" );
749 }
750
751 // Remove keys starting with '@'; they are reserved for metadata and non-message data
752 foreach ( $data as $key => $unused ) {
753 if ( $key === '' || $key[0] === '@' ) {
754 unset( $data[$key] );
755 }
756 }
757
758 return $data;
759 }
760
768 private function getCompiledPluralRules( $code ) {
769 $rules = $this->getPluralRules( $code );
770 if ( $rules === null ) {
771 return null;
772 }
773 try {
774 $compiledRules = Evaluator::compile( $rules );
775 } catch ( CLDRPluralRuleError $e ) {
776 $this->logger->debug( $e->getMessage() );
777
778 return [];
779 }
780
781 return $compiledRules;
782 }
783
793 private function getPluralRules( $code ) {
794 if ( self::$pluralRules === null ) {
795 self::loadPluralFiles();
796 }
797 return self::$pluralRules[$code] ?? null;
798 }
799
809 private function getPluralRuleTypes( $code ) {
810 if ( self::$pluralRuleTypes === null ) {
811 self::loadPluralFiles();
812 }
813 return self::$pluralRuleTypes[$code] ?? null;
814 }
815
819 private static function loadPluralFiles() {
820 foreach ( self::PLURAL_FILES as $fileName ) {
821 self::loadPluralFile( $fileName );
822 }
823 }
824
831 private static function loadPluralFile( $fileName ) {
832 // Use file_get_contents instead of DOMDocument::load (T58439)
833 $xml = file_get_contents( $fileName );
834 if ( !$xml ) {
835 throw new RuntimeException( "Unable to read plurals file $fileName" );
836 }
837 $doc = new DOMDocument;
838 $doc->loadXML( $xml );
839 $rulesets = $doc->getElementsByTagName( "pluralRules" );
840 foreach ( $rulesets as $ruleset ) {
841 $codes = $ruleset->getAttribute( 'locales' );
842 $rules = [];
843 $ruleTypes = [];
844 $ruleElements = $ruleset->getElementsByTagName( "pluralRule" );
845 foreach ( $ruleElements as $elt ) {
846 $ruleType = $elt->getAttribute( 'count' );
847 if ( $ruleType === 'other' ) {
848 // Don't record "other" rules, which have an empty condition
849 continue;
850 }
851 $rules[] = $elt->nodeValue;
852 $ruleTypes[] = $ruleType;
853 }
854 foreach ( explode( ' ', $codes ) as $code ) {
855 self::$pluralRules[$code] = $rules;
856 self::$pluralRuleTypes[$code] = $ruleTypes;
857 }
858 }
859 }
860
869 private function readSourceFilesAndRegisterDeps( $code, &$deps ) {
870 // This reads in the PHP i18n file with non-messages l10n data
871 $fileName = $this->langNameUtils->getMessagesFileName( $code );
872 if ( !is_file( $fileName ) ) {
873 $data = [];
874 } else {
875 $deps[] = new FileDependency( $fileName );
876 $data = $this->readPHPFile( $fileName, 'core' );
877 }
878
879 return $data;
880 }
881
890 private function readPluralFilesAndRegisterDeps( $code, &$deps ) {
891 $data = [
892 // Load CLDR plural rules for JavaScript
893 'pluralRules' => $this->getPluralRules( $code ),
894 // And for PHP
895 'compiledPluralRules' => $this->getCompiledPluralRules( $code ),
896 // Load plural rule types
897 'pluralRuleTypes' => $this->getPluralRuleTypes( $code ),
898 ];
899
900 foreach ( self::PLURAL_FILES as $fileName ) {
901 $deps[] = new FileDependency( $fileName );
902 }
903
904 return $data;
905 }
906
915 private function mergeItem( $key, &$value, $fallbackValue ) {
916 if ( $value !== null ) {
917 if ( $fallbackValue !== null ) {
918 if ( in_array( $key, self::MERGEABLE_MAP_KEYS ) ) {
919 $value += $fallbackValue;
920 } elseif ( in_array( $key, self::MERGEABLE_ALIAS_LIST_KEYS ) ) {
921 $value = array_merge_recursive( $value, $fallbackValue );
922 } elseif ( in_array( $key, self::OPTIONAL_MERGE_KEYS ) ) {
923 if ( !empty( $value['inherit'] ) ) {
924 $value = array_merge( $fallbackValue, $value );
925 }
926
927 unset( $value['inherit'] );
928 } elseif ( in_array( $key, self::MAGIC_WORD_KEYS ) ) {
929 $this->mergeMagicWords( $value, $fallbackValue );
930 }
931 }
932 } else {
933 $value = $fallbackValue;
934 }
935 }
936
937 private function mergeMagicWords( array &$value, array $fallbackValue ): void {
938 foreach ( $fallbackValue as $magicName => $fallbackInfo ) {
939 if ( !isset( $value[$magicName] ) ) {
940 $value[$magicName] = $fallbackInfo;
941 } else {
942 $value[$magicName] = [
943 $fallbackInfo[0],
944 ...array_unique( [
945 // First value is 1 if the magic word is case-sensitive, 0 if not
946 ...array_slice( $value[$magicName], 1 ),
947 ...array_slice( $fallbackInfo, 1 ),
948 ] )
949 ];
950 }
951 }
952 }
953
961 public function getMessagesDirs() {
962 global $IP;
963
964 return [
965 'core' => "$IP/languages/i18n",
966 'codex' => "$IP/languages/i18n/codex",
967 'exif' => "$IP/languages/i18n/exif",
968 'preferences' => "$IP/languages/i18n/preferences",
969 'api' => "$IP/includes/api/i18n",
970 'rest' => "$IP/includes/Rest/i18n",
971 'oojs-ui' => "$IP/resources/lib/ooui/i18n",
972 'paramvalidator' => "$IP/includes/libs/ParamValidator/i18n",
973 'installer' => "$IP/includes/installer/i18n",
974 ] + $this->options->get( MainConfigNames::MessagesDirs );
975 }
976
987 private function loadCoreData( string $code ) {
988 if ( !$code ) {
989 throw new InvalidArgumentException( "Invalid language code requested" );
990 }
991 if ( $this->coreDataLoaded[$code] ?? false ) {
992 return;
993 }
994
995 $coreData = array_fill_keys( self::CORE_ONLY_KEYS, null );
996 $deps = [];
997
998 # Load the primary localisation from the source file
999 $data = $this->readSourceFilesAndRegisterDeps( $code, $deps );
1000 $this->logger->debug( __METHOD__ . ": got localisation for $code from source" );
1001
1002 # Merge primary localisation
1003 foreach ( $data as $key => $value ) {
1004 $this->mergeItem( $key, $coreData[ $key ], $value );
1005 }
1006
1007 # Fill in the fallback if it's not there already
1008 // @phan-suppress-next-line PhanSuspiciousValueComparison
1009 if ( ( $coreData['fallback'] === null || $coreData['fallback'] === false ) && $code === 'en' ) {
1010 $coreData['fallback'] = false;
1011 $coreData['originalFallbackSequence'] = $coreData['fallbackSequence'] = [];
1012 } else {
1013 if ( $coreData['fallback'] !== null ) {
1014 $coreData['fallbackSequence'] = array_map( 'trim', explode( ',', $coreData['fallback'] ) );
1015 } else {
1016 $coreData['fallbackSequence'] = [];
1017 }
1018 $len = count( $coreData['fallbackSequence'] );
1019
1020 # Before we add the 'en' fallback for messages, keep a copy of
1021 # the original fallback sequence
1022 $coreData['originalFallbackSequence'] = $coreData['fallbackSequence'];
1023
1024 # Ensure that the sequence ends at 'en' for messages
1025 if ( !$len || $coreData['fallbackSequence'][$len - 1] !== 'en' ) {
1026 $coreData['fallbackSequence'][] = 'en';
1027 }
1028 }
1029
1030 foreach ( $coreData['fallbackSequence'] as $fbCode ) {
1031 // load core fallback data
1032 $fbData = $this->readSourceFilesAndRegisterDeps( $fbCode, $deps );
1033 foreach ( self::CORE_ONLY_KEYS as $key ) {
1034 // core-only keys are not mergeable, only set if not present in core data yet
1035 if ( isset( $fbData[$key] ) && !isset( $coreData[$key] ) ) {
1036 $coreData[$key] = $fbData[$key];
1037 }
1038 }
1039 }
1040
1041 $coreData['deps'] = $deps;
1042 foreach ( $coreData as $key => $item ) {
1043 $this->data[$code][$key] ??= null;
1044 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- we just set a default null
1045 $this->mergeItem( $key, $this->data[$code][$key], $item );
1046 if (
1047 in_array( $key, self::CORE_ONLY_KEYS, true ) ||
1048 // "synthetic" keys based on "fallback" (see above)
1049 $key === 'fallbackSequence' ||
1050 $key === 'originalFallbackSequence'
1051 ) {
1052 // only mark core-only keys as loaded;
1053 // we may have loaded additional ones from the source file,
1054 // but they are not fully loaded yet, since recache()
1055 // may have to merge in additional values from fallback languages
1056 $this->loadedItems[$code][$key] = true;
1057 }
1058 }
1059
1060 $this->coreDataLoaded[$code] = true;
1061 }
1062
1069 public function recache( $code ) {
1070 if ( !$code ) {
1071 throw new InvalidArgumentException( "Invalid language code requested" );
1072 }
1073 $this->recachedLangs[ $code ] = true;
1074
1075 # Initial values
1076 $initialData = array_fill_keys( self::ALL_KEYS, null );
1077 $this->data[$code] = [];
1078 $this->loadedItems[$code] = [];
1079 $this->loadedSubitems[$code] = [];
1080 $this->coreDataLoaded[$code] = false;
1081 $this->loadCoreData( $code );
1082 $coreData = $this->data[$code];
1083 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- guaranteed by loadCoreData()
1084 $deps = $coreData['deps'];
1085 $coreData += $this->readPluralFilesAndRegisterDeps( $code, $deps );
1086
1087 $codeSequence = array_merge( [ $code ], $coreData['fallbackSequence'] );
1088 $messageDirs = $this->getMessagesDirs();
1089 $translationAliasesDirs = $this->options->get( MainConfigNames::TranslationAliasesDirs );
1090
1091 # Load non-JSON localisation data for extensions
1092 $extensionData = array_fill_keys( $codeSequence, $initialData );
1093 foreach ( $this->options->get( MainConfigNames::ExtensionMessagesFiles ) as $extension => $fileName ) {
1094 if ( isset( $messageDirs[$extension] ) || isset( $translationAliasesDirs[$extension] ) ) {
1095 # This extension has JSON message data; skip the PHP shim
1096 continue;
1097 }
1098
1099 $data = $this->readPHPFile( $fileName, 'extension' );
1100 $used = false;
1101
1102 foreach ( $data as $key => $item ) {
1103 foreach ( $codeSequence as $csCode ) {
1104 if ( isset( $item[$csCode] ) ) {
1105 // Keep the behaviour the same as for json messages.
1106 // TODO: Consider deprecating using a PHP file for messages.
1107 if ( in_array( $key, self::SOURCE_PREFIX_KEYS ) ) {
1108 foreach ( $item[$csCode] as $subkey => $_ ) {
1109 $this->sourceLanguage[$code][$key][$subkey] ??= $csCode;
1110 }
1111 }
1112 $this->mergeItem( $key, $extensionData[$csCode][$key], $item[$csCode] );
1113 $used = true;
1114 }
1115 }
1116 }
1117
1118 if ( $used ) {
1119 $deps[] = new FileDependency( $fileName );
1120 }
1121 }
1122
1123 # Load the localisation data for each fallback, then merge it into the full array
1124 $allData = $initialData;
1125 foreach ( $codeSequence as $csCode ) {
1126 $csData = $initialData;
1127
1128 # Load core messages and the extension localisations.
1129 foreach ( $messageDirs as $dirs ) {
1130 foreach ( (array)$dirs as $dir ) {
1131 $fileName = "$dir/$csCode.json";
1132 $messages = $this->readJSONFile( $fileName );
1133
1134 foreach ( $messages as $subkey => $_ ) {
1135 $this->sourceLanguage[$code]['messages'][$subkey] ??= $csCode;
1136 }
1137 $this->mergeItem( 'messages', $csData['messages'], $messages );
1138
1139 $deps[] = new FileDependency( $fileName );
1140 }
1141 }
1142
1143 foreach ( $translationAliasesDirs as $dirs ) {
1144 foreach ( (array)$dirs as $dir ) {
1145 $fileName = "$dir/$csCode.json";
1146 $data = $this->readJSONFile( $fileName );
1147
1148 foreach ( $data as $key => $item ) {
1149 // We allow the key in the JSON to be specified in PascalCase similar to key definitions in
1150 // extension.json, but eventually they are stored in camelCase
1151 $normalizedKey = lcfirst( $key );
1152
1153 if ( $normalizedKey === '@metadata' ) {
1154 // Don't store @metadata information in extension data.
1155 continue;
1156 }
1157
1158 if ( !in_array( $normalizedKey, self::ALL_ALIAS_KEYS ) ) {
1159 throw new UnexpectedValueException(
1160 "Invalid key: \"$key\" for " . MainConfigNames::TranslationAliasesDirs . ". " .
1161 'Valid keys: ' . implode( ', ', self::ALL_ALIAS_KEYS )
1162 );
1163 }
1164
1165 $this->mergeItem( $normalizedKey, $extensionData[$csCode][$normalizedKey], $item );
1166 }
1167
1168 $deps[] = new FileDependency( $fileName );
1169 }
1170 }
1171
1172 # Merge non-JSON extension data
1173 if ( isset( $extensionData[$csCode] ) ) {
1174 foreach ( $extensionData[$csCode] as $key => $item ) {
1175 $this->mergeItem( $key, $csData[$key], $item );
1176 }
1177 }
1178
1179 if ( $csCode === $code ) {
1180 # Merge core data into extension data
1181 foreach ( $coreData as $key => $item ) {
1182 $this->mergeItem( $key, $csData[$key], $item );
1183 }
1184 } else {
1185 # Load the secondary localisation from the source file to
1186 # avoid infinite cycles on cyclic fallbacks
1187 $fbData = $this->readSourceFilesAndRegisterDeps( $csCode, $deps );
1188 $fbData += $this->readPluralFilesAndRegisterDeps( $csCode, $deps );
1189 # Only merge the keys that make sense to merge
1190 foreach ( self::ALL_KEYS as $key ) {
1191 if ( !isset( $fbData[ $key ] ) ) {
1192 continue;
1193 }
1194
1195 if ( !isset( $coreData[ $key ] ) || self::isMergeableKey( $key ) ) {
1196 $this->mergeItem( $key, $csData[ $key ], $fbData[ $key ] );
1197 }
1198 }
1199 }
1200
1201 # Allow extensions an opportunity to adjust the data for this fallback
1202 $this->hookRunner->onLocalisationCacheRecacheFallback( $this, $csCode, $csData );
1203
1204 # Merge the data for this fallback into the final array
1205 if ( $csCode === $code ) {
1206 $allData = $csData;
1207 } else {
1208 foreach ( self::ALL_KEYS as $key ) {
1209 if ( !isset( $csData[$key] ) ) {
1210 continue;
1211 }
1212
1213 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1214 if ( $allData[$key] === null || self::isMergeableKey( $key ) ) {
1215 $this->mergeItem( $key, $allData[$key], $csData[$key] );
1216 }
1217 }
1218 }
1219 }
1220
1221 if ( !isset( $allData['rtl'] ) ) {
1222 throw new RuntimeException( __METHOD__ . ': Localisation data failed validation check! ' .
1223 'Check that your languages/messages/MessagesEn.php file is intact.' );
1224 }
1225
1226 // Add cache dependencies for any referenced configs
1227 // We use the keys prefixed with 'wg' for historical reasons.
1228 $deps['wgExtensionMessagesFiles'] =
1229 new MainConfigDependency( MainConfigNames::ExtensionMessagesFiles );
1230 $deps['wgMessagesDirs'] =
1231 new MainConfigDependency( MainConfigNames::MessagesDirs );
1232 $deps['version'] = new ConstantDependency( self::class . '::VERSION' );
1233
1234 # Add dependencies to the cache entry
1235 $allData['deps'] = $deps;
1236
1237 # Replace spaces with underscores in namespace names
1238 $allData['namespaceNames'] = str_replace( ' ', '_', $allData['namespaceNames'] );
1239
1240 # And do the same for special page aliases. $page is an array.
1241 foreach ( $allData['specialPageAliases'] as &$page ) {
1242 $page = str_replace( ' ', '_', $page );
1243 }
1244 # Decouple the reference to prevent accidental damage
1245 unset( $page );
1246
1247 # If there were no plural rules, return an empty array
1248 $allData['pluralRules'] ??= [];
1249 $allData['compiledPluralRules'] ??= [];
1250 # If there were no plural rule types, return an empty array
1251 $allData['pluralRuleTypes'] ??= [];
1252
1253 # Set the list keys
1254 $allData['list'] = [];
1255 foreach ( self::SPLIT_KEYS as $key ) {
1256 $allData['list'][$key] = array_keys( $allData[$key] );
1257 }
1258 # Run hooks
1259 $unused = true; // Used to be $purgeBlobs, removed in 1.34
1260 $this->hookRunner->onLocalisationCacheRecache( $this, $code, $allData, $unused );
1261
1262 # Save to the process cache and register the items loaded
1263 $this->data[$code] = $allData;
1264 $this->loadedItems[$code] = [];
1265 $this->loadedSubitems[$code] = [];
1266 foreach ( $allData as $key => $item ) {
1267 $this->loadedItems[$code][$key] = true;
1268 }
1269
1270 # Prefix each item with its source language code before save
1271 foreach ( self::SOURCE_PREFIX_KEYS as $key ) {
1272 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1273 foreach ( $allData[$key] as $subKey => $value ) {
1274 // The source language should have been set, but to avoid Phan error and be double sure.
1275 $allData[$key][$subKey] = ( $this->sourceLanguage[$code][$key][$subKey] ?? $code ) .
1276 self::SOURCEPREFIX_SEPARATOR . $value;
1277 }
1278 }
1279
1280 # Set the preload key
1281 $allData['preload'] = $this->buildPreload( $allData );
1282
1283 # Save to the persistent cache
1284 $this->store->startWrite( $code );
1285 foreach ( $allData as $key => $value ) {
1286 if ( in_array( $key, self::SPLIT_KEYS ) ) {
1287 foreach ( $value as $subkey => $subvalue ) {
1288 $this->store->set( "$key:$subkey", $subvalue );
1289 }
1290 } else {
1291 $this->store->set( $key, $value );
1292 }
1293 }
1294 $this->store->finishWrite();
1295
1296 # Clear out the MessageBlobStore
1297 # HACK: If using a null (i.e., disabled) storage backend, we
1298 # can't write to the MessageBlobStore either
1299 if ( !$this->store instanceof LCStoreNull ) {
1300 foreach ( $this->clearStoreCallbacks as $callback ) {
1301 $callback();
1302 }
1303 }
1304 }
1305
1315 private function buildPreload( $data ) {
1316 $preload = [ 'messages' => [] ];
1317 foreach ( self::PRELOADED_KEYS as $key ) {
1318 $preload[$key] = $data[$key];
1319 }
1320
1321 foreach ( $data['preloadedMessages'] as $subkey ) {
1322 $subitem = $data['messages'][$subkey] ?? null;
1323 $preload['messages'][$subkey] = $subitem;
1324 }
1325
1326 return $preload;
1327 }
1328
1336 public function unload( $code ) {
1337 unset( $this->data[$code] );
1338 unset( $this->loadedItems[$code] );
1339 unset( $this->loadedSubitems[$code] );
1340 unset( $this->initialisedLangs[$code] );
1341 unset( $this->shallowFallbacks[$code] );
1342 unset( $this->sourceLanguage[$code] );
1343 unset( $this->coreDataLoaded[$code] );
1344
1345 foreach ( $this->shallowFallbacks as $shallowCode => $fbCode ) {
1346 if ( $fbCode === $code ) {
1347 $this->unload( $shallowCode );
1348 }
1349 }
1350 }
1351
1355 public function unloadAll() {
1356 foreach ( $this->initialisedLangs as $lang => $unused ) {
1357 $this->unload( $lang );
1358 }
1359 }
1360
1364 public function disableBackend() {
1365 $this->store = new LCStoreNull;
1366 $this->manualRecache = false;
1367 }
1368}
if(!defined( 'MEDIAWIKI')) if(ini_get('mbstring.func_overload')) if(!defined( 'MW_ENTRY_POINT')) global $IP
Environment checks.
Definition Setup.php:105
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