MediaWiki 1.41.2
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 MWException(
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
591 private function initLanguage( $code ) {
592 if ( isset( $this->initialisedLangs[$code] ) ) {
593 return;
594 }
595
596 $this->initialisedLangs[$code] = true;
597
598 # If the code is of the wrong form for a Messages*.php file, do a shallow fallback
599 if ( !$this->langNameUtils->isValidBuiltInCode( $code ) ) {
600 $this->initShallowFallback( $code, 'en' );
601
602 return;
603 }
604
605 # Re-cache the data if necessary
606 if ( !$this->manualRecache && $this->isExpired( $code ) ) {
607 if ( $this->langNameUtils->isSupportedLanguage( $code ) ) {
608 $this->recache( $code );
609 } elseif ( $code === 'en' ) {
610 throw new MWException( 'MessagesEn.php is missing.' );
611 } else {
612 $this->initShallowFallback( $code, 'en' );
613 }
614
615 return;
616 }
617
618 # Preload some stuff
619 $preload = $this->getItem( $code, 'preload' );
620 if ( $preload === null ) {
621 if ( $this->manualRecache ) {
622 // No Messages*.php file. Do shallow fallback to en.
623 if ( $code === 'en' ) {
624 throw new MWException( 'No localisation cache found for English. ' .
625 'Please run maintenance/rebuildLocalisationCache.php.' );
626 }
627 $this->initShallowFallback( $code, 'en' );
628
629 return;
630 } else {
631 throw new MWException( 'Invalid or missing localisation cache.' );
632 }
633 }
634
635 foreach ( self::SOURCE_PREFIX_KEYS as $key ) {
636 if ( !isset( $preload[$key] ) ) {
637 continue;
638 }
639 foreach ( $preload[$key] as $subkey => $value ) {
640 if ( $value !== null ) {
641 [
642 $this->sourceLanguage[$code][$key][$subkey],
643 $preload[$key][$subkey]
644 ] = explode( self::SOURCEPREFIX_SEPARATOR, $value, 2 );
645 } else {
646 $preload[$key][$subkey] = null;
647 }
648 }
649 }
650
651 if ( isset( $this->data[$code] ) ) {
652 foreach ( $preload as $key => $value ) {
653 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- see isset() above
654 $this->mergeItem( $key, $this->data[$code][$key], $value );
655 }
656 } else {
657 $this->data[$code] = $preload;
658 }
659 foreach ( $preload as $key => $item ) {
660 if ( in_array( $key, self::SPLIT_KEYS ) ) {
661 foreach ( $item as $subkey => $subitem ) {
662 $this->loadedSubitems[$code][$key][$subkey] = true;
663 }
664 } else {
665 $this->loadedItems[$code][$key] = true;
666 }
667 }
668 }
669
677 private function initShallowFallback( $primaryCode, $fallbackCode ) {
678 $this->data[$primaryCode] =& $this->data[$fallbackCode];
679 $this->loadedItems[$primaryCode] =& $this->loadedItems[$fallbackCode];
680 $this->loadedSubitems[$primaryCode] =& $this->loadedSubitems[$fallbackCode];
681 $this->shallowFallbacks[$primaryCode] = $fallbackCode;
682 $this->coreDataLoaded[$primaryCode] =& $this->coreDataLoaded[$fallbackCode];
683 }
684
693 protected function readPHPFile( $_fileName, $_fileType ) {
694 include $_fileName;
695
696 $data = [];
697 if ( $_fileType == 'core' ) {
698 foreach ( self::ALL_KEYS as $key ) {
699 // Not all keys are set in language files, so
700 // check they exist first
701 if ( isset( $$key ) ) {
702 $data[$key] = $$key;
703 }
704 }
705 } elseif ( $_fileType == 'extension' ) {
706 foreach ( self::ALL_EXCEPT_CORE_ONLY_KEYS as $key ) {
707 if ( isset( $$key ) ) {
708 $data[$key] = $$key;
709 }
710 }
711 } elseif ( $_fileType == 'aliases' ) {
712 // @phan-suppress-next-line PhanImpossibleCondition May be set in the included file
713 if ( isset( $aliases ) ) {
714 $data['aliases'] = $aliases;
715 }
716 } else {
717 throw new MWException( __METHOD__ . ": Invalid file type: $_fileType" );
718 }
719
720 return $data;
721 }
722
730 private function readJSONFile( $fileName ) {
731 if ( !is_readable( $fileName ) ) {
732 return [];
733 }
734
735 $json = file_get_contents( $fileName );
736 if ( $json === false ) {
737 return [];
738 }
739
740 $data = FormatJson::decode( $json, true );
741 if ( $data === null ) {
742 throw new MWException( __METHOD__ . ": Invalid JSON file: $fileName" );
743 }
744
745 // Remove keys starting with '@'; they are reserved for metadata and non-message data
746 foreach ( $data as $key => $unused ) {
747 if ( $key === '' || $key[0] === '@' ) {
748 unset( $data[$key] );
749 }
750 }
751
752 return $data;
753 }
754
762 private function getCompiledPluralRules( $code ) {
763 $rules = $this->getPluralRules( $code );
764 if ( $rules === null ) {
765 return null;
766 }
767 try {
768 $compiledRules = Evaluator::compile( $rules );
769 } catch ( CLDRPluralRuleError $e ) {
770 $this->logger->debug( $e->getMessage() );
771
772 return [];
773 }
774
775 return $compiledRules;
776 }
777
787 private function getPluralRules( $code ) {
788 if ( self::$pluralRules === null ) {
789 self::loadPluralFiles();
790 }
791 return self::$pluralRules[$code] ?? null;
792 }
793
803 private function getPluralRuleTypes( $code ) {
804 if ( self::$pluralRuleTypes === null ) {
805 self::loadPluralFiles();
806 }
807 return self::$pluralRuleTypes[$code] ?? null;
808 }
809
813 private static function loadPluralFiles() {
814 foreach ( self::PLURAL_FILES as $fileName ) {
815 self::loadPluralFile( $fileName );
816 }
817 }
818
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 MWException( "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 MWException( "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
1066 public function recache( $code ) {
1067 if ( !$code ) {
1068 throw new MWException( "Invalid language code requested" );
1069 }
1070 $this->recachedLangs[ $code ] = true;
1071
1072 # Initial values
1073 $initialData = array_fill_keys( self::ALL_KEYS, null );
1074 $this->data[$code] = [];
1075 $this->loadedItems[$code] = [];
1076 $this->loadedSubitems[$code] = [];
1077 $this->coreDataLoaded[$code] = false;
1078 $this->loadCoreData( $code );
1079 $coreData = $this->data[$code];
1080 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- guaranteed by loadCoreData()
1081 $deps = $coreData['deps'];
1082 $coreData += $this->readPluralFilesAndRegisterDeps( $code, $deps );
1083
1084 $codeSequence = array_merge( [ $code ], $coreData['fallbackSequence'] );
1085 $messageDirs = $this->getMessagesDirs();
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] ) ) {
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 # Merge non-JSON extension data
1140 if ( isset( $extensionData[$csCode] ) ) {
1141 foreach ( $extensionData[$csCode] as $key => $item ) {
1142 $this->mergeItem( $key, $csData[$key], $item );
1143 }
1144 }
1145
1146 if ( $csCode === $code ) {
1147 # Merge core data into extension data
1148 foreach ( $coreData as $key => $item ) {
1149 $this->mergeItem( $key, $csData[$key], $item );
1150 }
1151 } else {
1152 # Load the secondary localisation from the source file to
1153 # avoid infinite cycles on cyclic fallbacks
1154 $fbData = $this->readSourceFilesAndRegisterDeps( $csCode, $deps );
1155 $fbData += $this->readPluralFilesAndRegisterDeps( $csCode, $deps );
1156 # Only merge the keys that make sense to merge
1157 foreach ( self::ALL_KEYS as $key ) {
1158 if ( !isset( $fbData[ $key ] ) ) {
1159 continue;
1160 }
1161
1162 if ( !isset( $coreData[ $key ] ) || self::isMergeableKey( $key ) ) {
1163 $this->mergeItem( $key, $csData[ $key ], $fbData[ $key ] );
1164 }
1165 }
1166 }
1167
1168 # Allow extensions an opportunity to adjust the data for this fallback
1169 $this->hookRunner->onLocalisationCacheRecacheFallback( $this, $csCode, $csData );
1170
1171 # Merge the data for this fallback into the final array
1172 if ( $csCode === $code ) {
1173 $allData = $csData;
1174 } else {
1175 foreach ( self::ALL_KEYS as $key ) {
1176 if ( !isset( $csData[$key] ) ) {
1177 continue;
1178 }
1179
1180 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1181 if ( $allData[$key] === null || self::isMergeableKey( $key ) ) {
1182 $this->mergeItem( $key, $allData[$key], $csData[$key] );
1183 }
1184 }
1185 }
1186 }
1187
1188 if ( !isset( $allData['rtl'] ) ) {
1189 throw new MWException( __METHOD__ . ': Localisation data failed validation check! ' .
1190 'Check that your languages/messages/MessagesEn.php file is intact.' );
1191 }
1192
1193 # Add cache dependencies for any referenced globals
1194 $deps['wgExtensionMessagesFiles'] = new GlobalDependency( 'wgExtensionMessagesFiles' );
1195 // The 'MessagesDirs' config setting is used in LocalisationCache::getMessagesDirs().
1196 // We use the key 'wgMessagesDirs' for historical reasons.
1197 $deps['wgMessagesDirs'] = new MainConfigDependency( MainConfigNames::MessagesDirs );
1198 $deps['version'] = new ConstantDependency( self::class . '::VERSION' );
1199
1200 # Add dependencies to the cache entry
1201 $allData['deps'] = $deps;
1202
1203 # Replace spaces with underscores in namespace names
1204 $allData['namespaceNames'] = str_replace( ' ', '_', $allData['namespaceNames'] );
1205
1206 # And do the same for special page aliases. $page is an array.
1207 foreach ( $allData['specialPageAliases'] as &$page ) {
1208 $page = str_replace( ' ', '_', $page );
1209 }
1210 # Decouple the reference to prevent accidental damage
1211 unset( $page );
1212
1213 # If there were no plural rules, return an empty array
1214 $allData['pluralRules'] ??= [];
1215 $allData['compiledPluralRules'] ??= [];
1216 # If there were no plural rule types, return an empty array
1217 $allData['pluralRuleTypes'] ??= [];
1218
1219 # Set the list keys
1220 $allData['list'] = [];
1221 foreach ( self::SPLIT_KEYS as $key ) {
1222 $allData['list'][$key] = array_keys( $allData[$key] );
1223 }
1224 # Run hooks
1225 $unused = true; // Used to be $purgeBlobs, removed in 1.34
1226 $this->hookRunner->onLocalisationCacheRecache( $this, $code, $allData, $unused );
1227
1228 # Save to the process cache and register the items loaded
1229 $this->data[$code] = $allData;
1230 $this->loadedItems[$code] = [];
1231 $this->loadedSubitems[$code] = [];
1232 foreach ( $allData as $key => $item ) {
1233 $this->loadedItems[$code][$key] = true;
1234 }
1235
1236 # Prefix each item with its source language code before save
1237 foreach ( self::SOURCE_PREFIX_KEYS as $key ) {
1238 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1239 foreach ( $allData[$key] as $subKey => $value ) {
1240 // The source language should have been set, but to avoid Phan error and be double sure.
1241 $allData[$key][$subKey] = ( $this->sourceLanguage[$code][$key][$subKey] ?? $code ) .
1242 self::SOURCEPREFIX_SEPARATOR . $value;
1243 }
1244 }
1245
1246 # Set the preload key
1247 $allData['preload'] = $this->buildPreload( $allData );
1248
1249 # Save to the persistent cache
1250 $this->store->startWrite( $code );
1251 foreach ( $allData as $key => $value ) {
1252 if ( in_array( $key, self::SPLIT_KEYS ) ) {
1253 foreach ( $value as $subkey => $subvalue ) {
1254 $this->store->set( "$key:$subkey", $subvalue );
1255 }
1256 } else {
1257 $this->store->set( $key, $value );
1258 }
1259 }
1260 $this->store->finishWrite();
1261
1262 # Clear out the MessageBlobStore
1263 # HACK: If using a null (i.e., disabled) storage backend, we
1264 # can't write to the MessageBlobStore either
1265 if ( !$this->store instanceof LCStoreNull ) {
1266 foreach ( $this->clearStoreCallbacks as $callback ) {
1267 $callback();
1268 }
1269 }
1270 }
1271
1281 private function buildPreload( $data ) {
1282 $preload = [ 'messages' => [] ];
1283 foreach ( self::PRELOADED_KEYS as $key ) {
1284 $preload[$key] = $data[$key];
1285 }
1286
1287 foreach ( $data['preloadedMessages'] as $subkey ) {
1288 $subitem = $data['messages'][$subkey] ?? null;
1289 $preload['messages'][$subkey] = $subitem;
1290 }
1291
1292 return $preload;
1293 }
1294
1302 public function unload( $code ) {
1303 unset( $this->data[$code] );
1304 unset( $this->loadedItems[$code] );
1305 unset( $this->loadedSubitems[$code] );
1306 unset( $this->initialisedLangs[$code] );
1307 unset( $this->shallowFallbacks[$code] );
1308 unset( $this->sourceLanguage[$code] );
1309 unset( $this->coreDataLoaded[$code] );
1310
1311 foreach ( $this->shallowFallbacks as $shallowCode => $fbCode ) {
1312 if ( $fbCode === $code ) {
1313 $this->unload( $shallowCode );
1314 }
1315 }
1316 }
1317
1321 public function unloadAll() {
1322 foreach ( $this->initialisedLangs as $lang => $unused ) {
1323 $this->unload( $lang );
1324 }
1325 }
1326
1330 public function disableBackend() {
1331 $this->store = new LCStoreNull;
1332 $this->manualRecache = false;
1333 }
1334}
if(!defined( 'MEDIAWIKI')) if(ini_get('mbstring.func_overload')) if(!defined( 'MW_ENTRY_POINT')) global $IP
Environment checks.
Definition Setup.php:96
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.
Depend on a PHP global variable.
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.
MediaWiki exception.
Depend on a MediaWiki configuration variable.
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