MediaWiki  master
LocalisationCache.php
Go to the documentation of this file.
1 <?php
21 use CLDRPluralRuleParser\Error as CLDRPluralRuleError;
22 use CLDRPluralRuleParser\Evaluator;
28 use 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  [
641  $this->sourceLanguage[$code][$key][$subkey],
642  $preload[$key][$subkey]
643  ] = explode( self::SOURCEPREFIX_SEPARATOR, $value, 2 );
644  }
645  }
646 
647  if ( isset( $this->data[$code] ) ) {
648  foreach ( $preload as $key => $value ) {
649  // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- see isset() above
650  $this->mergeItem( $key, $this->data[$code][$key], $value );
651  }
652  } else {
653  $this->data[$code] = $preload;
654  }
655  foreach ( $preload as $key => $item ) {
656  if ( in_array( $key, self::SPLIT_KEYS ) ) {
657  foreach ( $item as $subkey => $subitem ) {
658  $this->loadedSubitems[$code][$key][$subkey] = true;
659  }
660  } else {
661  $this->loadedItems[$code][$key] = true;
662  }
663  }
664  }
665 
673  private function initShallowFallback( $primaryCode, $fallbackCode ) {
674  $this->data[$primaryCode] =& $this->data[$fallbackCode];
675  $this->loadedItems[$primaryCode] =& $this->loadedItems[$fallbackCode];
676  $this->loadedSubitems[$primaryCode] =& $this->loadedSubitems[$fallbackCode];
677  $this->shallowFallbacks[$primaryCode] = $fallbackCode;
678  $this->coreDataLoaded[$primaryCode] =& $this->coreDataLoaded[$fallbackCode];
679  }
680 
689  protected function readPHPFile( $_fileName, $_fileType ) {
690  include $_fileName;
691 
692  $data = [];
693  if ( $_fileType == 'core' ) {
694  foreach ( self::ALL_KEYS as $key ) {
695  // Not all keys are set in language files, so
696  // check they exist first
697  if ( isset( $$key ) ) {
698  $data[$key] = $$key;
699  }
700  }
701  } elseif ( $_fileType == 'extension' ) {
702  foreach ( self::ALL_EXCEPT_CORE_ONLY_KEYS as $key ) {
703  if ( isset( $$key ) ) {
704  $data[$key] = $$key;
705  }
706  }
707  } elseif ( $_fileType == 'aliases' ) {
708  // @phan-suppress-next-line PhanImpossibleCondition May be set in the included file
709  if ( isset( $aliases ) ) {
710  $data['aliases'] = $aliases;
711  }
712  } else {
713  throw new MWException( __METHOD__ . ": Invalid file type: $_fileType" );
714  }
715 
716  return $data;
717  }
718 
726  private function readJSONFile( $fileName ) {
727  if ( !is_readable( $fileName ) ) {
728  return [];
729  }
730 
731  $json = file_get_contents( $fileName );
732  if ( $json === false ) {
733  return [];
734  }
735 
736  $data = FormatJson::decode( $json, true );
737  if ( $data === null ) {
738  throw new MWException( __METHOD__ . ": Invalid JSON file: $fileName" );
739  }
740 
741  // Remove keys starting with '@'; they are reserved for metadata and non-message data
742  foreach ( $data as $key => $unused ) {
743  if ( $key === '' || $key[0] === '@' ) {
744  unset( $data[$key] );
745  }
746  }
747 
748  return $data;
749  }
750 
758  private function getCompiledPluralRules( $code ) {
759  $rules = $this->getPluralRules( $code );
760  if ( $rules === null ) {
761  return null;
762  }
763  try {
764  $compiledRules = Evaluator::compile( $rules );
765  } catch ( CLDRPluralRuleError $e ) {
766  $this->logger->debug( $e->getMessage() );
767 
768  return [];
769  }
770 
771  return $compiledRules;
772  }
773 
783  private function getPluralRules( $code ) {
784  if ( self::$pluralRules === null ) {
785  self::loadPluralFiles();
786  }
787  return self::$pluralRules[$code] ?? null;
788  }
789 
799  private function getPluralRuleTypes( $code ) {
800  if ( self::$pluralRuleTypes === null ) {
801  self::loadPluralFiles();
802  }
803  return self::$pluralRuleTypes[$code] ?? null;
804  }
805 
809  private static function loadPluralFiles() {
810  foreach ( self::PLURAL_FILES as $fileName ) {
811  self::loadPluralFile( $fileName );
812  }
813  }
814 
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 MWException( "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 MWException( "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 
1062  public function recache( $code ) {
1063  if ( !$code ) {
1064  throw new MWException( "Invalid language code requested" );
1065  }
1066  $this->recachedLangs[ $code ] = true;
1067 
1068  # Initial values
1069  $initialData = array_fill_keys( self::ALL_KEYS, null );
1070  $this->data[$code] = [];
1071  $this->loadedItems[$code] = [];
1072  $this->loadedSubitems[$code] = [];
1073  $this->coreDataLoaded[$code] = false;
1074  $this->loadCoreData( $code );
1075  $coreData = $this->data[$code];
1076  // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- guaranteed by loadCoreData()
1077  $deps = $coreData['deps'];
1078  $coreData += $this->readPluralFilesAndRegisterDeps( $code, $deps );
1079 
1080  $codeSequence = array_merge( [ $code ], $coreData['fallbackSequence'] );
1081  $messageDirs = $this->getMessagesDirs();
1082 
1083  # Load non-JSON localisation data for extensions
1084  $extensionData = array_fill_keys( $codeSequence, $initialData );
1085  foreach ( $this->options->get( MainConfigNames::ExtensionMessagesFiles ) as $extension => $fileName ) {
1086  if ( isset( $messageDirs[$extension] ) ) {
1087  # This extension has JSON message data; skip the PHP shim
1088  continue;
1089  }
1090 
1091  $data = $this->readPHPFile( $fileName, 'extension' );
1092  $used = false;
1093 
1094  foreach ( $data as $key => $item ) {
1095  foreach ( $codeSequence as $csCode ) {
1096  if ( isset( $item[$csCode] ) ) {
1097  // Keep the behaviour the same as for json messages.
1098  // TODO: Consider deprecating using a PHP file for messages.
1099  if ( in_array( $key, self::SOURCE_PREFIX_KEYS ) ) {
1100  foreach ( $item[$csCode] as $subkey => $_ ) {
1101  $this->sourceLanguage[$code][$key][$subkey] ??= $csCode;
1102  }
1103  }
1104  $this->mergeItem( $key, $extensionData[$csCode][$key], $item[$csCode] );
1105  $used = true;
1106  }
1107  }
1108  }
1109 
1110  if ( $used ) {
1111  $deps[] = new FileDependency( $fileName );
1112  }
1113  }
1114 
1115  # Load the localisation data for each fallback, then merge it into the full array
1116  $allData = $initialData;
1117  foreach ( $codeSequence as $csCode ) {
1118  $csData = $initialData;
1119 
1120  # Load core messages and the extension localisations.
1121  foreach ( $messageDirs as $dirs ) {
1122  foreach ( (array)$dirs as $dir ) {
1123  $fileName = "$dir/$csCode.json";
1124  $messages = $this->readJSONFile( $fileName );
1125 
1126  foreach ( $messages as $subkey => $_ ) {
1127  $this->sourceLanguage[$code]['messages'][$subkey] ??= $csCode;
1128  }
1129  $this->mergeItem( 'messages', $csData['messages'], $messages );
1130 
1131  $deps[] = new FileDependency( $fileName );
1132  }
1133  }
1134 
1135  # Merge non-JSON extension data
1136  if ( isset( $extensionData[$csCode] ) ) {
1137  foreach ( $extensionData[$csCode] as $key => $item ) {
1138  $this->mergeItem( $key, $csData[$key], $item );
1139  }
1140  }
1141 
1142  if ( $csCode === $code ) {
1143  # Merge core data into extension data
1144  foreach ( $coreData as $key => $item ) {
1145  $this->mergeItem( $key, $csData[$key], $item );
1146  }
1147  } else {
1148  # Load the secondary localisation from the source file to
1149  # avoid infinite cycles on cyclic fallbacks
1150  $fbData = $this->readSourceFilesAndRegisterDeps( $csCode, $deps );
1151  $fbData += $this->readPluralFilesAndRegisterDeps( $csCode, $deps );
1152  # Only merge the keys that make sense to merge
1153  foreach ( self::ALL_KEYS as $key ) {
1154  if ( !isset( $fbData[ $key ] ) ) {
1155  continue;
1156  }
1157 
1158  if ( !isset( $coreData[ $key ] ) || self::isMergeableKey( $key ) ) {
1159  $this->mergeItem( $key, $csData[ $key ], $fbData[ $key ] );
1160  }
1161  }
1162  }
1163 
1164  # Allow extensions an opportunity to adjust the data for this fallback
1165  $this->hookRunner->onLocalisationCacheRecacheFallback( $this, $csCode, $csData );
1166 
1167  # Merge the data for this fallback into the final array
1168  if ( $csCode === $code ) {
1169  $allData = $csData;
1170  } else {
1171  foreach ( self::ALL_KEYS as $key ) {
1172  if ( !isset( $csData[$key] ) ) {
1173  continue;
1174  }
1175 
1176  // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
1177  if ( $allData[$key] === null || self::isMergeableKey( $key ) ) {
1178  $this->mergeItem( $key, $allData[$key], $csData[$key] );
1179  }
1180  }
1181  }
1182  }
1183 
1184  if ( !isset( $allData['rtl'] ) ) {
1185  throw new MWException( __METHOD__ . ': Localisation data failed validation check! ' .
1186  'Check that your languages/messages/MessagesEn.php file is intact.' );
1187  }
1188 
1189  # Add cache dependencies for any referenced globals
1190  $deps['wgExtensionMessagesFiles'] = new GlobalDependency( 'wgExtensionMessagesFiles' );
1191  // The 'MessagesDirs' config setting is used in LocalisationCache::getMessagesDirs().
1192  // We use the key 'wgMessagesDirs' for historical reasons.
1193  $deps['wgMessagesDirs'] = new MainConfigDependency( MainConfigNames::MessagesDirs );
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: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.
Definition: FormatJson.php:148
Depend on a PHP global variable.
Null store backend, used to avoid DB errors during MediaWiki installation.
Definition: LCStoreNull.php:26
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.
Definition: MWException.php:33
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...
Definition: HookRunner.php:567
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