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
194 private const MERGEABLE_MAP_KEYS = [ 'messages', 'namespaceNames',
195 'namespaceAliases', 'dateFormats', 'imageFiles', 'preloadedMessages'
196 ];
197
202 private const MERGEABLE_ALIAS_LIST_KEYS = [ 'specialPageAliases' ];
203
209 private const OPTIONAL_MERGE_KEYS = [ 'bookstoreList' ];
210
214 private const MAGIC_WORD_KEYS = [ 'magicWords' ];
215
219 private const SPLIT_KEYS = [ 'messages' ];
220
225 private const SOURCE_PREFIX_KEYS = [ 'messages' ];
226
230 private const SOURCEPREFIX_SEPARATOR = ':';
231
235 private const PRELOADED_KEYS = [ 'dateFormats', 'namespaceNames' ];
236
237 private const PLURAL_FILES = [
238 // Load CLDR plural rules
239 MW_INSTALL_PATH . '/languages/data/plurals.xml',
240 // Override or extend with MW-specific rules
241 MW_INSTALL_PATH . '/languages/data/plurals-mediawiki.xml',
242 ];
243
250 private static $pluralRules = null;
251
266 private static $pluralRuleTypes = null;
267
276 public static function getStoreFromConf( array $conf, $fallbackCacheDir ): LCStore {
277 $storeArg = [];
278 $storeArg['directory'] =
279 $conf['storeDirectory'] ?: $fallbackCacheDir;
280
281 if ( !empty( $conf['storeClass'] ) ) {
282 $storeClass = $conf['storeClass'];
283 } elseif ( $conf['store'] === 'files' || $conf['store'] === 'file' ||
284 ( $conf['store'] === 'detect' && $storeArg['directory'] )
285 ) {
286 $storeClass = LCStoreCDB::class;
287 } elseif ( $conf['store'] === 'db' || $conf['store'] === 'detect' ) {
288 $storeClass = LCStoreDB::class;
289 $storeArg['server'] = $conf['storeServer'] ?? [];
290 } elseif ( $conf['store'] === 'array' ) {
291 $storeClass = LCStoreStaticArray::class;
292 } else {
293 throw new ConfigException(
294 'Please set $wgLocalisationCacheConf[\'store\'] to something sensible.'
295 );
296 }
297
298 return new $storeClass( $storeArg );
299 }
300
304 public const CONSTRUCTOR_OPTIONS = [
305 // True to treat all files as expired until they are regenerated by this object.
306 'forceRecache',
307 'manualRecache',
308 MainConfigNames::ExtensionMessagesFiles,
309 MainConfigNames::MessagesDirs,
310 ];
311
325 public function __construct(
326 ServiceOptions $options,
327 LCStore $store,
328 LoggerInterface $logger,
329 array $clearStoreCallbacks,
330 LanguageNameUtils $langNameUtils,
331 HookContainer $hookContainer
332 ) {
333 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
334
335 $this->options = $options;
336 $this->store = $store;
337 $this->logger = $logger;
338 $this->clearStoreCallbacks = $clearStoreCallbacks;
339 $this->langNameUtils = $langNameUtils;
340 $this->hookRunner = new HookRunner( $hookContainer );
341
342 // Keep this separate from $this->options so that it can be mutable
343 $this->manualRecache = $options->get( 'manualRecache' );
344 }
345
352 private static function isMergeableKey( string $key ): bool {
353 static $mergeableKeys;
354 $mergeableKeys ??= array_fill_keys( [
355 ...self::MERGEABLE_MAP_KEYS,
356 ...self::MERGEABLE_ALIAS_LIST_KEYS,
357 ...self::OPTIONAL_MERGE_KEYS,
358 ...self::MAGIC_WORD_KEYS,
359 ], true );
360 return isset( $mergeableKeys[$key] );
361 }
362
372 public function getItem( $code, $key ) {
373 if ( !isset( $this->loadedItems[$code][$key] ) ) {
374 $this->loadItem( $code, $key );
375 }
376
377 if ( $key === 'fallback' && isset( $this->shallowFallbacks[$code] ) ) {
378 return $this->shallowFallbacks[$code];
379 }
380
381 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
382 return $this->data[$code][$key];
383 }
384
392 public function getSubitem( $code, $key, $subkey ) {
393 if ( !isset( $this->loadedSubitems[$code][$key][$subkey] ) &&
394 !isset( $this->loadedItems[$code][$key] )
395 ) {
396 $this->loadSubitem( $code, $key, $subkey );
397 }
398
399 return $this->data[$code][$key][$subkey] ?? null;
400 }
401
411 public function getSubitemWithSource( $code, $key, $subkey ) {
412 $subitem = $this->getSubitem( $code, $key, $subkey );
413 // Undefined in the backend.
414 if ( $subitem === null ) {
415 return null;
416 }
417
418 // The source language should have been set, but to avoid a Phan error and to be double sure.
419 return [ $subitem, $this->sourceLanguage[$code][$key][$subkey] ?? $code ];
420 }
421
435 public function getSubitemList( $code, $key ) {
436 if ( in_array( $key, self::SPLIT_KEYS ) ) {
437 return $this->getSubitem( $code, 'list', $key );
438 } else {
439 $item = $this->getItem( $code, $key );
440 if ( is_array( $item ) ) {
441 return array_keys( $item );
442 } else {
443 return false;
444 }
445 }
446 }
447
454 private function loadItem( $code, $key ) {
455 if ( isset( $this->loadedItems[$code][$key] ) ) {
456 return;
457 }
458
459 if (
460 in_array( $key, self::CORE_ONLY_KEYS, true ) ||
461 // "synthetic" keys added by loadCoreData based on "fallback"
462 $key === 'fallbackSequence' ||
463 $key === 'originalFallbackSequence'
464 ) {
465 if ( $this->langNameUtils->isValidBuiltInCode( $code ) ) {
466 $this->loadCoreData( $code );
467 return;
468 }
469 }
470
471 if ( !isset( $this->initialisedLangs[$code] ) ) {
472 $this->initLanguage( $code );
473
474 // Check to see if initLanguage() loaded it for us
475 if ( isset( $this->loadedItems[$code][$key] ) ) {
476 return;
477 }
478 }
479
480 if ( isset( $this->shallowFallbacks[$code] ) ) {
481 $this->loadItem( $this->shallowFallbacks[$code], $key );
482
483 return;
484 }
485
486 if ( in_array( $key, self::SPLIT_KEYS ) ) {
487 $subkeyList = $this->getSubitem( $code, 'list', $key );
488 foreach ( $subkeyList as $subkey ) {
489 if ( isset( $this->data[$code][$key][$subkey] ) ) {
490 continue;
491 }
492 $this->loadSubitem( $code, $key, $subkey );
493 }
494 } else {
495 $this->data[$code][$key] = $this->store->get( $code, $key );
496 }
497
498 $this->loadedItems[$code][$key] = true;
499 }
500
508 private function loadSubitem( $code, $key, $subkey ) {
509 if ( !in_array( $key, self::SPLIT_KEYS ) ) {
510 $this->loadItem( $code, $key );
511
512 return;
513 }
514
515 if ( !isset( $this->initialisedLangs[$code] ) ) {
516 $this->initLanguage( $code );
517 }
518
519 // Check to see if initLanguage() loaded it for us
520 if ( isset( $this->loadedItems[$code][$key] ) ||
521 isset( $this->loadedSubitems[$code][$key][$subkey] )
522 ) {
523 return;
524 }
525
526 if ( isset( $this->shallowFallbacks[$code] ) ) {
527 $this->loadSubitem( $this->shallowFallbacks[$code], $key, $subkey );
528
529 return;
530 }
531
532 $value = $this->store->get( $code, "$key:$subkey" );
533 if ( $value !== null && in_array( $key, self::SOURCE_PREFIX_KEYS ) ) {
534 [
535 $this->sourceLanguage[$code][$key][$subkey],
536 $this->data[$code][$key][$subkey]
537 ] = explode( self::SOURCEPREFIX_SEPARATOR, $value, 2 );
538 } else {
539 $this->data[$code][$key][$subkey] = $value;
540 }
541
542 $this->loadedSubitems[$code][$key][$subkey] = true;
543 }
544
552 public function isExpired( $code ) {
553 if ( $this->options->get( 'forceRecache' ) && !isset( $this->recachedLangs[$code] ) ) {
554 $this->logger->debug( __METHOD__ . "($code): forced reload" );
555
556 return true;
557 }
558
559 $deps = $this->store->get( $code, 'deps' );
560 $keys = $this->store->get( $code, 'list' );
561 $preload = $this->store->get( $code, 'preload' );
562 // Different keys may expire separately for some stores
563 if ( $deps === null || $keys === null || $preload === null ) {
564 $this->logger->debug( __METHOD__ . "($code): cache missing, need to make one" );
565
566 return true;
567 }
568
569 foreach ( $deps as $dep ) {
570 // Because we're unserializing stuff from cache, we
571 // could receive objects of classes that don't exist
572 // anymore (e.g., uninstalled extensions)
573 // When this happens, always expire the cache
574 if ( !$dep instanceof CacheDependency || $dep->isExpired() ) {
575 $this->logger->debug( __METHOD__ . "($code): cache for $code expired due to " .
576 get_class( $dep ) );
577
578 return true;
579 }
580 }
581
582 return false;
583 }
584
590 private function initLanguage( $code ) {
591 if ( isset( $this->initialisedLangs[$code] ) ) {
592 return;
593 }
594
595 $this->initialisedLangs[$code] = true;
596
597 # If the code is of the wrong form for a Messages*.php file, do a shallow fallback
598 if ( !$this->langNameUtils->isValidBuiltInCode( $code ) ) {
599 $this->initShallowFallback( $code, 'en' );
600
601 return;
602 }
603
604 # Re-cache the data if necessary
605 if ( !$this->manualRecache && $this->isExpired( $code ) ) {
606 if ( $this->langNameUtils->isSupportedLanguage( $code ) ) {
607 $this->recache( $code );
608 } elseif ( $code === 'en' ) {
609 throw new RuntimeException( 'MessagesEn.php is missing.' );
610 } else {
611 $this->initShallowFallback( $code, 'en' );
612 }
613
614 return;
615 }
616
617 # Preload some stuff
618 $preload = $this->getItem( $code, 'preload' );
619 if ( $preload === null ) {
620 if ( $this->manualRecache ) {
621 // No Messages*.php file. Do shallow fallback to en.
622 if ( $code === 'en' ) {
623 throw new RuntimeException( 'No localisation cache found for English. ' .
624 'Please run maintenance/rebuildLocalisationCache.php.' );
625 }
626 $this->initShallowFallback( $code, 'en' );
627
628 return;
629 } else {
630 throw new RuntimeException( 'Invalid or missing localisation cache.' );
631 }
632 }
633
634 foreach ( self::SOURCE_PREFIX_KEYS as $key ) {
635 if ( !isset( $preload[$key] ) ) {
636 continue;
637 }
638 foreach ( $preload[$key] as $subkey => $value ) {
639 if ( $value !== null ) {
640 [
641 $this->sourceLanguage[$code][$key][$subkey],
642 $preload[$key][$subkey]
643 ] = explode( self::SOURCEPREFIX_SEPARATOR, $value, 2 );
644 } else {
645 $preload[$key][$subkey] = null;
646 }
647 }
648 }
649
650 if ( isset( $this->data[$code] ) ) {
651 foreach ( $preload as $key => $value ) {
652 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- see isset() above
653 $this->mergeItem( $key, $this->data[$code][$key], $value );
654 }
655 } else {
656 $this->data[$code] = $preload;
657 }
658 foreach ( $preload as $key => $item ) {
659 if ( in_array( $key, self::SPLIT_KEYS ) ) {
660 foreach ( $item as $subkey => $subitem ) {
661 $this->loadedSubitems[$code][$key][$subkey] = true;
662 }
663 } else {
664 $this->loadedItems[$code][$key] = true;
665 }
666 }
667 }
668
676 private function initShallowFallback( $primaryCode, $fallbackCode ) {
677 $this->data[$primaryCode] =& $this->data[$fallbackCode];
678 $this->loadedItems[$primaryCode] =& $this->loadedItems[$fallbackCode];
679 $this->loadedSubitems[$primaryCode] =& $this->loadedSubitems[$fallbackCode];
680 $this->shallowFallbacks[$primaryCode] = $fallbackCode;
681 $this->coreDataLoaded[$primaryCode] =& $this->coreDataLoaded[$fallbackCode];
682 }
683
691 protected function readPHPFile( $_fileName, $_fileType ) {
692 include $_fileName;
693
694 $data = [];
695 if ( $_fileType == 'core' ) {
696 foreach ( self::ALL_KEYS as $key ) {
697 // Not all keys are set in language files, so
698 // check they exist first
699 if ( isset( $$key ) ) {
700 $data[$key] = $$key;
701 }
702 }
703 } elseif ( $_fileType == 'extension' ) {
704 foreach ( self::ALL_EXCEPT_CORE_ONLY_KEYS as $key ) {
705 if ( isset( $$key ) ) {
706 $data[$key] = $$key;
707 }
708 }
709 } elseif ( $_fileType == 'aliases' ) {
710 // @phan-suppress-next-line PhanImpossibleCondition May be set in the included file
711 if ( isset( $aliases ) ) {
712 $data['aliases'] = $aliases;
713 }
714 } else {
715 throw new InvalidArgumentException( __METHOD__ . ": Invalid file type: $_fileType" );
716 }
717
718 return $data;
719 }
720
727 private function readJSONFile( $fileName ) {
728 if ( !is_readable( $fileName ) ) {
729 return [];
730 }
731
732 $json = file_get_contents( $fileName );
733 if ( $json === false ) {
734 return [];
735 }
736
737 $data = FormatJson::decode( $json, true );
738 if ( $data === null ) {
739 throw new RuntimeException( __METHOD__ . ": Invalid JSON file: $fileName" );
740 }
741
742 // Remove keys starting with '@'; they are reserved for metadata and non-message data
743 foreach ( $data as $key => $unused ) {
744 if ( $key === '' || $key[0] === '@' ) {
745 unset( $data[$key] );
746 }
747 }
748
749 return $data;
750 }
751
759 private function getCompiledPluralRules( $code ) {
760 $rules = $this->getPluralRules( $code );
761 if ( $rules === null ) {
762 return null;
763 }
764 try {
765 $compiledRules = Evaluator::compile( $rules );
766 } catch ( CLDRPluralRuleError $e ) {
767 $this->logger->debug( $e->getMessage() );
768
769 return [];
770 }
771
772 return $compiledRules;
773 }
774
784 private function getPluralRules( $code ) {
785 if ( self::$pluralRules === null ) {
786 self::loadPluralFiles();
787 }
788 return self::$pluralRules[$code] ?? null;
789 }
790
800 private function getPluralRuleTypes( $code ) {
801 if ( self::$pluralRuleTypes === null ) {
802 self::loadPluralFiles();
803 }
804 return self::$pluralRuleTypes[$code] ?? null;
805 }
806
810 private static function loadPluralFiles() {
811 foreach ( self::PLURAL_FILES as $fileName ) {
812 self::loadPluralFile( $fileName );
813 }
814 }
815
822 private static function loadPluralFile( $fileName ) {
823 // Use file_get_contents instead of DOMDocument::load (T58439)
824 $xml = file_get_contents( $fileName );
825 if ( !$xml ) {
826 throw new RuntimeException( "Unable to read plurals file $fileName" );
827 }
828 $doc = new DOMDocument;
829 $doc->loadXML( $xml );
830 $rulesets = $doc->getElementsByTagName( "pluralRules" );
831 foreach ( $rulesets as $ruleset ) {
832 $codes = $ruleset->getAttribute( 'locales' );
833 $rules = [];
834 $ruleTypes = [];
835 $ruleElements = $ruleset->getElementsByTagName( "pluralRule" );
836 foreach ( $ruleElements as $elt ) {
837 $ruleType = $elt->getAttribute( 'count' );
838 if ( $ruleType === 'other' ) {
839 // Don't record "other" rules, which have an empty condition
840 continue;
841 }
842 $rules[] = $elt->nodeValue;
843 $ruleTypes[] = $ruleType;
844 }
845 foreach ( explode( ' ', $codes ) as $code ) {
846 self::$pluralRules[$code] = $rules;
847 self::$pluralRuleTypes[$code] = $ruleTypes;
848 }
849 }
850 }
851
860 private function readSourceFilesAndRegisterDeps( $code, &$deps ) {
861 // This reads in the PHP i18n file with non-messages l10n data
862 $fileName = $this->langNameUtils->getMessagesFileName( $code );
863 if ( !is_file( $fileName ) ) {
864 $data = [];
865 } else {
866 $deps[] = new FileDependency( $fileName );
867 $data = $this->readPHPFile( $fileName, 'core' );
868 }
869
870 return $data;
871 }
872
881 private function readPluralFilesAndRegisterDeps( $code, &$deps ) {
882 $data = [
883 // Load CLDR plural rules for JavaScript
884 'pluralRules' => $this->getPluralRules( $code ),
885 // And for PHP
886 'compiledPluralRules' => $this->getCompiledPluralRules( $code ),
887 // Load plural rule types
888 'pluralRuleTypes' => $this->getPluralRuleTypes( $code ),
889 ];
890
891 foreach ( self::PLURAL_FILES as $fileName ) {
892 $deps[] = new FileDependency( $fileName );
893 }
894
895 return $data;
896 }
897
906 private function mergeItem( $key, &$value, $fallbackValue ) {
907 if ( $value !== null ) {
908 if ( $fallbackValue !== null ) {
909 if ( in_array( $key, self::MERGEABLE_MAP_KEYS ) ) {
910 $value += $fallbackValue;
911 } elseif ( in_array( $key, self::MERGEABLE_ALIAS_LIST_KEYS ) ) {
912 $value = array_merge_recursive( $value, $fallbackValue );
913 } elseif ( in_array( $key, self::OPTIONAL_MERGE_KEYS ) ) {
914 if ( !empty( $value['inherit'] ) ) {
915 $value = array_merge( $fallbackValue, $value );
916 }
917
918 unset( $value['inherit'] );
919 } elseif ( in_array( $key, self::MAGIC_WORD_KEYS ) ) {
920 $this->mergeMagicWords( $value, $fallbackValue );
921 }
922 }
923 } else {
924 $value = $fallbackValue;
925 }
926 }
927
932 private function mergeMagicWords( array &$value, array $fallbackValue ): void {
933 foreach ( $fallbackValue as $magicName => $fallbackInfo ) {
934 if ( !isset( $value[$magicName] ) ) {
935 $value[$magicName] = $fallbackInfo;
936 } else {
937 $value[$magicName] = [
938 $fallbackInfo[0],
939 ...array_unique( [
940 // First value is 1 if the magic word is case-sensitive, 0 if not
941 ...array_slice( $value[$magicName], 1 ),
942 ...array_slice( $fallbackInfo, 1 ),
943 ] )
944 ];
945 }
946 }
947 }
948
956 public function getMessagesDirs() {
957 global $IP;
958
959 return [
960 'core' => "$IP/languages/i18n",
961 'exif' => "$IP/languages/i18n/exif",
962 'api' => "$IP/includes/api/i18n",
963 'rest' => "$IP/includes/Rest/i18n",
964 'oojs-ui' => "$IP/resources/lib/ooui/i18n",
965 'paramvalidator' => "$IP/includes/libs/ParamValidator/i18n",
966 ] + $this->options->get( MainConfigNames::MessagesDirs );
967 }
968
979 private function loadCoreData( string $code ) {
980 if ( !$code ) {
981 throw new InvalidArgumentException( "Invalid language code requested" );
982 }
983 if ( $this->coreDataLoaded[$code] ?? false ) {
984 return;
985 }
986
987 $coreData = array_fill_keys( self::CORE_ONLY_KEYS, null );
988 $deps = [];
989
990 # Load the primary localisation from the source file
991 $data = $this->readSourceFilesAndRegisterDeps( $code, $deps );
992 $this->logger->debug( __METHOD__ . ": got localisation for $code from source" );
993
994 # Merge primary localisation
995 foreach ( $data as $key => $value ) {
996 $this->mergeItem( $key, $coreData[ $key ], $value );
997 }
998
999 # Fill in the fallback if it's not there already
1000 // @phan-suppress-next-line PhanSuspiciousValueComparison
1001 if ( ( $coreData['fallback'] === null || $coreData['fallback'] === false ) && $code === 'en' ) {
1002 $coreData['fallback'] = false;
1003 $coreData['originalFallbackSequence'] = $coreData['fallbackSequence'] = [];
1004 } else {
1005 if ( $coreData['fallback'] !== null ) {
1006 $coreData['fallbackSequence'] = array_map( 'trim', explode( ',', $coreData['fallback'] ) );
1007 } else {
1008 $coreData['fallbackSequence'] = [];
1009 }
1010 $len = count( $coreData['fallbackSequence'] );
1011
1012 # Before we add the 'en' fallback for messages, keep a copy of
1013 # the original fallback sequence
1014 $coreData['originalFallbackSequence'] = $coreData['fallbackSequence'];
1015
1016 # Ensure that the sequence ends at 'en' for messages
1017 if ( !$len || $coreData['fallbackSequence'][$len - 1] !== 'en' ) {
1018 $coreData['fallbackSequence'][] = 'en';
1019 }
1020 }
1021
1022 foreach ( $coreData['fallbackSequence'] as $fbCode ) {
1023 // load core fallback data
1024 $fbData = $this->readSourceFilesAndRegisterDeps( $fbCode, $deps );
1025 foreach ( self::CORE_ONLY_KEYS as $key ) {
1026 // core-only keys are not mergeable, only set if not present in core data yet
1027 if ( isset( $fbData[$key] ) && !isset( $coreData[$key] ) ) {
1028 $coreData[$key] = $fbData[$key];
1029 }
1030 }
1031 }
1032
1033 $coreData['deps'] = $deps;
1034 foreach ( $coreData as $key => $item ) {
1035 $this->data[$code][$key] ??= null;
1036 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- we just set a default null
1037 $this->mergeItem( $key, $this->data[$code][$key], $item );
1038 if (
1039 in_array( $key, self::CORE_ONLY_KEYS, true ) ||
1040 // "synthetic" keys based on "fallback" (see above)
1041 $key === 'fallbackSequence' ||
1042 $key === 'originalFallbackSequence'
1043 ) {
1044 // only mark core-only keys as loaded;
1045 // we may have loaded additional ones from the source file,
1046 // but they are not fully loaded yet, since recache()
1047 // may have to merge in additional values from fallback languages
1048 $this->loadedItems[$code][$key] = true;
1049 }
1050 }
1051
1052 $this->coreDataLoaded[$code] = true;
1053 }
1054
1061 public function recache( $code ) {
1062 if ( !$code ) {
1063 throw new InvalidArgumentException( "Invalid language code requested" );
1064 }
1065 $this->recachedLangs[ $code ] = true;
1066
1067 # Initial values
1068 $initialData = array_fill_keys( self::ALL_KEYS, null );
1069 $this->data[$code] = [];
1070 $this->loadedItems[$code] = [];
1071 $this->loadedSubitems[$code] = [];
1072 $this->coreDataLoaded[$code] = false;
1073 $this->loadCoreData( $code );
1074 $coreData = $this->data[$code];
1075 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- guaranteed by loadCoreData()
1076 $deps = $coreData['deps'];
1077 $coreData += $this->readPluralFilesAndRegisterDeps( $code, $deps );
1078
1079 $codeSequence = array_merge( [ $code ], $coreData['fallbackSequence'] );
1080 $messageDirs = $this->getMessagesDirs();
1081
1082 # Load non-JSON localisation data for extensions
1083 $extensionData = array_fill_keys( $codeSequence, $initialData );
1084 foreach ( $this->options->get( MainConfigNames::ExtensionMessagesFiles ) as $extension => $fileName ) {
1085 if ( isset( $messageDirs[$extension] ) ) {
1086 # This extension has JSON message data; skip the PHP shim
1087 continue;
1088 }
1089
1090 $data = $this->readPHPFile( $fileName, 'extension' );
1091 $used = false;
1092
1093 foreach ( $data as $key => $item ) {
1094 foreach ( $codeSequence as $csCode ) {
1095 if ( isset( $item[$csCode] ) ) {
1096 // Keep the behaviour the same as for json messages.
1097 // TODO: Consider deprecating using a PHP file for messages.
1098 if ( in_array( $key, self::SOURCE_PREFIX_KEYS ) ) {
1099 foreach ( $item[$csCode] as $subkey => $_ ) {
1100 $this->sourceLanguage[$code][$key][$subkey] ??= $csCode;
1101 }
1102 }
1103 $this->mergeItem( $key, $extensionData[$csCode][$key], $item[$csCode] );
1104 $used = true;
1105 }
1106 }
1107 }
1108
1109 if ( $used ) {
1110 $deps[] = new FileDependency( $fileName );
1111 }
1112 }
1113
1114 # Load the localisation data for each fallback, then merge it into the full array
1115 $allData = $initialData;
1116 foreach ( $codeSequence as $csCode ) {
1117 $csData = $initialData;
1118
1119 # Load core messages and the extension localisations.
1120 foreach ( $messageDirs as $dirs ) {
1121 foreach ( (array)$dirs as $dir ) {
1122 $fileName = "$dir/$csCode.json";
1123 $messages = $this->readJSONFile( $fileName );
1124
1125 foreach ( $messages as $subkey => $_ ) {
1126 $this->sourceLanguage[$code]['messages'][$subkey] ??= $csCode;
1127 }
1128 $this->mergeItem( 'messages', $csData['messages'], $messages );
1129
1130 $deps[] = new FileDependency( $fileName );
1131 }
1132 }
1133
1134 # Merge non-JSON extension data
1135 if ( isset( $extensionData[$csCode] ) ) {
1136 foreach ( $extensionData[$csCode] as $key => $item ) {
1137 $this->mergeItem( $key, $csData[$key], $item );
1138 }
1139 }
1140
1141 if ( $csCode === $code ) {
1142 # Merge core data into extension data
1143 foreach ( $coreData as $key => $item ) {
1144 $this->mergeItem( $key, $csData[$key], $item );
1145 }
1146 } else {
1147 # Load the secondary localisation from the source file to
1148 # avoid infinite cycles on cyclic fallbacks
1149 $fbData = $this->readSourceFilesAndRegisterDeps( $csCode, $deps );
1150 $fbData += $this->readPluralFilesAndRegisterDeps( $csCode, $deps );
1151 # Only merge the keys that make sense to merge
1152 foreach ( self::ALL_KEYS as $key ) {
1153 if ( !isset( $fbData[ $key ] ) ) {
1154 continue;
1155 }
1156
1157 if ( !isset( $coreData[ $key ] ) || self::isMergeableKey( $key ) ) {
1158 $this->mergeItem( $key, $csData[ $key ], $fbData[ $key ] );
1159 }
1160 }
1161 }
1162
1163 # Allow extensions an opportunity to adjust the data for this fallback
1164 $this->hookRunner->onLocalisationCacheRecacheFallback( $this, $csCode, $csData );
1165
1166 # Merge the data for this fallback into the final array
1167 if ( $csCode === $code ) {
1168 $allData = $csData;
1169 } else {
1170 foreach ( self::ALL_KEYS as $key ) {
1171 if ( !isset( $csData[$key] ) ) {
1172 continue;
1173 }
1174
1175 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1176 if ( $allData[$key] === null || self::isMergeableKey( $key ) ) {
1177 $this->mergeItem( $key, $allData[$key], $csData[$key] );
1178 }
1179 }
1180 }
1181 }
1182
1183 if ( !isset( $allData['rtl'] ) ) {
1184 throw new RuntimeException( __METHOD__ . ': Localisation data failed validation check! ' .
1185 'Check that your languages/messages/MessagesEn.php file is intact.' );
1186 }
1187
1188 // Add cache dependencies for any referenced configs
1189 // We use the keys prefixed with 'wg' for historical reasons.
1190 $deps['wgExtensionMessagesFiles'] =
1191 new ConfigDependency( MainConfigNames::ExtensionMessagesFiles, $this->options );
1192 $deps['wgMessagesDirs'] =
1193 new ConfigDependency( MainConfigNames::MessagesDirs, $this->options );
1194 $deps['version'] = new ConstantDependency( self::class . '::VERSION' );
1195
1196 # Add dependencies to the cache entry
1197 $allData['deps'] = $deps;
1198
1199 # Replace spaces with underscores in namespace names
1200 $allData['namespaceNames'] = str_replace( ' ', '_', $allData['namespaceNames'] );
1201
1202 # And do the same for special page aliases. $page is an array.
1203 foreach ( $allData['specialPageAliases'] as &$page ) {
1204 $page = str_replace( ' ', '_', $page );
1205 }
1206 # Decouple the reference to prevent accidental damage
1207 unset( $page );
1208
1209 # If there were no plural rules, return an empty array
1210 $allData['pluralRules'] ??= [];
1211 $allData['compiledPluralRules'] ??= [];
1212 # If there were no plural rule types, return an empty array
1213 $allData['pluralRuleTypes'] ??= [];
1214
1215 # Set the list keys
1216 $allData['list'] = [];
1217 foreach ( self::SPLIT_KEYS as $key ) {
1218 $allData['list'][$key] = array_keys( $allData[$key] );
1219 }
1220 # Run hooks
1221 $unused = true; // Used to be $purgeBlobs, removed in 1.34
1222 $this->hookRunner->onLocalisationCacheRecache( $this, $code, $allData, $unused );
1223
1224 # Save to the process cache and register the items loaded
1225 $this->data[$code] = $allData;
1226 $this->loadedItems[$code] = [];
1227 $this->loadedSubitems[$code] = [];
1228 foreach ( $allData as $key => $item ) {
1229 $this->loadedItems[$code][$key] = true;
1230 }
1231
1232 # Prefix each item with its source language code before save
1233 foreach ( self::SOURCE_PREFIX_KEYS as $key ) {
1234 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1235 foreach ( $allData[$key] as $subKey => $value ) {
1236 // The source language should have been set, but to avoid Phan error and be double sure.
1237 $allData[$key][$subKey] = ( $this->sourceLanguage[$code][$key][$subKey] ?? $code ) .
1238 self::SOURCEPREFIX_SEPARATOR . $value;
1239 }
1240 }
1241
1242 # Set the preload key
1243 $allData['preload'] = $this->buildPreload( $allData );
1244
1245 # Save to the persistent cache
1246 $this->store->startWrite( $code );
1247 foreach ( $allData as $key => $value ) {
1248 if ( in_array( $key, self::SPLIT_KEYS ) ) {
1249 foreach ( $value as $subkey => $subvalue ) {
1250 $this->store->set( "$key:$subkey", $subvalue );
1251 }
1252 } else {
1253 $this->store->set( $key, $value );
1254 }
1255 }
1256 $this->store->finishWrite();
1257
1258 # Clear out the MessageBlobStore
1259 # HACK: If using a null (i.e., disabled) storage backend, we
1260 # can't write to the MessageBlobStore either
1261 if ( !$this->store instanceof LCStoreNull ) {
1262 foreach ( $this->clearStoreCallbacks as $callback ) {
1263 $callback();
1264 }
1265 }
1266 }
1267
1277 private function buildPreload( $data ) {
1278 $preload = [ 'messages' => [] ];
1279 foreach ( self::PRELOADED_KEYS as $key ) {
1280 $preload[$key] = $data[$key];
1281 }
1282
1283 foreach ( $data['preloadedMessages'] as $subkey ) {
1284 $subitem = $data['messages'][$subkey] ?? null;
1285 $preload['messages'][$subkey] = $subitem;
1286 }
1287
1288 return $preload;
1289 }
1290
1298 public function unload( $code ) {
1299 unset( $this->data[$code] );
1300 unset( $this->loadedItems[$code] );
1301 unset( $this->loadedSubitems[$code] );
1302 unset( $this->initialisedLangs[$code] );
1303 unset( $this->shallowFallbacks[$code] );
1304 unset( $this->sourceLanguage[$code] );
1305 unset( $this->coreDataLoaded[$code] );
1306
1307 foreach ( $this->shallowFallbacks as $shallowCode => $fbCode ) {
1308 if ( $fbCode === $code ) {
1309 $this->unload( $shallowCode );
1310 }
1311 }
1312 }
1313
1317 public function unloadAll() {
1318 foreach ( $this->initialisedLangs as $lang => $unused ) {
1319 $this->unload( $lang );
1320 }
1321 }
1322
1326 public function disableBackend() {
1327 $this->store = new LCStoreNull;
1328 $this->manualRecache = false;
1329 }
1330}
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 MediaWiki configuration variable provided via ServiceOptions.
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.
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.
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