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