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 ] + $this->options->get( MainConfigNames::MessagesDirs );
976 }
977
988 private function loadCoreData( string $code ) {
989 if ( !$code ) {
990 throw new InvalidArgumentException( "Invalid language code requested" );
991 }
992 if ( $this->coreDataLoaded[$code] ?? false ) {
993 return;
994 }
995
996 $coreData = array_fill_keys( self::CORE_ONLY_KEYS, null );
997 $deps = [];
998
999 # Load the primary localisation from the source file
1000 $data = $this->readSourceFilesAndRegisterDeps( $code, $deps );
1001 $this->logger->debug( __METHOD__ . ": got localisation for $code from source" );
1002
1003 # Merge primary localisation
1004 foreach ( $data as $key => $value ) {
1005 $this->mergeItem( $key, $coreData[ $key ], $value );
1006 }
1007
1008 # Fill in the fallback if it's not there already
1009 // @phan-suppress-next-line PhanSuspiciousValueComparison
1010 if ( ( $coreData['fallback'] === null || $coreData['fallback'] === false ) && $code === 'en' ) {
1011 $coreData['fallback'] = false;
1012 $coreData['originalFallbackSequence'] = $coreData['fallbackSequence'] = [];
1013 } else {
1014 if ( $coreData['fallback'] !== null ) {
1015 $coreData['fallbackSequence'] = array_map( 'trim', explode( ',', $coreData['fallback'] ) );
1016 } else {
1017 $coreData['fallbackSequence'] = [];
1018 }
1019 $len = count( $coreData['fallbackSequence'] );
1020
1021 # Before we add the 'en' fallback for messages, keep a copy of
1022 # the original fallback sequence
1023 $coreData['originalFallbackSequence'] = $coreData['fallbackSequence'];
1024
1025 # Ensure that the sequence ends at 'en' for messages
1026 if ( !$len || $coreData['fallbackSequence'][$len - 1] !== 'en' ) {
1027 $coreData['fallbackSequence'][] = 'en';
1028 }
1029 }
1030
1031 foreach ( $coreData['fallbackSequence'] as $fbCode ) {
1032 // load core fallback data
1033 $fbData = $this->readSourceFilesAndRegisterDeps( $fbCode, $deps );
1034 foreach ( self::CORE_ONLY_KEYS as $key ) {
1035 // core-only keys are not mergeable, only set if not present in core data yet
1036 if ( isset( $fbData[$key] ) && !isset( $coreData[$key] ) ) {
1037 $coreData[$key] = $fbData[$key];
1038 }
1039 }
1040 }
1041
1042 $coreData['deps'] = $deps;
1043 foreach ( $coreData as $key => $item ) {
1044 $this->data[$code][$key] ??= null;
1045 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- we just set a default null
1046 $this->mergeItem( $key, $this->data[$code][$key], $item );
1047 if (
1048 in_array( $key, self::CORE_ONLY_KEYS, true ) ||
1049 // "synthetic" keys based on "fallback" (see above)
1050 $key === 'fallbackSequence' ||
1051 $key === 'originalFallbackSequence'
1052 ) {
1053 // only mark core-only keys as loaded;
1054 // we may have loaded additional ones from the source file,
1055 // but they are not fully loaded yet, since recache()
1056 // may have to merge in additional values from fallback languages
1057 $this->loadedItems[$code][$key] = true;
1058 }
1059 }
1060
1061 $this->coreDataLoaded[$code] = true;
1062 }
1063
1070 public function recache( $code ) {
1071 if ( !$code ) {
1072 throw new InvalidArgumentException( "Invalid language code requested" );
1073 }
1074 $this->recachedLangs[ $code ] = true;
1075
1076 # Initial values
1077 $initialData = array_fill_keys( self::ALL_KEYS, null );
1078 $this->data[$code] = [];
1079 $this->loadedItems[$code] = [];
1080 $this->loadedSubitems[$code] = [];
1081 $this->coreDataLoaded[$code] = false;
1082 $this->loadCoreData( $code );
1083 $coreData = $this->data[$code];
1084 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- guaranteed by loadCoreData()
1085 $deps = $coreData['deps'];
1086 $coreData += $this->readPluralFilesAndRegisterDeps( $code, $deps );
1087
1088 $codeSequence = array_merge( [ $code ], $coreData['fallbackSequence'] );
1089 $messageDirs = $this->getMessagesDirs();
1090 $translationAliasesDirs = $this->options->get( MainConfigNames::TranslationAliasesDirs );
1091
1092 # Load non-JSON localisation data for extensions
1093 $extensionData = array_fill_keys( $codeSequence, $initialData );
1094 foreach ( $this->options->get( MainConfigNames::ExtensionMessagesFiles ) as $extension => $fileName ) {
1095 if ( isset( $messageDirs[$extension] ) || isset( $translationAliasesDirs[$extension] ) ) {
1096 # This extension has JSON message data; skip the PHP shim
1097 continue;
1098 }
1099
1100 $data = $this->readPHPFile( $fileName, 'extension' );
1101 $used = false;
1102
1103 foreach ( $data as $key => $item ) {
1104 foreach ( $codeSequence as $csCode ) {
1105 if ( isset( $item[$csCode] ) ) {
1106 // Keep the behaviour the same as for json messages.
1107 // TODO: Consider deprecating using a PHP file for messages.
1108 if ( in_array( $key, self::SOURCE_PREFIX_KEYS ) ) {
1109 foreach ( $item[$csCode] as $subkey => $_ ) {
1110 $this->sourceLanguage[$code][$key][$subkey] ??= $csCode;
1111 }
1112 }
1113 $this->mergeItem( $key, $extensionData[$csCode][$key], $item[$csCode] );
1114 $used = true;
1115 }
1116 }
1117 }
1118
1119 if ( $used ) {
1120 $deps[] = new FileDependency( $fileName );
1121 }
1122 }
1123
1124 # Load the localisation data for each fallback, then merge it into the full array
1125 $allData = $initialData;
1126 foreach ( $codeSequence as $csCode ) {
1127 $csData = $initialData;
1128
1129 # Load core messages and the extension localisations.
1130 foreach ( $messageDirs as $dirs ) {
1131 foreach ( (array)$dirs as $dir ) {
1132 $fileName = "$dir/$csCode.json";
1133 $messages = $this->readJSONFile( $fileName );
1134
1135 foreach ( $messages as $subkey => $_ ) {
1136 $this->sourceLanguage[$code]['messages'][$subkey] ??= $csCode;
1137 }
1138 $this->mergeItem( 'messages', $csData['messages'], $messages );
1139
1140 $deps[] = new FileDependency( $fileName );
1141 }
1142 }
1143
1144 foreach ( $translationAliasesDirs as $dirs ) {
1145 foreach ( (array)$dirs as $dir ) {
1146 $fileName = "$dir/$csCode.json";
1147 $data = $this->readJSONFile( $fileName );
1148
1149 foreach ( $data as $key => $item ) {
1150 // We allow the key in the JSON to be specified in PascalCase similar to key definitions in
1151 // extension.json, but eventually they are stored in camelCase
1152 $normalizedKey = lcfirst( $key );
1153
1154 if ( $normalizedKey === '@metadata' ) {
1155 // Don't store @metadata information in extension data.
1156 continue;
1157 }
1158
1159 if ( !in_array( $normalizedKey, self::ALL_ALIAS_KEYS ) ) {
1160 throw new UnexpectedValueException(
1161 "Invalid key: \"$key\" for " . MainConfigNames::TranslationAliasesDirs . ". " .
1162 'Valid keys: ' . implode( ', ', self::ALL_ALIAS_KEYS )
1163 );
1164 }
1165
1166 $this->mergeItem( $normalizedKey, $extensionData[$csCode][$normalizedKey], $item );
1167 }
1168
1169 $deps[] = new FileDependency( $fileName );
1170 }
1171 }
1172
1173 # Merge non-JSON extension data
1174 if ( isset( $extensionData[$csCode] ) ) {
1175 foreach ( $extensionData[$csCode] as $key => $item ) {
1176 $this->mergeItem( $key, $csData[$key], $item );
1177 }
1178 }
1179
1180 if ( $csCode === $code ) {
1181 # Merge core data into extension data
1182 foreach ( $coreData as $key => $item ) {
1183 $this->mergeItem( $key, $csData[$key], $item );
1184 }
1185 } else {
1186 # Load the secondary localisation from the source file to
1187 # avoid infinite cycles on cyclic fallbacks
1188 $fbData = $this->readSourceFilesAndRegisterDeps( $csCode, $deps );
1189 $fbData += $this->readPluralFilesAndRegisterDeps( $csCode, $deps );
1190 # Only merge the keys that make sense to merge
1191 foreach ( self::ALL_KEYS as $key ) {
1192 if ( !isset( $fbData[ $key ] ) ) {
1193 continue;
1194 }
1195
1196 if ( !isset( $coreData[ $key ] ) || self::isMergeableKey( $key ) ) {
1197 $this->mergeItem( $key, $csData[ $key ], $fbData[ $key ] );
1198 }
1199 }
1200 }
1201
1202 # Allow extensions an opportunity to adjust the data for this fallback
1203 $this->hookRunner->onLocalisationCacheRecacheFallback( $this, $csCode, $csData );
1204
1205 # Merge the data for this fallback into the final array
1206 if ( $csCode === $code ) {
1207 $allData = $csData;
1208 } else {
1209 foreach ( self::ALL_KEYS as $key ) {
1210 if ( !isset( $csData[$key] ) ) {
1211 continue;
1212 }
1213
1214 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1215 if ( $allData[$key] === null || self::isMergeableKey( $key ) ) {
1216 $this->mergeItem( $key, $allData[$key], $csData[$key] );
1217 }
1218 }
1219 }
1220 }
1221
1222 if ( !isset( $allData['rtl'] ) ) {
1223 throw new RuntimeException( __METHOD__ . ': Localisation data failed validation check! ' .
1224 'Check that your languages/messages/MessagesEn.php file is intact.' );
1225 }
1226
1227 // Add cache dependencies for any referenced configs
1228 // We use the keys prefixed with 'wg' for historical reasons.
1229 $deps['wgExtensionMessagesFiles'] =
1230 new MainConfigDependency( MainConfigNames::ExtensionMessagesFiles );
1231 $deps['wgMessagesDirs'] =
1232 new MainConfigDependency( MainConfigNames::MessagesDirs );
1233 $deps['version'] = new ConstantDependency( self::class . '::VERSION' );
1234
1235 # Add dependencies to the cache entry
1236 $allData['deps'] = $deps;
1237
1238 # Replace spaces with underscores in namespace names
1239 $allData['namespaceNames'] = str_replace( ' ', '_', $allData['namespaceNames'] );
1240
1241 # And do the same for special page aliases. $page is an array.
1242 foreach ( $allData['specialPageAliases'] as &$page ) {
1243 $page = str_replace( ' ', '_', $page );
1244 }
1245 # Decouple the reference to prevent accidental damage
1246 unset( $page );
1247
1248 # If there were no plural rules, return an empty array
1249 $allData['pluralRules'] ??= [];
1250 $allData['compiledPluralRules'] ??= [];
1251 # If there were no plural rule types, return an empty array
1252 $allData['pluralRuleTypes'] ??= [];
1253
1254 # Set the list keys
1255 $allData['list'] = [];
1256 foreach ( self::SPLIT_KEYS as $key ) {
1257 $allData['list'][$key] = array_keys( $allData[$key] );
1258 }
1259 # Run hooks
1260 $unused = true; // Used to be $purgeBlobs, removed in 1.34
1261 $this->hookRunner->onLocalisationCacheRecache( $this, $code, $allData, $unused );
1262
1263 # Save to the process cache and register the items loaded
1264 $this->data[$code] = $allData;
1265 $this->loadedItems[$code] = [];
1266 $this->loadedSubitems[$code] = [];
1267 foreach ( $allData as $key => $item ) {
1268 $this->loadedItems[$code][$key] = true;
1269 }
1270
1271 # Prefix each item with its source language code before save
1272 foreach ( self::SOURCE_PREFIX_KEYS as $key ) {
1273 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1274 foreach ( $allData[$key] as $subKey => $value ) {
1275 // The source language should have been set, but to avoid Phan error and be double sure.
1276 $allData[$key][$subKey] = ( $this->sourceLanguage[$code][$key][$subKey] ?? $code ) .
1277 self::SOURCEPREFIX_SEPARATOR . $value;
1278 }
1279 }
1280
1281 # Set the preload key
1282 $allData['preload'] = $this->buildPreload( $allData );
1283
1284 # Save to the persistent cache
1285 $this->store->startWrite( $code );
1286 foreach ( $allData as $key => $value ) {
1287 if ( in_array( $key, self::SPLIT_KEYS ) ) {
1288 foreach ( $value as $subkey => $subvalue ) {
1289 $this->store->set( "$key:$subkey", $subvalue );
1290 }
1291 } else {
1292 $this->store->set( $key, $value );
1293 }
1294 }
1295 $this->store->finishWrite();
1296
1297 # Clear out the MessageBlobStore
1298 # HACK: If using a null (i.e., disabled) storage backend, we
1299 # can't write to the MessageBlobStore either
1300 if ( !$this->store instanceof LCStoreNull ) {
1301 foreach ( $this->clearStoreCallbacks as $callback ) {
1302 $callback();
1303 }
1304 }
1305 }
1306
1316 private function buildPreload( $data ) {
1317 $preload = [ 'messages' => [] ];
1318 foreach ( self::PRELOADED_KEYS as $key ) {
1319 $preload[$key] = $data[$key];
1320 }
1321
1322 foreach ( $data['preloadedMessages'] as $subkey ) {
1323 $subitem = $data['messages'][$subkey] ?? null;
1324 $preload['messages'][$subkey] = $subitem;
1325 }
1326
1327 return $preload;
1328 }
1329
1337 public function unload( $code ) {
1338 unset( $this->data[$code] );
1339 unset( $this->loadedItems[$code] );
1340 unset( $this->loadedSubitems[$code] );
1341 unset( $this->initialisedLangs[$code] );
1342 unset( $this->shallowFallbacks[$code] );
1343 unset( $this->sourceLanguage[$code] );
1344 unset( $this->coreDataLoaded[$code] );
1345
1346 foreach ( $this->shallowFallbacks as $shallowCode => $fbCode ) {
1347 if ( $fbCode === $code ) {
1348 $this->unload( $shallowCode );
1349 }
1350 }
1351 }
1352
1356 public function unloadAll() {
1357 foreach ( $this->initialisedLangs as $lang => $unused ) {
1358 $this->unload( $lang );
1359 }
1360 }
1361
1365 public function disableBackend() {
1366 $this->store = new LCStoreNull;
1367 $this->manualRecache = false;
1368 }
1369}
if(!defined( 'MEDIAWIKI')) if(ini_get('mbstring.func_overload')) if(!defined( 'MW_ENTRY_POINT')) global $IP
Environment checks.
Definition Setup.php:100
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