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 if ( isset( $$key ) ) {
707 $data[$key] = $$key;
708 }
709 }
710 } elseif ( $_fileType == 'extension' ) {
711 foreach ( self::ALL_EXCEPT_CORE_ONLY_KEYS as $key ) {
712 if ( isset( $$key ) ) {
713 $data[$key] = $$key;
714 }
715 }
716 } elseif ( $_fileType == 'aliases' ) {
717 // @phan-suppress-next-line PhanImpossibleCondition May be set in the included file
718 if ( isset( $aliases ) ) {
719 $data['aliases'] = $aliases;
720 }
721 } else {
722 throw new InvalidArgumentException( __METHOD__ . ": Invalid file type: $_fileType" );
723 }
724
725 return $data;
726 }
727
734 private function readJSONFile( $fileName ) {
735 if ( !is_readable( $fileName ) ) {
736 return [];
737 }
738
739 $json = file_get_contents( $fileName );
740 if ( $json === false ) {
741 return [];
742 }
743
744 $data = FormatJson::decode( $json, true );
745 if ( $data === null ) {
746 throw new RuntimeException( __METHOD__ . ": Invalid JSON file: $fileName" );
747 }
748
749 // Remove keys starting with '@'; they are reserved for metadata and non-message data
750 foreach ( $data as $key => $unused ) {
751 if ( $key === '' || $key[0] === '@' ) {
752 unset( $data[$key] );
753 }
754 }
755
756 return $data;
757 }
758
766 private function getCompiledPluralRules( $code ) {
767 $rules = $this->getPluralRules( $code );
768 if ( $rules === null ) {
769 return null;
770 }
771 try {
772 $compiledRules = Evaluator::compile( $rules );
773 } catch ( CLDRPluralRuleError $e ) {
774 $this->logger->debug( $e->getMessage() );
775
776 return [];
777 }
778
779 return $compiledRules;
780 }
781
791 private function getPluralRules( $code ) {
792 if ( self::$pluralRules === null ) {
793 self::loadPluralFiles();
794 }
795 return self::$pluralRules[$code] ?? null;
796 }
797
807 private function getPluralRuleTypes( $code ) {
808 if ( self::$pluralRuleTypes === null ) {
809 self::loadPluralFiles();
810 }
811 return self::$pluralRuleTypes[$code] ?? null;
812 }
813
817 private static function loadPluralFiles() {
818 foreach ( self::PLURAL_FILES as $fileName ) {
819 self::loadPluralFile( $fileName );
820 }
821 }
822
829 private static function loadPluralFile( $fileName ) {
830 // Use file_get_contents instead of DOMDocument::load (T58439)
831 $xml = file_get_contents( $fileName );
832 if ( !$xml ) {
833 throw new RuntimeException( "Unable to read plurals file $fileName" );
834 }
835 $doc = new DOMDocument;
836 $doc->loadXML( $xml );
837 $rulesets = $doc->getElementsByTagName( "pluralRules" );
838 foreach ( $rulesets as $ruleset ) {
839 $codes = $ruleset->getAttribute( 'locales' );
840 $rules = [];
841 $ruleTypes = [];
842 $ruleElements = $ruleset->getElementsByTagName( "pluralRule" );
843 foreach ( $ruleElements as $elt ) {
844 $ruleType = $elt->getAttribute( 'count' );
845 if ( $ruleType === 'other' ) {
846 // Don't record "other" rules, which have an empty condition
847 continue;
848 }
849 $rules[] = $elt->nodeValue;
850 $ruleTypes[] = $ruleType;
851 }
852 foreach ( explode( ' ', $codes ) as $code ) {
853 self::$pluralRules[$code] = $rules;
854 self::$pluralRuleTypes[$code] = $ruleTypes;
855 }
856 }
857 }
858
867 private function readSourceFilesAndRegisterDeps( $code, &$deps ) {
868 // This reads in the PHP i18n file with non-messages l10n data
869 $fileName = $this->langNameUtils->getMessagesFileName( $code );
870 if ( !is_file( $fileName ) ) {
871 $data = [];
872 } else {
873 $deps[] = new FileDependency( $fileName );
874 $data = $this->readPHPFile( $fileName, 'core' );
875 }
876
877 return $data;
878 }
879
888 private function readPluralFilesAndRegisterDeps( $code, &$deps ) {
889 $data = [
890 // Load CLDR plural rules for JavaScript
891 'pluralRules' => $this->getPluralRules( $code ),
892 // And for PHP
893 'compiledPluralRules' => $this->getCompiledPluralRules( $code ),
894 // Load plural rule types
895 'pluralRuleTypes' => $this->getPluralRuleTypes( $code ),
896 ];
897
898 foreach ( self::PLURAL_FILES as $fileName ) {
899 $deps[] = new FileDependency( $fileName );
900 }
901
902 return $data;
903 }
904
913 private function mergeItem( $key, &$value, $fallbackValue ) {
914 if ( $value !== null ) {
915 if ( $fallbackValue !== null ) {
916 if ( in_array( $key, self::MERGEABLE_MAP_KEYS ) ) {
917 $value += $fallbackValue;
918 } elseif ( in_array( $key, self::MERGEABLE_ALIAS_LIST_KEYS ) ) {
919 $value = array_merge_recursive( $value, $fallbackValue );
920 } elseif ( in_array( $key, self::OPTIONAL_MERGE_KEYS ) ) {
921 if ( !empty( $value['inherit'] ) ) {
922 $value = array_merge( $fallbackValue, $value );
923 }
924
925 unset( $value['inherit'] );
926 } elseif ( in_array( $key, self::MAGIC_WORD_KEYS ) ) {
927 $this->mergeMagicWords( $value, $fallbackValue );
928 }
929 }
930 } else {
931 $value = $fallbackValue;
932 }
933 }
934
939 private function mergeMagicWords( array &$value, array $fallbackValue ): void {
940 foreach ( $fallbackValue as $magicName => $fallbackInfo ) {
941 if ( !isset( $value[$magicName] ) ) {
942 $value[$magicName] = $fallbackInfo;
943 } else {
944 $value[$magicName] = [
945 $fallbackInfo[0],
946 ...array_unique( [
947 // First value is 1 if the magic word is case-sensitive, 0 if not
948 ...array_slice( $value[$magicName], 1 ),
949 ...array_slice( $fallbackInfo, 1 ),
950 ] )
951 ];
952 }
953 }
954 }
955
963 public function getMessagesDirs() {
964 global $IP;
965
966 return [
967 'core' => "$IP/languages/i18n",
968 'codex' => "$IP/languages/i18n/codex",
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: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