MediaWiki master
LocalisationCache.php
Go to the documentation of this file.
1<?php
7use CLDRPluralRuleParser\Error as CLDRPluralRuleError;
8use CLDRPluralRuleParser\Evaluator;
14use MediaWiki\Languages\LanguageNameUtils;
16use Psr\Log\LoggerInterface;
17
35 public const VERSION = 5;
36
38 private $options;
39
47 private $manualRecache;
48
57 protected $data = [];
58
64 protected $sourceLanguage = [];
65
67 private $store;
69 private $logger;
71 private $hookRunner;
73 private $clearStoreCallbacks;
75 private $langNameUtils;
76
86 private $loadedItems = [];
87
94 private $loadedSubitems = [];
95
103 private $initialisedLangs = [];
104
112 private $shallowFallbacks = [];
113
119 private $recachedLangs = [];
120
131 private $coreDataLoaded = [];
132
136 public const ALL_KEYS = [
137 'fallback', 'namespaceNames', 'bookstoreList',
138 'magicWords', 'messages', 'rtl',
139 'digitTransformTable', 'separatorTransformTable',
140 'minimumGroupingDigits', 'numberingSystem', 'fallback8bitEncoding',
141 'linkPrefixExtension', 'linkTrail', 'linkPrefixCharset',
142 'namespaceAliases', 'dateFormats', 'jsDateFormats', 'datePreferences',
143 'datePreferenceMigrationMap', 'defaultDateFormat',
144 'specialPageAliases', 'imageFiles', 'preloadedMessages',
145 'namespaceGenderAliases', 'digitGroupingPattern', 'pluralRules',
146 'pluralRuleTypes', 'compiledPluralRules', 'formalityIndex'
147 ];
148
156 private const CORE_ONLY_KEYS = [
157 'fallback', 'rtl', 'digitTransformTable', 'separatorTransformTable',
158 'minimumGroupingDigits', 'numberingSystem',
159 'fallback8bitEncoding', 'linkPrefixExtension',
160 'linkTrail', 'linkPrefixCharset', 'datePreferences',
161 'datePreferenceMigrationMap', 'defaultDateFormat', 'digitGroupingPattern',
162 'formalityIndex',
163 ];
164
173 private const ALL_EXCEPT_CORE_ONLY_KEYS = [
174 'namespaceNames', 'bookstoreList', 'magicWords', 'messages',
175 'namespaceAliases', 'dateFormats', 'jsDateFormats', 'specialPageAliases',
176 'imageFiles', 'preloadedMessages', 'namespaceGenderAliases',
177 'pluralRules', 'pluralRuleTypes', 'compiledPluralRules',
178 ];
179
181 public const ALL_ALIAS_KEYS = [ 'specialPageAliases' ];
182
187 private const MERGEABLE_MAP_KEYS = [ 'messages', 'namespaceNames',
188 'namespaceAliases', 'dateFormats', 'jsDateFormats', 'imageFiles', 'preloadedMessages'
189 ];
190
195 private const MERGEABLE_ALIAS_LIST_KEYS = [ 'specialPageAliases' ];
196
202 private const OPTIONAL_MERGE_KEYS = [ 'bookstoreList' ];
203
207 private const MAGIC_WORD_KEYS = [ 'magicWords' ];
208
212 private const SPLIT_KEYS = [ 'messages' ];
213
218 private const SOURCE_PREFIX_KEYS = [ 'messages' ];
219
223 private const SOURCEPREFIX_SEPARATOR = ':';
224
228 private const PRELOADED_KEYS = [ 'dateFormats', 'namespaceNames' ];
229
230 private const PLURAL_FILES = [
231 // Load CLDR plural rules
232 MW_INSTALL_PATH . '/languages/data/plurals.xml',
233 // Override or extend with MW-specific rules
234 MW_INSTALL_PATH . '/languages/data/plurals-mediawiki.xml',
235 ];
236
243 private static $pluralRules = null;
244
259 private static $pluralRuleTypes = null;
260
269 public static function getStoreFromConf( array $conf, $fallbackCacheDir ): LCStore {
270 $storeArg = [];
271 $storeArg['directory'] =
272 $conf['storeDirectory'] ?: $fallbackCacheDir;
273
274 if ( !empty( $conf['storeClass'] ) ) {
275 $storeClass = $conf['storeClass'];
276 } elseif ( $conf['store'] === 'files' || $conf['store'] === 'file' ||
277 ( $conf['store'] === 'detect' && $storeArg['directory'] )
278 ) {
279 $storeClass = LCStoreCDB::class;
280 } elseif ( $conf['store'] === 'db' || $conf['store'] === 'detect' ) {
281 $storeClass = LCStoreDB::class;
282 $storeArg['server'] = $conf['storeServer'] ?? [];
283 } elseif ( $conf['store'] === 'array' ) {
284 $storeClass = LCStoreStaticArray::class;
285 } else {
286 throw new ConfigException(
287 'Please set $wgLocalisationCacheConf[\'store\'] to something sensible.'
288 );
289 }
290
291 return new $storeClass( $storeArg );
292 }
293
297 public const CONSTRUCTOR_OPTIONS = [
298 // True to treat all files as expired until they are regenerated by this object.
299 'forceRecache',
300 'manualRecache',
301 MainConfigNames::ExtensionMessagesFiles,
302 MainConfigNames::MessagesDirs,
303 MainConfigNames::TranslationAliasesDirs,
304 ];
305
319 public function __construct(
320 ServiceOptions $options,
321 LCStore $store,
322 LoggerInterface $logger,
323 array $clearStoreCallbacks,
324 LanguageNameUtils $langNameUtils,
325 HookContainer $hookContainer
326 ) {
327 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
328
329 $this->options = $options;
330 $this->store = $store;
331 $this->logger = $logger;
332 $this->clearStoreCallbacks = $clearStoreCallbacks;
333 $this->langNameUtils = $langNameUtils;
334 $this->hookRunner = new HookRunner( $hookContainer );
335
336 // Keep this separate from $this->options so that it can be mutable
337 $this->manualRecache = $options->get( 'manualRecache' );
338 }
339
346 private static function isMergeableKey( string $key ): bool {
347 static $mergeableKeys;
348 $mergeableKeys ??= array_fill_keys( [
349 ...self::MERGEABLE_MAP_KEYS,
350 ...self::MERGEABLE_ALIAS_LIST_KEYS,
351 ...self::OPTIONAL_MERGE_KEYS,
352 ...self::MAGIC_WORD_KEYS,
353 ], true );
354 return isset( $mergeableKeys[$key] );
355 }
356
366 public function getItem( $code, $key ) {
367 if ( !isset( $this->loadedItems[$code][$key] ) ) {
368 $this->loadItem( $code, $key );
369 }
370
371 if ( $key === 'fallback' && isset( $this->shallowFallbacks[$code] ) ) {
372 return $this->shallowFallbacks[$code];
373 }
374
375 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
376 return $this->data[$code][$key];
377 }
378
386 public function getSubitem( $code, $key, $subkey ) {
387 if ( !isset( $this->loadedSubitems[$code][$key][$subkey] ) &&
388 !isset( $this->loadedItems[$code][$key] )
389 ) {
390 $this->loadSubitem( $code, $key, $subkey );
391 }
392
393 return $this->data[$code][$key][$subkey] ?? null;
394 }
395
405 public function getSubitemWithSource( $code, $key, $subkey ) {
406 $subitem = $this->getSubitem( $code, $key, $subkey );
407 // Undefined in the backend.
408 if ( $subitem === null ) {
409 return null;
410 }
411
412 // The source language should have been set, but to avoid a Phan error and to be double sure.
413 return [ $subitem, $this->sourceLanguage[$code][$key][$subkey] ?? $code ];
414 }
415
429 public function getSubitemList( $code, $key ) {
430 if ( in_array( $key, self::SPLIT_KEYS ) ) {
431 return $this->getSubitem( $code, 'list', $key );
432 } else {
433 $item = $this->getItem( $code, $key );
434 if ( is_array( $item ) ) {
435 return array_keys( $item );
436 } else {
437 return false;
438 }
439 }
440 }
441
448 private function loadItem( $code, $key ) {
449 if ( isset( $this->loadedItems[$code][$key] ) ) {
450 return;
451 }
452
453 if (
454 in_array( $key, self::CORE_ONLY_KEYS, true ) ||
455 // "synthetic" keys added by loadCoreData based on "fallback"
456 $key === 'fallbackSequence' ||
457 $key === 'originalFallbackSequence'
458 ) {
459 if ( $this->langNameUtils->isValidBuiltInCode( $code ) ) {
460 $this->loadCoreData( $code );
461 return;
462 }
463 }
464
465 if ( !isset( $this->initialisedLangs[$code] ) ) {
466 $this->initLanguage( $code );
467
468 // Check to see if initLanguage() loaded it for us
469 if ( isset( $this->loadedItems[$code][$key] ) ) {
470 return;
471 }
472 }
473
474 if ( isset( $this->shallowFallbacks[$code] ) ) {
475 $this->loadItem( $this->shallowFallbacks[$code], $key );
476
477 return;
478 }
479
480 if ( in_array( $key, self::SPLIT_KEYS ) ) {
481 $subkeyList = $this->getSubitem( $code, 'list', $key );
482 foreach ( $subkeyList as $subkey ) {
483 if ( isset( $this->data[$code][$key][$subkey] ) ) {
484 continue;
485 }
486 $this->loadSubitem( $code, $key, $subkey );
487 }
488 } else {
489 $this->data[$code][$key] = $this->store->get( $code, $key );
490 }
491
492 $this->loadedItems[$code][$key] = true;
493 }
494
502 private function loadSubitem( $code, $key, $subkey ) {
503 if ( !in_array( $key, self::SPLIT_KEYS ) ) {
504 $this->loadItem( $code, $key );
505
506 return;
507 }
508
509 if ( !isset( $this->initialisedLangs[$code] ) ) {
510 $this->initLanguage( $code );
511 }
512
513 // Check to see if initLanguage() loaded it for us
514 if ( isset( $this->loadedItems[$code][$key] ) ||
515 isset( $this->loadedSubitems[$code][$key][$subkey] )
516 ) {
517 return;
518 }
519
520 if ( isset( $this->shallowFallbacks[$code] ) ) {
521 $this->loadSubitem( $this->shallowFallbacks[$code], $key, $subkey );
522
523 return;
524 }
525
526 $value = $this->store->get( $code, "$key:$subkey" );
527 if ( $value !== null && in_array( $key, self::SOURCE_PREFIX_KEYS ) ) {
528 [
529 $this->sourceLanguage[$code][$key][$subkey],
530 $this->data[$code][$key][$subkey]
531 ] = explode( self::SOURCEPREFIX_SEPARATOR, $value, 2 );
532 } else {
533 $this->data[$code][$key][$subkey] = $value;
534 }
535
536 $this->loadedSubitems[$code][$key][$subkey] = true;
537 }
538
546 public function isExpired( $code ) {
547 if ( $this->options->get( 'forceRecache' ) && !isset( $this->recachedLangs[$code] ) ) {
548 $this->logger->debug( __METHOD__ . "($code): forced reload" );
549
550 return true;
551 }
552
553 $deps = $this->store->get( $code, 'deps' );
554 $keys = $this->store->get( $code, 'list' );
555 $preload = $this->store->get( $code, 'preload' );
556 // Different keys may expire separately for some stores
557 if ( $deps === null || $keys === null || $preload === null ) {
558 $this->logger->debug( __METHOD__ . "($code): cache missing, need to make one" );
559
560 return true;
561 }
562
563 foreach ( $deps as $dep ) {
564 // Because we're unserializing stuff from cache, we
565 // could receive objects of classes that don't exist
566 // anymore (e.g., uninstalled extensions)
567 // When this happens, always expire the cache
568 if ( !$dep instanceof CacheDependency || $dep->isExpired() ) {
569 $this->logger->debug( __METHOD__ . "($code): cache for $code expired due to " .
570 get_class( $dep ) );
571
572 return true;
573 }
574 }
575
576 return false;
577 }
578
584 private function initLanguage( $code ) {
585 if ( isset( $this->initialisedLangs[$code] ) ) {
586 return;
587 }
588
589 $this->initialisedLangs[$code] = true;
590
591 # If the code is of the wrong form for a Messages*.php file, do a shallow fallback
592 if ( !$this->langNameUtils->isValidBuiltInCode( $code ) ) {
593 $this->initShallowFallback( $code, 'en' );
594
595 return;
596 }
597
598 # Re-cache the data if necessary
599 if ( !$this->manualRecache && $this->isExpired( $code ) ) {
600 if ( $this->langNameUtils->isSupportedLanguage( $code ) ) {
601 $this->recache( $code );
602 } elseif ( $code === 'en' ) {
603 throw new RuntimeException( 'MessagesEn.php is missing.' );
604 } else {
605 $this->initShallowFallback( $code, 'en' );
606 }
607
608 return;
609 }
610
611 # Preload some stuff
612 $preload = $this->getItem( $code, 'preload' );
613 if ( $preload === null ) {
614 if ( $this->manualRecache ) {
615 // No Messages*.php file. Do shallow fallback to en.
616 if ( $code === 'en' ) {
617 throw new RuntimeException( 'No localisation cache found for English. ' .
618 'Please run maintenance/rebuildLocalisationCache.php.' );
619 }
620 $this->initShallowFallback( $code, 'en' );
621
622 return;
623 } else {
624 throw new RuntimeException( 'Invalid or missing localisation cache.' );
625 }
626 }
627
628 foreach ( self::SOURCE_PREFIX_KEYS as $key ) {
629 if ( !isset( $preload[$key] ) ) {
630 continue;
631 }
632 foreach ( $preload[$key] as $subkey => $value ) {
633 if ( $value !== null ) {
634 [
635 $this->sourceLanguage[$code][$key][$subkey],
636 $preload[$key][$subkey]
637 ] = explode( self::SOURCEPREFIX_SEPARATOR, $value, 2 );
638 } else {
639 $preload[$key][$subkey] = null;
640 }
641 }
642 }
643
644 if ( isset( $this->data[$code] ) ) {
645 foreach ( $preload as $key => $value ) {
646 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- see isset() above
647 $this->mergeItem( $key, $this->data[$code][$key], $value );
648 }
649 } else {
650 $this->data[$code] = $preload;
651 }
652 foreach ( $preload as $key => $item ) {
653 if ( in_array( $key, self::SPLIT_KEYS ) ) {
654 foreach ( $item as $subkey => $subitem ) {
655 $this->loadedSubitems[$code][$key][$subkey] = true;
656 }
657 } else {
658 $this->loadedItems[$code][$key] = true;
659 }
660 }
661 }
662
670 private function initShallowFallback( $primaryCode, $fallbackCode ) {
671 $this->data[$primaryCode] =& $this->data[$fallbackCode];
672 $this->loadedItems[$primaryCode] =& $this->loadedItems[$fallbackCode];
673 $this->loadedSubitems[$primaryCode] =& $this->loadedSubitems[$fallbackCode];
674 $this->shallowFallbacks[$primaryCode] = $fallbackCode;
675 $this->coreDataLoaded[$primaryCode] =& $this->coreDataLoaded[$fallbackCode];
676 }
677
685 protected function readPHPFile( $_fileName, $_fileType ) {
686 include $_fileName;
687
688 $data = [];
689 if ( $_fileType == 'core' ) {
690 foreach ( self::ALL_KEYS as $key ) {
691 // Not all keys are set in language files, so
692 // check they exist first
693 // @phan-suppress-next-line MediaWikiNoIssetIfDefined May be set in the included file
694 if ( isset( $$key ) ) {
695 $data[$key] = $$key;
696 }
697 }
698 } elseif ( $_fileType == 'extension' ) {
699 foreach ( self::ALL_EXCEPT_CORE_ONLY_KEYS as $key ) {
700 // @phan-suppress-next-line MediaWikiNoIssetIfDefined May be set in the included file
701 if ( isset( $$key ) ) {
702 $data[$key] = $$key;
703 }
704 }
705 } elseif ( $_fileType == 'aliases' ) {
706 // @phan-suppress-next-line PhanImpossibleCondition May be set in the included file
707 if ( isset( $aliases ) ) {
708 $data['aliases'] = $aliases;
709 }
710 } else {
711 throw new InvalidArgumentException( __METHOD__ . ": Invalid file type: $_fileType" );
712 }
713
714 return $data;
715 }
716
723 private function readJSONFile( $fileName ) {
724 if ( !is_readable( $fileName ) ) {
725 return [];
726 }
727
728 $json = file_get_contents( $fileName );
729 if ( $json === false ) {
730 return [];
731 }
732
733 $data = FormatJson::decode( $json, true );
734 if ( $data === null ) {
735 throw new RuntimeException( __METHOD__ . ": Invalid JSON file: $fileName" );
736 }
737
738 // Remove keys starting with '@'; they are reserved for metadata and non-message data
739 foreach ( $data as $key => $unused ) {
740 if ( $key === '' || $key[0] === '@' ) {
741 unset( $data[$key] );
742 }
743 }
744
745 return $data;
746 }
747
755 private function getCompiledPluralRules( $code ) {
756 $rules = $this->getPluralRules( $code );
757 if ( $rules === null ) {
758 return null;
759 }
760 try {
761 $compiledRules = Evaluator::compile( $rules );
762 } catch ( CLDRPluralRuleError $e ) {
763 $this->logger->debug( $e->getMessage() );
764
765 return [];
766 }
767
768 return $compiledRules;
769 }
770
780 private function getPluralRules( $code ) {
781 if ( self::$pluralRules === null ) {
782 self::loadPluralFiles();
783 }
784 return self::$pluralRules[$code] ?? null;
785 }
786
796 private function getPluralRuleTypes( $code ) {
797 if ( self::$pluralRuleTypes === null ) {
798 self::loadPluralFiles();
799 }
800 return self::$pluralRuleTypes[$code] ?? null;
801 }
802
806 private static function loadPluralFiles() {
807 foreach ( self::PLURAL_FILES as $fileName ) {
808 self::loadPluralFile( $fileName );
809 }
810 }
811
818 private static function loadPluralFile( $fileName ) {
819 // Use file_get_contents instead of DOMDocument::load (T58439)
820 $xml = file_get_contents( $fileName );
821 if ( !$xml ) {
822 throw new RuntimeException( "Unable to read plurals file $fileName" );
823 }
824 $doc = new DOMDocument;
825 $doc->loadXML( $xml );
826 $rulesets = $doc->getElementsByTagName( "pluralRules" );
827 foreach ( $rulesets as $ruleset ) {
828 $codes = $ruleset->getAttribute( 'locales' );
829 $rules = [];
830 $ruleTypes = [];
831 $ruleElements = $ruleset->getElementsByTagName( "pluralRule" );
832 foreach ( $ruleElements as $elt ) {
833 $ruleType = $elt->getAttribute( 'count' );
834 if ( $ruleType === 'other' ) {
835 // Don't record "other" rules, which have an empty condition
836 continue;
837 }
838 $rules[] = $elt->nodeValue;
839 $ruleTypes[] = $ruleType;
840 }
841 foreach ( explode( ' ', $codes ) as $code ) {
842 self::$pluralRules[$code] = $rules;
843 self::$pluralRuleTypes[$code] = $ruleTypes;
844 }
845 }
846 }
847
856 private function readSourceFilesAndRegisterDeps( $code, &$deps ) {
857 // This reads in the PHP i18n file with non-messages l10n data
858 $fileName = $this->langNameUtils->getMessagesFileName( $code );
859 if ( !is_file( $fileName ) ) {
860 $data = [];
861 } else {
862 $deps[] = new FileDependency( $fileName );
863 $data = $this->readPHPFile( $fileName, 'core' );
864 }
865
866 return $data;
867 }
868
877 private function readPluralFilesAndRegisterDeps( $code, &$deps ) {
878 $data = [
879 // Load CLDR plural rules for JavaScript
880 'pluralRules' => $this->getPluralRules( $code ),
881 // And for PHP
882 'compiledPluralRules' => $this->getCompiledPluralRules( $code ),
883 // Load plural rule types
884 'pluralRuleTypes' => $this->getPluralRuleTypes( $code ),
885 ];
886
887 foreach ( self::PLURAL_FILES as $fileName ) {
888 $deps[] = new FileDependency( $fileName );
889 }
890
891 return $data;
892 }
893
902 private function mergeItem( $key, &$value, $fallbackValue ) {
903 if ( $value !== null ) {
904 if ( $fallbackValue !== null ) {
905 if ( in_array( $key, self::MERGEABLE_MAP_KEYS ) ) {
906 $value += $fallbackValue;
907 } elseif ( in_array( $key, self::MERGEABLE_ALIAS_LIST_KEYS ) ) {
908 $value = array_merge_recursive( $value, $fallbackValue );
909 } elseif ( in_array( $key, self::OPTIONAL_MERGE_KEYS ) ) {
910 if ( !empty( $value['inherit'] ) ) {
911 $value = array_merge( $fallbackValue, $value );
912 }
913
914 unset( $value['inherit'] );
915 } elseif ( in_array( $key, self::MAGIC_WORD_KEYS ) ) {
916 $this->mergeMagicWords( $value, $fallbackValue );
917 }
918 }
919 } else {
920 $value = $fallbackValue;
921 }
922 }
923
924 private function mergeMagicWords( array &$value, array $fallbackValue ): void {
925 foreach ( $fallbackValue as $magicName => $fallbackInfo ) {
926 if ( !isset( $value[$magicName] ) ) {
927 $value[$magicName] = $fallbackInfo;
928 } else {
929 $value[$magicName] = [
930 $fallbackInfo[0],
931 ...array_unique( [
932 // First value is 1 if the magic word is case-sensitive, 0 if not
933 ...array_slice( $value[$magicName], 1 ),
934 ...array_slice( $fallbackInfo, 1 ),
935 ] )
936 ];
937 }
938 }
939 }
940
948 public function getMessagesDirs() {
949 global $IP;
950
951 return [
952 'core' => "$IP/languages/i18n",
953 'botpasswords' => "$IP/languages/i18n/botpasswords",
954 'codex' => "$IP/languages/i18n/codex",
955 'datetime' => "$IP/languages/i18n/datetime",
956 'exif' => "$IP/languages/i18n/exif",
957 'languageconverter' => "$IP/languages/i18n/languageconverter",
958 'interwiki' => "$IP/languages/i18n/interwiki",
959 'preferences' => "$IP/languages/i18n/preferences",
960
961 'nontranslatable' => "$IP/languages/i18n/nontranslatable",
962
963 'api' => "$IP/includes/Api/i18n",
964 'rest' => "$IP/includes/Rest/i18n",
965 'oojs-ui' => "$IP/resources/lib/ooui/i18n",
966 'paramvalidator' => "$IP/includes/libs/ParamValidator/i18n",
967 'installer' => "$IP/includes/Installer/i18n",
968 ] + $this->options->get( MainConfigNames::MessagesDirs );
969 }
970
981 private function loadCoreData( string $code ) {
982 if ( !$code ) {
983 throw new InvalidArgumentException( "Invalid language code requested" );
984 }
985 if ( $this->coreDataLoaded[$code] ?? false ) {
986 return;
987 }
988
989 $coreData = array_fill_keys( self::CORE_ONLY_KEYS, null );
990 $deps = [];
991
992 # Load the primary localisation from the source file
993 $data = $this->readSourceFilesAndRegisterDeps( $code, $deps );
994 $this->logger->debug( __METHOD__ . ": got localisation for $code from source" );
995
996 # Merge primary localisation
997 foreach ( $data as $key => $value ) {
998 $this->mergeItem( $key, $coreData[ $key ], $value );
999 }
1000
1001 # Fill in the fallback if it's not there already
1002 // @phan-suppress-next-line PhanSuspiciousValueComparison
1003 if ( ( $coreData['fallback'] === null || $coreData['fallback'] === false ) && $code === 'en' ) {
1004 $coreData['fallback'] = false;
1005 $coreData['originalFallbackSequence'] = $coreData['fallbackSequence'] = [];
1006 } else {
1007 if ( $coreData['fallback'] !== null ) {
1008 $coreData['fallbackSequence'] = array_map( 'trim', explode( ',', $coreData['fallback'] ) );
1009 } else {
1010 $coreData['fallbackSequence'] = [];
1011 }
1012 $len = count( $coreData['fallbackSequence'] );
1013
1014 # Before we add the 'en' fallback for messages, keep a copy of
1015 # the original fallback sequence
1016 $coreData['originalFallbackSequence'] = $coreData['fallbackSequence'];
1017
1018 # Ensure that the sequence ends at 'en' for messages
1019 if ( !$len || $coreData['fallbackSequence'][$len - 1] !== 'en' ) {
1020 $coreData['fallbackSequence'][] = 'en';
1021 }
1022 }
1023
1024 foreach ( $coreData['fallbackSequence'] as $fbCode ) {
1025 // load core fallback data
1026 $fbData = $this->readSourceFilesAndRegisterDeps( $fbCode, $deps );
1027 foreach ( self::CORE_ONLY_KEYS as $key ) {
1028 // core-only keys are not mergeable, only set if not present in core data yet
1029 if ( isset( $fbData[$key] ) && !isset( $coreData[$key] ) ) {
1030 $coreData[$key] = $fbData[$key];
1031 }
1032 }
1033 }
1034
1035 $coreData['deps'] = $deps;
1036 foreach ( $coreData as $key => $item ) {
1037 $this->data[$code][$key] ??= null;
1038 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- we just set a default null
1039 $this->mergeItem( $key, $this->data[$code][$key], $item );
1040 if (
1041 in_array( $key, self::CORE_ONLY_KEYS, true ) ||
1042 // "synthetic" keys based on "fallback" (see above)
1043 $key === 'fallbackSequence' ||
1044 $key === 'originalFallbackSequence'
1045 ) {
1046 // only mark core-only keys as loaded;
1047 // we may have loaded additional ones from the source file,
1048 // but they are not fully loaded yet, since recache()
1049 // may have to merge in additional values from fallback languages
1050 $this->loadedItems[$code][$key] = true;
1051 }
1052 }
1053
1054 $this->coreDataLoaded[$code] = true;
1055 }
1056
1063 public function recache( $code ) {
1064 if ( !$code ) {
1065 throw new InvalidArgumentException( "Invalid language code requested" );
1066 }
1067 $this->recachedLangs[ $code ] = true;
1068
1069 # Initial values
1070 $initialData = array_fill_keys( self::ALL_KEYS, null );
1071 $this->data[$code] = [];
1072 $this->loadedItems[$code] = [];
1073 $this->loadedSubitems[$code] = [];
1074 $this->coreDataLoaded[$code] = false;
1075 $this->loadCoreData( $code );
1076 $coreData = $this->data[$code];
1077 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- guaranteed by loadCoreData()
1078 $deps = $coreData['deps'];
1079 $coreData += $this->readPluralFilesAndRegisterDeps( $code, $deps );
1080
1081 $codeSequence = [ $code, ...$coreData['fallbackSequence'] ];
1082 $messageDirs = $this->getMessagesDirs();
1083 $translationAliasesDirs = $this->options->get( MainConfigNames::TranslationAliasesDirs );
1084
1085 # Load non-JSON localisation data for extensions
1086 $extensionData = array_fill_keys( $codeSequence, $initialData );
1087 foreach ( $this->options->get( MainConfigNames::ExtensionMessagesFiles ) as $extension => $fileName ) {
1088 if ( isset( $messageDirs[$extension] ) || isset( $translationAliasesDirs[$extension] ) ) {
1089 # This extension has JSON message data; skip the PHP shim
1090 continue;
1091 }
1092
1093 $data = $this->readPHPFile( $fileName, 'extension' );
1094 $used = false;
1095
1096 foreach ( $data as $key => $item ) {
1097 foreach ( $codeSequence as $csCode ) {
1098 if ( isset( $item[$csCode] ) ) {
1099 // Keep the behaviour the same as for json messages.
1100 // TODO: Consider deprecating using a PHP file for messages.
1101 if ( in_array( $key, self::SOURCE_PREFIX_KEYS ) ) {
1102 foreach ( $item[$csCode] as $subkey => $_ ) {
1103 $this->sourceLanguage[$code][$key][$subkey] ??= $csCode;
1104 }
1105 }
1106 $this->mergeItem( $key, $extensionData[$csCode][$key], $item[$csCode] );
1107 $used = true;
1108 }
1109 }
1110 }
1111
1112 if ( $used ) {
1113 $deps[] = new FileDependency( $fileName );
1114 }
1115 }
1116
1117 # Load the localisation data for each fallback, then merge it into the full array
1118 $allData = $initialData;
1119 foreach ( $codeSequence as $csCode ) {
1120 $csData = $initialData;
1121
1122 # Load core messages and the extension localisations.
1123 foreach ( $messageDirs as $dirs ) {
1124 foreach ( (array)$dirs as $dir ) {
1125 $fileName = "$dir/$csCode.json";
1126 $messages = $this->readJSONFile( $fileName );
1127
1128 foreach ( $messages as $subkey => $_ ) {
1129 $this->sourceLanguage[$code]['messages'][$subkey] ??= $csCode;
1130 }
1131 $this->mergeItem( 'messages', $csData['messages'], $messages );
1132
1133 $deps[] = new FileDependency( $fileName );
1134 }
1135 }
1136
1137 foreach ( $translationAliasesDirs as $dirs ) {
1138 foreach ( (array)$dirs as $dir ) {
1139 $fileName = "$dir/$csCode.json";
1140 $data = $this->readJSONFile( $fileName );
1141
1142 foreach ( $data as $key => $item ) {
1143 // We allow the key in the JSON to be specified in PascalCase similar to key definitions in
1144 // extension.json, but eventually they are stored in camelCase
1145 $normalizedKey = lcfirst( $key );
1146
1147 if ( $normalizedKey === '@metadata' ) {
1148 // Don't store @metadata information in extension data.
1149 continue;
1150 }
1151
1152 if ( !in_array( $normalizedKey, self::ALL_ALIAS_KEYS ) ) {
1153 throw new UnexpectedValueException(
1154 "Invalid key: \"$key\" for " . MainConfigNames::TranslationAliasesDirs . ". " .
1155 'Valid keys: ' . implode( ', ', self::ALL_ALIAS_KEYS )
1156 );
1157 }
1158
1159 $this->mergeItem( $normalizedKey, $extensionData[$csCode][$normalizedKey], $item );
1160 }
1161
1162 $deps[] = new FileDependency( $fileName );
1163 }
1164 }
1165
1166 # Merge non-JSON extension data
1167 if ( isset( $extensionData[$csCode] ) ) {
1168 foreach ( $extensionData[$csCode] as $key => $item ) {
1169 $this->mergeItem( $key, $csData[$key], $item );
1170 }
1171 }
1172
1173 if ( $csCode === $code ) {
1174 # Merge core data into extension data
1175 foreach ( $coreData as $key => $item ) {
1176 $this->mergeItem( $key, $csData[$key], $item );
1177 }
1178 } else {
1179 # Load the secondary localisation from the source file to
1180 # avoid infinite cycles on cyclic fallbacks
1181 $fbData = $this->readSourceFilesAndRegisterDeps( $csCode, $deps );
1182 $fbData += $this->readPluralFilesAndRegisterDeps( $csCode, $deps );
1183 # Only merge the keys that make sense to merge
1184 foreach ( self::ALL_KEYS as $key ) {
1185 if ( !isset( $fbData[ $key ] ) ) {
1186 continue;
1187 }
1188
1189 if ( !isset( $coreData[ $key ] ) || self::isMergeableKey( $key ) ) {
1190 $this->mergeItem( $key, $csData[ $key ], $fbData[ $key ] );
1191 }
1192 }
1193 }
1194
1195 # Allow extensions an opportunity to adjust the data for this fallback
1196 $this->hookRunner->onLocalisationCacheRecacheFallback( $this, $csCode, $csData );
1197
1198 # Merge the data for this fallback into the final array
1199 if ( $csCode === $code ) {
1200 $allData = $csData;
1201 } else {
1202 foreach ( self::ALL_KEYS as $key ) {
1203 if ( !isset( $csData[$key] ) ) {
1204 continue;
1205 }
1206
1207 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1208 if ( $allData[$key] === null || self::isMergeableKey( $key ) ) {
1209 $this->mergeItem( $key, $allData[$key], $csData[$key] );
1210 }
1211 }
1212 }
1213 }
1214
1215 if ( !isset( $allData['rtl'] ) ) {
1216 throw new RuntimeException( __METHOD__ . ': Localisation data failed validation check! ' .
1217 'Check that your languages/messages/MessagesEn.php file is intact.' );
1218 }
1219
1220 // Add cache dependencies for any referenced configs
1221 // We use the keys prefixed with 'wg' for historical reasons.
1222 $deps['wgExtensionMessagesFiles'] =
1223 new MainConfigDependency( MainConfigNames::ExtensionMessagesFiles );
1224 $deps['wgMessagesDirs'] =
1225 new MainConfigDependency( MainConfigNames::MessagesDirs );
1226 $deps['version'] = new ConstantDependency( self::class . '::VERSION' );
1227
1228 # Add dependencies to the cache entry
1229 $allData['deps'] = $deps;
1230
1231 # Replace spaces with underscores in namespace names
1232 $allData['namespaceNames'] = str_replace( ' ', '_', $allData['namespaceNames'] );
1233
1234 # And do the same for special page aliases. $page is an array.
1235 foreach ( $allData['specialPageAliases'] as &$page ) {
1236 $page = str_replace( ' ', '_', $page );
1237 }
1238 # Decouple the reference to prevent accidental damage
1239 unset( $page );
1240
1241 # If there were no plural rules, return an empty array
1242 $allData['pluralRules'] ??= [];
1243 $allData['compiledPluralRules'] ??= [];
1244 # If there were no plural rule types, return an empty array
1245 $allData['pluralRuleTypes'] ??= [];
1246
1247 # Set the list keys
1248 $allData['list'] = [];
1249 foreach ( self::SPLIT_KEYS as $key ) {
1250 $allData['list'][$key] = array_keys( $allData[$key] );
1251 }
1252 # Run hooks
1253 $unused = true; // Used to be $purgeBlobs, removed in 1.34
1254 $this->hookRunner->onLocalisationCacheRecache( $this, $code, $allData, $unused );
1255
1256 # Save to the process cache and register the items loaded
1257 $this->data[$code] = $allData;
1258 $this->loadedItems[$code] = [];
1259 $this->loadedSubitems[$code] = [];
1260 foreach ( $allData as $key => $item ) {
1261 $this->loadedItems[$code][$key] = true;
1262 }
1263
1264 # Prefix each item with its source language code before save
1265 foreach ( self::SOURCE_PREFIX_KEYS as $key ) {
1266 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1267 foreach ( $allData[$key] as $subKey => $value ) {
1268 // The source language should have been set, but to avoid Phan error and be double sure.
1269 $allData[$key][$subKey] = ( $this->sourceLanguage[$code][$key][$subKey] ?? $code ) .
1270 self::SOURCEPREFIX_SEPARATOR . $value;
1271 }
1272 }
1273
1274 # Set the preload key
1275 $allData['preload'] = $this->buildPreload( $allData );
1276
1277 # Save to the persistent cache
1278 $this->store->startWrite( $code );
1279 foreach ( $allData as $key => $value ) {
1280 if ( in_array( $key, self::SPLIT_KEYS ) ) {
1281 foreach ( $value as $subkey => $subvalue ) {
1282 $this->store->set( "$key:$subkey", $subvalue );
1283 }
1284 } else {
1285 $this->store->set( $key, $value );
1286 }
1287 }
1288 $this->store->finishWrite();
1289
1290 # Clear out the MessageBlobStore
1291 # HACK: If using a null (i.e., disabled) storage backend, we
1292 # can't write to the MessageBlobStore either
1293 if ( !$this->store instanceof LCStoreNull ) {
1294 foreach ( $this->clearStoreCallbacks as $callback ) {
1295 $callback();
1296 }
1297 }
1298 }
1299
1309 private function buildPreload( $data ) {
1310 $preload = [ 'messages' => [] ];
1311 foreach ( self::PRELOADED_KEYS as $key ) {
1312 $preload[$key] = $data[$key];
1313 }
1314
1315 foreach ( $data['preloadedMessages'] as $subkey ) {
1316 $subitem = $data['messages'][$subkey] ?? null;
1317 $preload['messages'][$subkey] = $subitem;
1318 }
1319
1320 return $preload;
1321 }
1322
1330 public function unload( $code ) {
1331 unset( $this->data[$code] );
1332 unset( $this->loadedItems[$code] );
1333 unset( $this->loadedSubitems[$code] );
1334 unset( $this->initialisedLangs[$code] );
1335 unset( $this->shallowFallbacks[$code] );
1336 unset( $this->sourceLanguage[$code] );
1337 unset( $this->coreDataLoaded[$code] );
1338
1339 foreach ( $this->shallowFallbacks as $shallowCode => $fbCode ) {
1340 if ( $fbCode === $code ) {
1341 $this->unload( $shallowCode );
1342 }
1343 }
1344 }
1345
1349 public function unloadAll() {
1350 foreach ( $this->initialisedLangs as $lang => $unused ) {
1351 $this->unload( $lang );
1352 }
1353 }
1354
1358 public function disableBackend() {
1359 $this->store = new LCStoreNull;
1360 $this->manualRecache = false;
1361 }
1362}
if(!defined('MEDIAWIKI')) if(!defined( 'MW_ENTRY_POINT')) global $IP
Environment checks.
Definition Setup.php:90
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 class containing constants representing the names of configuration variables.
Interface for the persistence layer of LocalisationCache.
Definition LCStore.php:26