TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization;
|
||||
|
||||
use TYPO3\CMS\Core\Attribute\AsEventListener;
|
||||
use TYPO3\CMS\Core\Cache\Event\CacheWarmupEvent;
|
||||
use TYPO3\CMS\Core\Package\PackageManager;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
readonly class CacheWarmer
|
||||
{
|
||||
public function __construct(
|
||||
protected PackageManager $packageManager,
|
||||
protected LabelFileResolver $labelFileResolver,
|
||||
protected LocalizationFactory $localizationFactory,
|
||||
protected Locales $locales,
|
||||
) {}
|
||||
|
||||
#[AsEventListener]
|
||||
public function warmupCaches(CacheWarmupEvent $event): void
|
||||
{
|
||||
if ($event->hasGroup('system')) {
|
||||
$activeLanguages = $this->locales->getActiveLanguages();
|
||||
$locales = [];
|
||||
foreach ($activeLanguages as $language) {
|
||||
$locales[$language] = $this->locales->createLocale($language);
|
||||
}
|
||||
|
||||
// Collect all label files from all packages first to avoid repeated filesystem scans.
|
||||
$allBaseLocaleResources = [];
|
||||
$packages = $this->packageManager->getActivePackages();
|
||||
foreach ($packages as $package) {
|
||||
$baseLocaleResources = $this->labelFileResolver->getAllLabelFilesOfPackage($package->getPackageKey(), true)['default'] ?? [];
|
||||
foreach ($baseLocaleResources as $fileReference) {
|
||||
$allBaseLocaleResources[] = $fileReference;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1: Add all resources to Symfony Translator without retrieving catalogues.
|
||||
// This avoids O(n²) behaviour where each getCatalogue() rebuilds from all previously
|
||||
// added resources. By batching all addResource() calls first, catalogue building
|
||||
// only happens once per locale in phase 2.
|
||||
foreach ($locales as $locale) {
|
||||
foreach ($allBaseLocaleResources as $fileReference) {
|
||||
$this->localizationFactory->warmupTranslatorResource($fileReference, $locale);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Retrieve catalogues and write to system cache.
|
||||
// Resources are already loaded, so this just builds catalogues once per locale.
|
||||
foreach ($locales as $locale) {
|
||||
foreach ($allBaseLocaleResources as $fileReference) {
|
||||
$this->localizationFactory->getParsedData($fileReference, $locale, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization;
|
||||
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Core\Utility\MathUtility;
|
||||
|
||||
/**
|
||||
* Wrapper for dealing with ICU-based (php-intl) date formatting
|
||||
* see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
|
||||
*/
|
||||
readonly class DateFormatter
|
||||
{
|
||||
/**
|
||||
* Formats any given input ($date) into a localized, formatted result
|
||||
*
|
||||
* @param mixed $date could be a DateTime object, a string or a number (Unix Timestamp)
|
||||
* @param string|int $format the pattern, as defined by the ICU - see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
|
||||
* @param string|Locale $locale the locale to be used, e.g. "nl-NL"
|
||||
* @return string the formatted output, such as "Tuesday at 12:40:20"
|
||||
*/
|
||||
public function format(mixed $date, string|int $format, string|Locale $locale): string
|
||||
{
|
||||
$locale = (string)$locale;
|
||||
// Use fallback locale if 'C' is provided.
|
||||
if ($locale === 'C') {
|
||||
$locale = 'en-US';
|
||||
}
|
||||
if (is_int($format) || MathUtility::canBeInterpretedAsInteger($format)) {
|
||||
$dateFormatter = new \IntlDateFormatter($locale, (int)$format, (int)$format);
|
||||
} else {
|
||||
$dateFormatter = match (strtoupper($format)) {
|
||||
'FULL' => new \IntlDateFormatter($locale, \IntlDateFormatter::FULL, \IntlDateFormatter::FULL),
|
||||
'FULLDATE' => new \IntlDateFormatter($locale, \IntlDateFormatter::FULL, \IntlDateFormatter::NONE),
|
||||
'FULLTIME' => new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::FULL),
|
||||
'LONG' => new \IntlDateFormatter($locale, \IntlDateFormatter::LONG, \IntlDateFormatter::LONG),
|
||||
'LONGDATE' => new \IntlDateFormatter($locale, \IntlDateFormatter::LONG, \IntlDateFormatter::NONE),
|
||||
'LONGTIME' => new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::LONG),
|
||||
'MEDIUM' => new \IntlDateFormatter($locale, \IntlDateFormatter::MEDIUM, \IntlDateFormatter::MEDIUM),
|
||||
'MEDIUMDATE' => new \IntlDateFormatter($locale, \IntlDateFormatter::MEDIUM, \IntlDateFormatter::NONE),
|
||||
'MEDIUMTIME' => new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::MEDIUM),
|
||||
'SHORT' => new \IntlDateFormatter($locale, \IntlDateFormatter::SHORT, \IntlDateFormatter::SHORT),
|
||||
'SHORTDATE' => new \IntlDateFormatter($locale, \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE),
|
||||
'SHORTTIME' => new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::SHORT),
|
||||
// Use a custom pattern
|
||||
default => new \IntlDateFormatter($locale, \IntlDateFormatter::FULL, \IntlDateFormatter::FULL, null, null, $format),
|
||||
};
|
||||
}
|
||||
return $dateFormatter->format($date) ?: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale-formatted strftime using IntlDateFormatter (PHP 8.1 compatible)
|
||||
* This provides a cross-platform alternative to strftime() for when it will be removed from PHP.
|
||||
* Note that output can be slightly different between libc sprintf and this function as it is using ICU.
|
||||
*
|
||||
* Original author BohwaZ <https://bohwaz.net/>
|
||||
* Adapted from https://github.com/alphp/strftime
|
||||
* MIT licensed
|
||||
*/
|
||||
public function strftime(string $format, int|string|\DateTimeInterface|null $timestamp, string|Locale|null $locale = null, $useUtcTimeZone = false): string
|
||||
{
|
||||
if (!$timestamp instanceof \DateTimeInterface) {
|
||||
$timestamp = MathUtility::canBeInterpretedAsInteger($timestamp) ? '@' . $timestamp : (string)$timestamp;
|
||||
try {
|
||||
$timestamp = new \DateTime($timestamp);
|
||||
} catch (\Exception $e) {
|
||||
throw new \InvalidArgumentException('$timestamp argument is neither a valid UNIX timestamp, a valid date-time string or a DateTime object.', 1679091446, $e);
|
||||
}
|
||||
$timestamp->setTimezone(new \DateTimeZone($useUtcTimeZone ? 'UTC' : date_default_timezone_get()));
|
||||
}
|
||||
|
||||
if (empty($locale)) {
|
||||
// get current locale
|
||||
$locale = (string)setlocale(LC_TIME, '0');
|
||||
} else {
|
||||
$locale = (string)$locale;
|
||||
}
|
||||
// Use fallback locale if 'C' is provided.
|
||||
if ($locale === 'C') {
|
||||
$locale = 'en-US';
|
||||
}
|
||||
// remove trailing part not supported by ext-intl locale
|
||||
$locale = preg_replace('/[^\w-].*$/', '', $locale);
|
||||
|
||||
$intl_formats = [
|
||||
'%a' => 'EEE', // An abbreviated textual representation of the day Sun through Sat
|
||||
'%A' => 'EEEE', // A full textual representation of the day Sunday through Saturday
|
||||
'%b' => 'MMM', // Abbreviated month name, based on the locale Jan through Dec
|
||||
'%B' => 'MMMM', // Full month name, based on the locale January through December
|
||||
'%h' => 'MMM', // Abbreviated month name, based on the locale (an alias of %b) Jan through Dec
|
||||
];
|
||||
|
||||
$intl_formatter = function (\DateTimeInterface $timestamp, string $format) use ($intl_formats, $locale): string {
|
||||
$tz = $timestamp->getTimezone();
|
||||
$date_type = \IntlDateFormatter::FULL;
|
||||
$time_type = \IntlDateFormatter::FULL;
|
||||
$pattern = '';
|
||||
|
||||
switch ($format) {
|
||||
// %c = Preferred date and time stamp based on locale
|
||||
// Example: Tue Feb 5 00:45:10 2009 for February 5, 2009 at 12:45:10 AM
|
||||
case '%c':
|
||||
$date_type = \IntlDateFormatter::LONG;
|
||||
$time_type = \IntlDateFormatter::SHORT;
|
||||
break;
|
||||
|
||||
// %x = Preferred date representation based on locale, without the time
|
||||
// Example: 02/05/09 for February 5, 2009
|
||||
case '%x':
|
||||
$date_type = \IntlDateFormatter::SHORT;
|
||||
$time_type = \IntlDateFormatter::NONE;
|
||||
break;
|
||||
|
||||
// Localized time format
|
||||
case '%X':
|
||||
$date_type = \IntlDateFormatter::NONE;
|
||||
$time_type = \IntlDateFormatter::MEDIUM;
|
||||
break;
|
||||
|
||||
default:
|
||||
$pattern = $intl_formats[$format];
|
||||
}
|
||||
|
||||
// In October 1582, the Gregorian calendar replaced the Julian in much of Europe, and
|
||||
// the 4th October was followed by the 15th October.
|
||||
// ICU (including IntlDateFormattter) interprets and formats dates based on this cutover.
|
||||
// Posix (including strftime) and timelib (including DateTimeImmutable) instead use
|
||||
// a "proleptic Gregorian calendar" - they pretend the Gregorian calendar has existed forever.
|
||||
// This leads to the same instants in time, as expressed in Unix time, having different representations
|
||||
// in formatted strings.
|
||||
// To adjust for this, a custom calendar can be supplied with a cutover date arbitrarily far in the past.
|
||||
$calendar = new \IntlGregorianCalendar();
|
||||
$calendar->setGregorianChange(PHP_INT_MIN);
|
||||
|
||||
return (new \IntlDateFormatter($locale, $date_type, $time_type, $tz, $calendar, $pattern))->format($timestamp) ?: '';
|
||||
};
|
||||
|
||||
// Same order as https://www.php.net/manual/en/function.strftime.php
|
||||
$translation_table = [
|
||||
// Day
|
||||
'%a' => $intl_formatter,
|
||||
'%A' => $intl_formatter,
|
||||
'%d' => 'd',
|
||||
'%e' => function (\DateTimeInterface $timestamp, string $_): string {
|
||||
return sprintf('% 2u', $timestamp->format('j'));
|
||||
},
|
||||
'%j' => function (\DateTimeInterface $timestamp, string $_): string {
|
||||
// Day number in year, 001 to 366
|
||||
return sprintf('%03d', (int)($timestamp->format('z')) + 1);
|
||||
},
|
||||
'%u' => 'N',
|
||||
'%w' => 'w',
|
||||
|
||||
// Week
|
||||
'%U' => function (\DateTimeInterface $timestamp, string $_): string {
|
||||
// Number of weeks between date and first Sunday of year
|
||||
$day = new \DateTime(sprintf('%d-01 Sunday', $timestamp->format('Y')));
|
||||
return sprintf('%02u', 1 + ($timestamp->format('z') - $day->format('z')) / 7);
|
||||
},
|
||||
'%V' => 'W',
|
||||
'%W' => function (\DateTimeInterface $timestamp, string $_): string {
|
||||
// Number of weeks between date and first Monday of year
|
||||
$day = new \DateTime(sprintf('%d-01 Monday', $timestamp->format('Y')));
|
||||
return sprintf('%02u', 1 + ($timestamp->format('z') - $day->format('z')) / 7);
|
||||
},
|
||||
|
||||
// Month
|
||||
'%b' => $intl_formatter,
|
||||
'%B' => $intl_formatter,
|
||||
'%h' => $intl_formatter,
|
||||
'%m' => 'm',
|
||||
|
||||
// Year
|
||||
'%C' => function (\DateTimeInterface $timestamp, string $_): string {
|
||||
// Century (-1): 19 for 20th century
|
||||
return (string)floor($timestamp->format('Y') / 100);
|
||||
},
|
||||
'%g' => function (\DateTimeInterface $timestamp, string $_): string {
|
||||
return substr($timestamp->format('o'), -2);
|
||||
},
|
||||
'%G' => 'o',
|
||||
'%y' => 'y',
|
||||
'%Y' => 'Y',
|
||||
|
||||
// Time
|
||||
'%H' => 'H',
|
||||
'%k' => function (\DateTimeInterface $timestamp, string $_): string {
|
||||
return sprintf('% 2u', $timestamp->format('G'));
|
||||
},
|
||||
'%I' => 'h',
|
||||
'%l' => function (\DateTimeInterface $timestamp, string $_): string {
|
||||
return sprintf('% 2u', $timestamp->format('g'));
|
||||
},
|
||||
'%M' => 'i',
|
||||
'%p' => 'A', // AM PM (this is reversed on purpose!)
|
||||
'%P' => 'a', // am pm
|
||||
'%r' => 'h:i:s A', // %I:%M:%S %p
|
||||
'%R' => 'H:i', // %H:%M
|
||||
'%S' => 's',
|
||||
'%T' => 'H:i:s', // %H:%M:%S
|
||||
'%X' => $intl_formatter, // Preferred time representation based on locale, without the date
|
||||
|
||||
// Timezone
|
||||
'%z' => 'O',
|
||||
'%Z' => 'T',
|
||||
|
||||
// Time and Date Stamps
|
||||
'%c' => $intl_formatter,
|
||||
'%D' => 'm/d/Y',
|
||||
'%F' => 'Y-m-d',
|
||||
'%s' => 'U',
|
||||
'%x' => $intl_formatter,
|
||||
];
|
||||
|
||||
$out = preg_replace_callback('/(?<!%)%([_#-]?)([a-zA-Z])/', function ($match) use ($translation_table, $timestamp) {
|
||||
$prefix = $match[1];
|
||||
$char = $match[2];
|
||||
$pattern = '%' . $char;
|
||||
if ($pattern == '%n') {
|
||||
return "\n";
|
||||
}
|
||||
if ($pattern == '%t') {
|
||||
return "\t";
|
||||
}
|
||||
|
||||
if (!isset($translation_table[$pattern])) {
|
||||
throw new \InvalidArgumentException(sprintf('Format "%s" is unknown in time format', $pattern), 1679091475);
|
||||
}
|
||||
|
||||
$replace = $translation_table[$pattern];
|
||||
|
||||
if (is_string($replace)) {
|
||||
$result = $timestamp->format($replace);
|
||||
} else {
|
||||
$result = $replace($timestamp, $pattern);
|
||||
}
|
||||
|
||||
return match ($prefix) {
|
||||
'_' => preg_replace('/\G0(?=.)/', ' ', $result),
|
||||
'#', '-' => preg_replace('/^0+(?=.)/', '', $result),
|
||||
default => $result,
|
||||
};
|
||||
}, $format);
|
||||
return str_replace('%%', '%', $out);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function convertPhpFormatToLuxon(string $phpFormat): string
|
||||
{
|
||||
$phpToLuxonMapping = [
|
||||
// Day
|
||||
'd' => 'dd',
|
||||
'D' => 'EEE',
|
||||
'j' => 'd',
|
||||
'l' => 'EEEE',
|
||||
'N' => 'E',
|
||||
'S' => 'o', // Note: Luxon doesn't directly support ordinal suffixes
|
||||
'w' => 'E', // Luxon uses 1-7 for day of the week (1 = Monday, 7 = Sunday)
|
||||
'z' => 'o',
|
||||
|
||||
// Week
|
||||
'W' => 'WW',
|
||||
|
||||
// Month
|
||||
'F' => 'MMMM',
|
||||
'm' => 'MM',
|
||||
'M' => 'MMM',
|
||||
'n' => 'M',
|
||||
't' => '', // Luxon doesn't have a token for the number of days in the month
|
||||
|
||||
// Year
|
||||
'L' => '', // Luxon doesn't have a token for leap year
|
||||
'o' => 'GGGG',
|
||||
'Y' => 'yyyy',
|
||||
'y' => 'yy',
|
||||
|
||||
// Time
|
||||
'a' => 'a',
|
||||
'A' => 'a',
|
||||
'B' => '', // Luxon doesn't support Swatch Internet time
|
||||
'g' => 'h',
|
||||
'G' => 'H',
|
||||
'h' => 'hh',
|
||||
'H' => 'HH',
|
||||
'i' => 'mm',
|
||||
's' => 'ss',
|
||||
'u' => 'SSS', // Note: Luxon uses SSS for milliseconds
|
||||
|
||||
// Timezone
|
||||
'e' => 'ZZZZ',
|
||||
'I' => '', // Luxon doesn't have a token for DST
|
||||
'O' => 'ZZ',
|
||||
'P' => 'ZZ',
|
||||
'p' => 'ZZ',
|
||||
'T' => 'ZZZ',
|
||||
'Z' => 'ZZ',
|
||||
|
||||
// Full Date/Time
|
||||
'c' => 'yyyy-MM-dd\'T\'HH:mm:ssZZ',
|
||||
'r' => 'EEE, dd MMM yyyy HH:mm:ss ZZ',
|
||||
'U' => 'X',
|
||||
];
|
||||
|
||||
$luxonFormat = '';
|
||||
$length = strlen($phpFormat);
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$char = $phpFormat[$i];
|
||||
$luxonFormat .= $phpToLuxonMapping[$char] ?? ("'" . $char . "'");
|
||||
}
|
||||
return $luxonFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateInterval $interval Date interval as generated by \DateTimeInterface::diff()
|
||||
* @param string $labels Translated label string, e.g. `$GLOBALS['LANG']->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.minutesHoursDaysYears')`
|
||||
*/
|
||||
public static function formatDateInterval(
|
||||
\DateInterval $interval,
|
||||
?string $labels = null,
|
||||
): string {
|
||||
$labels = GeneralUtility::trimExplode('|', $labels, true);
|
||||
if (count($labels) === 4) {
|
||||
$labels = array_merge($labels, $labels);
|
||||
} elseif (count($labels) !== 8) {
|
||||
$labels = ['min', 'hrs', 'days', 'yrs', 'min', 'hour', 'day', 'year'];
|
||||
}
|
||||
|
||||
$sign = $interval->invert ? '-' : '';
|
||||
|
||||
if ($interval->y > 0) {
|
||||
$years = $interval->y;
|
||||
$singular = $years === 1;
|
||||
return $sign . $years . ' ' . ($singular ? $labels[7] : $labels[3]);
|
||||
}
|
||||
|
||||
$days = $interval->days !== false && $interval->days > 0 ? $interval->days : $interval->d;
|
||||
if ($days > 0) {
|
||||
$singular = $days === 1;
|
||||
return $sign . $days . ' ' . ($singular ? $labels[6] : $labels[2]);
|
||||
}
|
||||
|
||||
if ($interval->h > 0) {
|
||||
$hours = $interval->h;
|
||||
$singular = $hours === 1;
|
||||
return $sign . $hours . ' ' . ($singular ? $labels[5] : $labels[1]);
|
||||
}
|
||||
|
||||
$minutes = $interval->i;
|
||||
$singular = $minutes === 1;
|
||||
if ($minutes === 0) {
|
||||
$sign = '';
|
||||
}
|
||||
return $sign . $minutes . ' ' . ($singular ? $labels[4] : $labels[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization\Event;
|
||||
|
||||
/**
|
||||
* Event that is fired after a file name has been mapped to a translation domain for all files
|
||||
* of a package.
|
||||
*
|
||||
* E.g. "vendor/myext.messages" is mapped to "EXT:myext/Resources/Private/Language/locallang.xlf"
|
||||
*
|
||||
* If you want this file to be a different location, e.g. for historical reasons, you can do this here.
|
||||
*/
|
||||
final class BeforeLabelResourceResolvedEvent
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $packageKey,
|
||||
public array $domains
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization\Event;
|
||||
|
||||
use Psr\Http\Message\UriInterface;
|
||||
|
||||
/**
|
||||
* Event to modify the main URL of a language
|
||||
*/
|
||||
final class ModifyLanguagePackRemoteBaseUrlEvent
|
||||
{
|
||||
public function __construct(private UriInterface $baseUrl, private readonly string $packageKey) {}
|
||||
|
||||
public function getBaseUrl(): UriInterface
|
||||
{
|
||||
return $this->baseUrl;
|
||||
}
|
||||
|
||||
public function setBaseUrl(UriInterface $baseUrl): void
|
||||
{
|
||||
$this->baseUrl = $baseUrl;
|
||||
}
|
||||
|
||||
public function getPackageKey(): string
|
||||
{
|
||||
return $this->packageKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization\Event;
|
||||
|
||||
/**
|
||||
* Event to modify the language pack array
|
||||
*/
|
||||
final class ModifyLanguagePacksEvent
|
||||
{
|
||||
public function __construct(private array $extensions) {}
|
||||
|
||||
public function getExtensions(): array
|
||||
{
|
||||
return $this->extensions;
|
||||
}
|
||||
|
||||
public function removeExtension(string $extension): void
|
||||
{
|
||||
unset($this->extensions[$extension]);
|
||||
}
|
||||
|
||||
public function removeIsoFromExtension(string $iso, string $extension): void
|
||||
{
|
||||
unset($this->extensions[$extension]['packs'][$iso]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization\Exception;
|
||||
|
||||
/**
|
||||
* File not found exception
|
||||
*/
|
||||
class FileNotFoundException extends \RuntimeException {}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization\Exception;
|
||||
|
||||
/**
|
||||
* Invalid Parser exception
|
||||
*/
|
||||
class InvalidParserException extends \RuntimeException {}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization\Exception;
|
||||
|
||||
/**
|
||||
* Invalid XML file exception
|
||||
*/
|
||||
class InvalidXmlFileException extends \RuntimeException {}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization;
|
||||
|
||||
use Psr\Http\Message\ResponseFactoryInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\StreamFactoryInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class JavaScriptLanguageDomainProvider
|
||||
{
|
||||
public function __construct(
|
||||
private LanguageServiceFactory $languageServiceFactory,
|
||||
private TranslationDomainResolver $translationDomainResolver,
|
||||
private ResponseFactoryInterface $responseFactory,
|
||||
private StreamFactoryInterface $streamFactory,
|
||||
) {}
|
||||
|
||||
public function createLanguageDomainResponse(string $domain, string $locale): ResponseInterface
|
||||
{
|
||||
$languageService = $this->languageServiceFactory->create($locale);
|
||||
$error = '';
|
||||
$allLabels = [];
|
||||
|
||||
if (!$this->translationDomainResolver->isValidDomainName($domain)) {
|
||||
// @todo check for unavailable domain name and deprecation state
|
||||
$error = 'throw new Error("Invalid domain name");';
|
||||
} else {
|
||||
$allLabels = $languageService->getLabelsFromResource($domain);
|
||||
}
|
||||
|
||||
$javaScriptModuleContents = implode("\n", [
|
||||
'import { LabelProvider } from "@typo3/backend/localization/label-provider.js";',
|
||||
$error,
|
||||
'export default new LabelProvider(',
|
||||
' ' . json_encode((object)$allLabels),
|
||||
');',
|
||||
]);
|
||||
|
||||
$lifetime = 3600 * 24 * 365;
|
||||
return $this->responseFactory->createResponse()
|
||||
->withHeader('Content-Type', 'text/javascript')
|
||||
->withHeader('Content-Length', (string)strlen($javaScriptModuleContents))
|
||||
->withHeader('Expires', gmdate('D, d M Y H:i:s T', (min($GLOBALS['EXEC_TIME'] + $lifetime, PHP_INT_MAX))))
|
||||
->withHeader('Cache-Control', 'private, max-age=' . $lifetime)
|
||||
->withBody(
|
||||
$this->streamFactory->createStream($javaScriptModuleContents)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class LabelBag
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public readonly array $arguments;
|
||||
|
||||
/**
|
||||
* @param string $key e.g. `LLL:EXT:core/Resources/Private/Language/Labels.xlf:HelloWorld`
|
||||
* @param string ...$arguments optional label arguments to be substituted
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $key,
|
||||
string ...$arguments
|
||||
) {
|
||||
$this->arguments = $arguments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles the given label key and substituted label arguments if given.
|
||||
*/
|
||||
public function compile(TranslatorInterface $translator): string
|
||||
{
|
||||
try {
|
||||
return $translator->label($this->key, $this->arguments, $this->key);
|
||||
} catch (\ValueError $e) {
|
||||
return sprintf(
|
||||
'Error: could not translate key "%s" and %d argument(s)!',
|
||||
$this->key,
|
||||
count($this->arguments)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
|
||||
use TYPO3\CMS\Core\Core\Environment;
|
||||
use TYPO3\CMS\Core\Localization\Exception\FileNotFoundException;
|
||||
use TYPO3\CMS\Core\Package\Exception\UnknownPackageException;
|
||||
use TYPO3\CMS\Core\Package\PackageManager;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Core\Utility\PathUtility;
|
||||
|
||||
/**
|
||||
* Service class for resolving label file paths and determining loading order.
|
||||
*
|
||||
* This class handles:
|
||||
* - Path resolving for localization files
|
||||
* - File name resolving with language variants
|
||||
* - Detecting resource override files via $GLOBALS['TYPO3_CONF_VARS']['LANG']['resourceOverrides']
|
||||
* - Determining file loading order without merging content
|
||||
*
|
||||
* This class does not handle reading of file contents, and also returns the full path,
|
||||
* so it does not care about caching. Should be handled at a more outer stage.
|
||||
*
|
||||
* @internal not part of TYPO3's public API.
|
||||
*/
|
||||
#[Autoconfigure(public: true)]
|
||||
readonly class LabelFileResolver
|
||||
{
|
||||
public function __construct(
|
||||
protected PackageManager $packageManager,
|
||||
protected TranslationDomainResolver $translationDomainResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Find all label files in a package, but also find overrides.
|
||||
* All files are returned in the order they should be loaded.
|
||||
*/
|
||||
public function getAllLabelFilesOfPackage(string $packageKey, $defaultLocaleOnlyForCacheWarmup = false): array
|
||||
{
|
||||
$result = [];
|
||||
try {
|
||||
$packagePath = $this->packageManager->getPackage($packageKey)->getPackagePath();
|
||||
} catch (UnknownPackageException) {
|
||||
throw new \InvalidArgumentException(sprintf('Package with key "%s" not found', $packageKey), 1760479988);
|
||||
}
|
||||
$directoriesToSearch = [
|
||||
'Resources/Private/Language/',
|
||||
'Configuration/Sets/',
|
||||
];
|
||||
$allowedFileExtensions = $this->getSupportedExtensions();
|
||||
$allowedFileExtensions = implode(',', $allowedFileExtensions);
|
||||
foreach ($directoriesToSearch as $searchPath) {
|
||||
$searchPath = $packagePath . $searchPath;
|
||||
$files = GeneralUtility::getAllFilesAndFoldersInPath([], $searchPath, $allowedFileExtensions);
|
||||
foreach ($files as $file) {
|
||||
$fileName = PathUtility::basename($file);
|
||||
$locale = $this->translationDomainResolver->getLocaleFromLanguageFile($fileName);
|
||||
if ($locale === null) {
|
||||
$locale = 'default';
|
||||
}
|
||||
if ($defaultLocaleOnlyForCacheWarmup && $locale !== 'default') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativeFilePath = substr($file, strlen($packagePath));
|
||||
$fileReference = 'EXT:' . $packageKey . '/' . $relativeFilePath;
|
||||
|
||||
if ($defaultLocaleOnlyForCacheWarmup) {
|
||||
$result[$locale][] = $fileReference;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$orderedFiles = $this->getOrderedFileResources($fileReference, $locale);
|
||||
if ($orderedFiles !== []) {
|
||||
if (!isset($result[$locale])) {
|
||||
$result[$locale] = [];
|
||||
}
|
||||
$result[$locale] = array_merge($result[$locale], $orderedFiles);
|
||||
}
|
||||
} catch (FileNotFoundException $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the actual files needed to resolve a file resource.
|
||||
* This should be used later-on directly in LocalizationFactory.
|
||||
*/
|
||||
protected function getOrderedFileResources(string $fileReference, string $locale): array
|
||||
{
|
||||
$result = [];
|
||||
try {
|
||||
$baseFile = $this->resolveFileReference($fileReference, $locale);
|
||||
if ($baseFile !== null) {
|
||||
$result[] = $baseFile;
|
||||
}
|
||||
} catch (FileNotFoundException $e) {
|
||||
|
||||
}
|
||||
$overrideFiles = $this->getOverrideFilePaths($fileReference, $locale);
|
||||
if ($overrideFiles !== []) {
|
||||
$result = array_merge($result, $overrideFiles);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws FileNotFoundException
|
||||
*/
|
||||
public function resolveFileReference(string $fileReference, string $locale): ?string
|
||||
{
|
||||
$actualSourcePath = $this->getAbsoluteFileReference($fileReference);
|
||||
if (PathUtility::isExtensionPath($fileReference)) {
|
||||
$actualSourcePath = $this->resolveExtensionResourcePath($actualSourcePath, $locale, $fileReference);
|
||||
}
|
||||
|
||||
if ($locale === 'default') {
|
||||
// The "default" (=base) locale must not contain other language entries.
|
||||
// Otherwise, when checking for a base locale file, it will hold an array of ALL
|
||||
// other language variants, and then due to alphabetical sorting, any language with a
|
||||
// first character AFTER "l" (locallang) would be regarded as the base entry.
|
||||
return $actualSourcePath;
|
||||
}
|
||||
|
||||
// Find localized file. If no localized version exists, return null.
|
||||
$localizedSourcePath = $this->resolveLocalizedFilePath($actualSourcePath, $locale);
|
||||
return $localizedSourcePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get override file paths for localization
|
||||
*
|
||||
* This method returns an array of override file paths that should be loaded
|
||||
* for the given file reference and language key. It supports both file path syntax
|
||||
* (e.g., 'EXT:core/Resources/Private/Language/locallang.xlf') and domain syntax
|
||||
* (e.g., 'core.messages').
|
||||
*
|
||||
* @return array<string> Array of absolute file paths to override files
|
||||
*/
|
||||
public function getOverrideFilePaths(string $fileReference, string $locale): array
|
||||
{
|
||||
if (!isset($GLOBALS['TYPO3_CONF_VARS']['LANG']['resourceOverrides'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$validOverrideFiles = [];
|
||||
$fileReferenceWithoutExtension = $this->getFileReferenceWithoutExtension($fileReference);
|
||||
$overrideFiles = $GLOBALS['TYPO3_CONF_VARS']['LANG']['resourceOverrides'];
|
||||
$supportedExtensions = $this->getSupportedExtensions();
|
||||
|
||||
// Build list of keys to check: file paths (with various extensions)
|
||||
$keysToCheck = [];
|
||||
foreach ($supportedExtensions as $extension) {
|
||||
$keysToCheck[] = $fileReferenceWithoutExtension . '.' . $extension;
|
||||
}
|
||||
|
||||
// Also add domain key for domain-syntax override support
|
||||
$domain = $this->translationDomainResolver->mapFileNameToDomain($fileReference);
|
||||
if ($domain !== $fileReference && $this->translationDomainResolver->isValidDomainName($domain)) {
|
||||
$keysToCheck[] = $domain;
|
||||
}
|
||||
|
||||
foreach ($keysToCheck as $key) {
|
||||
// Check language-specific overrides first
|
||||
if (isset($overrideFiles[$locale][$key]) && is_array($overrideFiles[$locale][$key])) {
|
||||
$validOverrideFiles = array_merge($validOverrideFiles, $overrideFiles[$locale][$key]);
|
||||
}
|
||||
// Check general overrides (applies to all languages)
|
||||
elseif (isset($overrideFiles[$key]) && is_array($overrideFiles[$key])) {
|
||||
$validOverrideFiles = array_merge($validOverrideFiles, $overrideFiles[$key]);
|
||||
}
|
||||
}
|
||||
$validOverrideFiles = array_unique($validOverrideFiles);
|
||||
|
||||
// Convert relative paths to absolute paths
|
||||
$absoluteOverrideFiles = [];
|
||||
foreach ($validOverrideFiles as $overrideFile) {
|
||||
if (PathUtility::isExtensionPath($overrideFile)) {
|
||||
$absoluteOverrideFiles[] = $overrideFile;
|
||||
} else {
|
||||
$absolutePath = GeneralUtility::getFileAbsFileName($overrideFile);
|
||||
if ($absolutePath) {
|
||||
$absoluteOverrideFiles[] = $absolutePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
$absoluteOverrideFiles = array_unique($absoluteOverrideFiles);
|
||||
|
||||
return $absoluteOverrideFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get absolute file reference
|
||||
*
|
||||
* @throws FileNotFoundException Source localization file not found.
|
||||
*/
|
||||
protected function getAbsoluteFileReference(string $fileReference): string
|
||||
{
|
||||
$fileReferenceWithoutExtension = $this->getFileReferenceWithoutExtension($fileReference);
|
||||
$supportedExtensions = $this->getSupportedExtensions();
|
||||
|
||||
foreach ($supportedExtensions as $extension) {
|
||||
$fullPath = GeneralUtility::getFileAbsFileName($fileReferenceWithoutExtension . '.' . $extension);
|
||||
if (@is_file($fullPath)) {
|
||||
return $fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
throw new FileNotFoundException(sprintf('Source localization file (%s) not found', $fileReference), 1306410755);
|
||||
}
|
||||
|
||||
public function getFileReferenceWithoutExtension(string $fileReference): string
|
||||
{
|
||||
return $this->translationDomainResolver->getFileReferenceWithoutExtension($fileReference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get localized labels path pattern for extensions
|
||||
*/
|
||||
protected function getLocalizedLabelsPathPattern(string $fileReference): string
|
||||
{
|
||||
if (!PathUtility::isExtensionPath($fileReference)) {
|
||||
throw new \InvalidArgumentException(sprintf('Invalid file reference configuration for the current file (%s)', $fileReference), 1635863703);
|
||||
}
|
||||
|
||||
$packageKey = $this->packageManager->extractPackageKeyFromPackagePath($fileReference);
|
||||
$relativeFileName = substr($fileReference, strlen($packageKey) + 5);
|
||||
$directory = dirname($relativeFileName);
|
||||
$fileName = basename($relativeFileName);
|
||||
|
||||
return sprintf(
|
||||
'/%%1$s/%s/%s%%1$s.%s',
|
||||
$packageKey,
|
||||
($directory !== '.' ? $directory . '/' : ''),
|
||||
$fileName
|
||||
);
|
||||
}
|
||||
|
||||
protected function getSupportedExtensions(): array
|
||||
{
|
||||
if (isset($GLOBALS['TYPO3_CONF_VARS']['LANG']['format']['priority']) && trim($GLOBALS['TYPO3_CONF_VARS']['LANG']['format']['priority']) !== '') {
|
||||
return GeneralUtility::trimExplode(',', $GLOBALS['TYPO3_CONF_VARS']['LANG']['format']['priority']);
|
||||
}
|
||||
return ['xlf'];
|
||||
}
|
||||
|
||||
protected function resolveExtensionResourcePath(string $sourcePath, string $locale, string $fileReference): string
|
||||
{
|
||||
$localizedLabelsPathPattern = $this->getLocalizedLabelsPathPattern($fileReference);
|
||||
$fileName = Environment::getLabelsPath() . sprintf($localizedLabelsPathPattern, $locale);
|
||||
|
||||
if (@is_file($fileName)) {
|
||||
return $fileName;
|
||||
}
|
||||
|
||||
// Fallback to source path if localized version doesn't exist
|
||||
return $sourcePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve localized file path by trying multiple location strategies.
|
||||
*
|
||||
* This method attempts to find localized versions of files in the following order:
|
||||
* 1. Check if the file already has the correct locale prefix (early return)
|
||||
* 2. Try same directory with locale prefix: "de.locallang.xlf" in same folder
|
||||
* 3. Try TYPO3 labels directory structure: "/var/labels/de/extension_key/path/de.filename.xlf"
|
||||
*
|
||||
* Language variant handling:
|
||||
* - Supports both underscore and hyphen variants: "de_CH" <-> "de-CH"
|
||||
* - Tests both formats when resolving files
|
||||
*
|
||||
* Examples:
|
||||
* - Source: "/ext/core/Resources/Private/Language/locallang.xlf"
|
||||
* - For locale "de": tries "de.locallang.xlf" in same dir, then "/var/labels/de/core/Resources/Private/Language/de.locallang.xlf"
|
||||
* - For locale "de-CH": tries both "de-CH.locallang.xlf" and "de_CH.locallang.xlf" variants
|
||||
*
|
||||
* @param string $sourcePath Absolute path to the source localization file
|
||||
* @param string $locale Language locale (e.g., "de", "de-CH", "de_AT")
|
||||
* @return ?string Absolute path to the localized file, or null if no localized version found
|
||||
*/
|
||||
protected function resolveLocalizedFilePath(string $sourcePath, string $locale): ?string
|
||||
{
|
||||
$possiblePrefixes = [$locale];
|
||||
if (str_contains($locale, '_')) {
|
||||
$possiblePrefixes[] = str_replace('_', '-', $locale);
|
||||
} elseif (str_contains($locale, '-')) {
|
||||
$possiblePrefixes[] = str_replace('-', '_', $locale);
|
||||
}
|
||||
$packageRootPaths = [];
|
||||
foreach ($this->packageManager->getActivePackages() as $package) {
|
||||
$packageRootPaths[$package->getPackageKey()] = $package->getPackagePath();
|
||||
}
|
||||
|
||||
foreach ($possiblePrefixes as $fileNamePrefix) {
|
||||
$fileName = PathUtility::basename($sourcePath);
|
||||
if (str_starts_with($fileName, $fileNamePrefix . '.')) {
|
||||
return $sourcePath;
|
||||
}
|
||||
|
||||
// Try same location first
|
||||
$sameLocationPath = str_replace($fileName, $fileNamePrefix . '.' . $fileName, $sourcePath);
|
||||
if (@is_file($sameLocationPath)) {
|
||||
return $sameLocationPath;
|
||||
}
|
||||
|
||||
// Try labels directory structure
|
||||
$relativePathInPackagePath = '';
|
||||
$extensionKey = null;
|
||||
foreach ($packageRootPaths as $packageKey => $packageRootPath) {
|
||||
if (str_starts_with($sourcePath, $packageRootPath)) {
|
||||
$relativePathInPackagePath = substr($sourcePath, strlen($packageRootPath));
|
||||
$extensionKey = $packageKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($relativePathInPackagePath === '' || $extensionKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$relativePathInPackagePath, $baseName] = GeneralUtility::revExplode('/', $relativePathInPackagePath, 2);
|
||||
$localizedPath = Environment::getLabelsPath() . '/' . $fileNamePrefix . '/' . $extensionKey . '/' . ($relativePathInPackagePath ? $relativePathInPackagePath . '/' : '') . $fileNamePrefix . '.' . $baseName;
|
||||
|
||||
if (@is_file($localizedPath)) {
|
||||
return $localizedPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization;
|
||||
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use TYPO3\CMS\Core\Core\Environment;
|
||||
use TYPO3\CMS\Core\Http\RequestFactory;
|
||||
use TYPO3\CMS\Core\Http\Uri;
|
||||
use TYPO3\CMS\Core\Information\Typo3Version;
|
||||
use TYPO3\CMS\Core\Localization\Event\ModifyLanguagePackRemoteBaseUrlEvent;
|
||||
use TYPO3\CMS\Core\Localization\Event\ModifyLanguagePacksEvent;
|
||||
use TYPO3\CMS\Core\Package\PackageManager;
|
||||
use TYPO3\CMS\Core\Registry;
|
||||
use TYPO3\CMS\Core\Service\Archive\ZipService;
|
||||
use TYPO3\CMS\Core\SystemResource\Publishing\SystemResourcePublisherInterface;
|
||||
use TYPO3\CMS\Core\SystemResource\SystemResourceFactory;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Service class handling language pack details
|
||||
* Used by 'manage language packs' module and 'language packs command'
|
||||
*
|
||||
* @internal This class is only meant to be used within EXT:core and is not part of the TYPO3 Core API.
|
||||
*/
|
||||
#[Autoconfigure(public: true)]
|
||||
readonly class LanguagePackService
|
||||
{
|
||||
private const string LANGUAGE_PACK_URL = 'https://localize.typo3.org/xliff/';
|
||||
|
||||
public function __construct(
|
||||
private EventDispatcherInterface $eventDispatcher,
|
||||
private RequestFactory $requestFactory,
|
||||
private LoggerInterface $logger,
|
||||
private SystemResourceFactory $resourceFactory,
|
||||
private SystemResourcePublisherInterface $resourcePublisher,
|
||||
private Locales $locales,
|
||||
private Registry $registry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get list of available languages
|
||||
*
|
||||
* @return array iso=>name
|
||||
*/
|
||||
public function getAvailableLanguages(): array
|
||||
{
|
||||
return $this->locales->getLanguages();
|
||||
}
|
||||
|
||||
/**
|
||||
* List of languages active in this instance
|
||||
* @return list<non-empty-string>
|
||||
*/
|
||||
public function getActiveLanguages(): array
|
||||
{
|
||||
$availableLanguages = $GLOBALS['TYPO3_CONF_VARS']['LANG']['availableLocales'] ?? [];
|
||||
return array_values(array_filter($availableLanguages));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an array with language details: active or not, iso codes, last update, ...
|
||||
*/
|
||||
public function getLanguageDetails(): array
|
||||
{
|
||||
$availableLanguages = $this->getAvailableLanguages();
|
||||
$activeLanguages = $this->getActiveLanguages();
|
||||
$languages = [];
|
||||
foreach ($availableLanguages as $iso => $name) {
|
||||
if ($iso === 'en') {
|
||||
continue;
|
||||
}
|
||||
$lastUpdate = $this->registry->get('languagePacks', $iso);
|
||||
$languages[] = [
|
||||
'iso' => $iso,
|
||||
'name' => $name,
|
||||
'active' => in_array($iso, $activeLanguages, true),
|
||||
'lastUpdate' => $this->getFormattedDate($lastUpdate),
|
||||
'dependencies' => $this->locales->getLocaleDependencies($iso),
|
||||
];
|
||||
}
|
||||
usort($languages, static function ($a, $b) {
|
||||
// Sort languages by name
|
||||
if ($a['name'] === $b['name']) {
|
||||
return 0;
|
||||
}
|
||||
return $a['name'] < $b['name'] ? -1 : 1;
|
||||
});
|
||||
return $languages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a list of loaded extensions and their language packs details
|
||||
*/
|
||||
public function getExtensionLanguagePackDetails(): array
|
||||
{
|
||||
$activeLanguages = $this->getActiveLanguages();
|
||||
$packageManager = GeneralUtility::makeInstance(PackageManager::class);
|
||||
$activePackages = $packageManager->getActivePackages();
|
||||
$extensions = [];
|
||||
foreach ($activePackages as $package) {
|
||||
$path = $package->getPackagePath();
|
||||
$finder = new Finder();
|
||||
try {
|
||||
$files = $finder->files()->ignoreUnreadableDirs()->in($path . 'Resources/Private/Language/')->name('*.xlf');
|
||||
if (!$files->hasResults()) {
|
||||
continue;
|
||||
}
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
// Dir does not exist
|
||||
continue;
|
||||
}
|
||||
$key = $package->getPackageKey();
|
||||
|
||||
$metaData = $package->getPackageMetaData();
|
||||
if ($metaData->isExcludedFromUpdates()) {
|
||||
continue;
|
||||
}
|
||||
$extension = [
|
||||
'key' => $key,
|
||||
'title' => $metaData->getTitle(),
|
||||
'type' => $metaData->getPackageType(),
|
||||
];
|
||||
$packageIcon = $package->getResources()->getPackageIcon();
|
||||
if ($packageIcon !== null) {
|
||||
$iconResource = $this->resourceFactory->createPublicResource($packageIcon);
|
||||
$extension['icon'] = (string)$this->resourcePublisher->generateUri($iconResource, null);
|
||||
}
|
||||
$extension['packs'] = [];
|
||||
foreach ($activeLanguages as $iso) {
|
||||
$isLanguagePackDownloaded = is_dir(Environment::getLabelsPath() . '/' . $iso . '/' . $key . '/');
|
||||
$lastUpdate = $this->registry->get('languagePacks', $iso . '-' . $key);
|
||||
$extension['packs'][$iso] = [
|
||||
'iso' => $iso,
|
||||
'exists' => $isLanguagePackDownloaded,
|
||||
'lastUpdate' => $this->getFormattedDate($lastUpdate),
|
||||
];
|
||||
}
|
||||
$extensions[$key] = $extension;
|
||||
}
|
||||
ksort($extensions);
|
||||
$event = $this->eventDispatcher->dispatch(new ModifyLanguagePacksEvent($extensions));
|
||||
return $event->getExtensions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and unpack a single language pack of one extension.
|
||||
*
|
||||
* @param string $key Extension key
|
||||
* @param string $iso Language iso code
|
||||
* @return string One of 'update', 'new', 'skipped' or 'failed'
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public function languagePackDownload(string $key, string $iso): string
|
||||
{
|
||||
// Sanitize extension and iso code
|
||||
$availableLanguages = $this->getAvailableLanguages();
|
||||
$activeLanguages = $this->getActiveLanguages();
|
||||
if (!array_key_exists($iso, $availableLanguages) || !in_array($iso, $activeLanguages, true)) {
|
||||
throw new \RuntimeException('Language iso code ' . (string)$iso . ' not available or active', 1520117054);
|
||||
}
|
||||
$packageManager = GeneralUtility::makeInstance(PackageManager::class);
|
||||
$package = $packageManager->getActivePackages()[$key] ?? null;
|
||||
if (!$package) {
|
||||
throw new \RuntimeException('Extension ' . (string)$key . ' not loaded', 1520117245);
|
||||
}
|
||||
|
||||
$languagePackBaseUrl = self::LANGUAGE_PACK_URL;
|
||||
|
||||
// Allow to modify the base url on the fly
|
||||
$event = $this->eventDispatcher->dispatch(new ModifyLanguagePackRemoteBaseUrlEvent(new Uri($languagePackBaseUrl), $key));
|
||||
$languagePackBaseUrl = $event->getBaseUrl();
|
||||
$majorVersion = GeneralUtility::makeInstance(Typo3Version::class)->getMajorVersion();
|
||||
if ($package->getPackageMetaData()->isFrameworkType()) {
|
||||
// This is a system extension and the package URL should be adapted to have different packs per core major version
|
||||
// https://localize.typo3.org/xliff/b/a/backend-l10n/backend-l10n-fr.v9.zip
|
||||
$packageUrl = $key[0] . '/' . $key[1] . '/' . $key . '-l10n/' . $key . '-l10n-' . $iso . '.v' . $majorVersion . '.zip';
|
||||
} else {
|
||||
// Typical non sysext path, Hungarian:
|
||||
// https://localize.typo3.org/xliff/a/n/anextension-l10n/anextension-l10n-hu.zip
|
||||
$packageUrl = $key[0] . '/' . $key[1] . '/' . $key . '-l10n/' . $key . '-l10n-' . $iso . '.zip';
|
||||
}
|
||||
|
||||
$absoluteLanguagePath = Environment::getLabelsPath() . '/' . $iso . '/';
|
||||
$absoluteExtractionPath = $absoluteLanguagePath . $key . '/';
|
||||
$absolutePathToZipFile = Environment::getVarPath() . '/transient/' . $key . '-l10n-' . $iso . '.zip';
|
||||
|
||||
$packExists = is_dir($absoluteExtractionPath);
|
||||
|
||||
$packResult = $packExists ? 'update' : 'new';
|
||||
|
||||
$operationResult = false;
|
||||
|
||||
$response = $this->requestFactory->request($languagePackBaseUrl . $packageUrl, 'GET', ['http_errors' => false]);
|
||||
if ($response->getStatusCode() === 200) {
|
||||
$languagePackContent = $response->getBody()->getContents();
|
||||
if (!empty($languagePackContent)) {
|
||||
$operationResult = true;
|
||||
if ($packExists) {
|
||||
$operationResult = GeneralUtility::rmdir($absoluteExtractionPath, true);
|
||||
}
|
||||
if ($operationResult) {
|
||||
GeneralUtility::mkdir_deep(Environment::getVarPath() . '/transient/');
|
||||
$operationResult = GeneralUtility::writeFileToTypo3tempDir($absolutePathToZipFile, $languagePackContent) === null;
|
||||
}
|
||||
$this->unzipTranslationFile($absolutePathToZipFile, $absoluteLanguagePath);
|
||||
if ($operationResult) {
|
||||
$operationResult = unlink($absolutePathToZipFile);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->logger->warning('Requesting {request} was not successful, got status code {status} ({reason})', [
|
||||
'request' => $languagePackBaseUrl . $packageUrl,
|
||||
'status' => $response->getStatusCode(),
|
||||
'reason' => $response->getReasonPhrase(),
|
||||
]);
|
||||
}
|
||||
if (!$operationResult) {
|
||||
$packResult = 'failed';
|
||||
$this->registry->set('languagePacks', $iso . '-' . $key, time());
|
||||
}
|
||||
return $packResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set 'last update' timestamp in registry for a series of iso codes.
|
||||
*
|
||||
* @param string[] $isos List of iso code timestamps to set
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public function setLastUpdatedIsoCode(array $isos): void
|
||||
{
|
||||
$activeLanguages = $GLOBALS['TYPO3_CONF_VARS']['LANG']['availableLocales'] ?? [];
|
||||
foreach ($isos as $iso) {
|
||||
if (!in_array($iso, $activeLanguages, true)) {
|
||||
throw new \RuntimeException('Language iso code ' . (string)$iso . ' not available or active', 1520176318);
|
||||
}
|
||||
$this->registry->set('languagePacks', $iso, time());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp to a formatted date string
|
||||
*/
|
||||
private function getFormattedDate(?int $timestamp): ?string
|
||||
{
|
||||
if (is_int($timestamp)) {
|
||||
$date = (new \DateTime())->setTimestamp($timestamp);
|
||||
$format = $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] . ' ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'];
|
||||
$timestamp = $date->format($format);
|
||||
}
|
||||
return $timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unzip a language zip file
|
||||
*
|
||||
* @param string $file path to zip file
|
||||
* @param string $path path to extract to
|
||||
*/
|
||||
private function unzipTranslationFile(string $file, string $path): void
|
||||
{
|
||||
if (!is_dir($path)) {
|
||||
GeneralUtility::mkdir_deep($path);
|
||||
}
|
||||
|
||||
$zipService = GeneralUtility::makeInstance(ZipService::class);
|
||||
if ($zipService->verify($file)) {
|
||||
$zipService->extract($file, $path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
|
||||
use TYPO3\CMS\Core\TypoScript\FrontendTypoScript;
|
||||
use TYPO3\CMS\Core\TypoScript\TypoScriptService;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Core\Utility\PathUtility;
|
||||
|
||||
/**
|
||||
* Main API to fetch labels from XLF (label files) based on the current system
|
||||
* language of TYPO3. It is able to resolve references to files + their pointers to the
|
||||
* proper language. If you see something about "LLL", this class does the trick for you. It
|
||||
* is not related to language handling of content, but rather of labels for plugins.
|
||||
*
|
||||
* Usually this is injected into $GLOBALS['LANG'] when in backend or CLI context, and
|
||||
* populated by the current backend user. Do not rely on $GLOBAL['LANG'] in frontend, as it is only
|
||||
* available under certain circumstances!
|
||||
*
|
||||
* As TYPO3 internally does not match the proper ISO locale standard, the "locale" here
|
||||
* is actually a list of supported language keys, (see Locales class), whereas "English"
|
||||
* is always the fallback ("default language").
|
||||
*
|
||||
* Further usages on setting up your own LanguageService in BE:
|
||||
*
|
||||
* ```
|
||||
* $languageService = GeneralUtility::makeInstance(LanguageServiceFactory::class)
|
||||
* ->createFromUserPreferences($GLOBALS['BE_USER']);
|
||||
* ```
|
||||
*
|
||||
* @phpstan-import-type TranslationLabel from LocalizationFactory
|
||||
* @phpstan-type TranslationFile array<string, TranslationLabel>
|
||||
* @phpstan-type LabelOverrides array<string, string>
|
||||
* @phpstan-type TypoScriptLabels array<string, LabelOverrides>
|
||||
*/
|
||||
#[Exclude]
|
||||
class LanguageService implements TranslatorInterface
|
||||
{
|
||||
/**
|
||||
* This is set to the language which is currently running for the user
|
||||
*/
|
||||
public string $lang = 'en';
|
||||
|
||||
protected ?Locale $locale = null;
|
||||
|
||||
/**
|
||||
* @var array<string, TranslationFile>
|
||||
*/
|
||||
protected array $overrideLabels = [];
|
||||
|
||||
/**
|
||||
* @internal use LanguageServiceFactory instead
|
||||
*/
|
||||
public function __construct(
|
||||
protected Locales $locales,
|
||||
protected readonly LocalizationFactory $localizationFactory,
|
||||
protected readonly FrontendInterface $runtimeCache
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initializes the language to fetch XLF labels for.
|
||||
*
|
||||
* ```
|
||||
* $languageService = GeneralUtility::makeInstance(LanguageServiceFactory::class)
|
||||
* ->createFromUserPreferences($GLOBALS['BE_USER']);
|
||||
* ```
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
* @param Locale|string $languageKey The language key (two character string from backend users profile)
|
||||
* @internal use one of the factory methods instead
|
||||
*/
|
||||
public function init(Locale|string $languageKey): void
|
||||
{
|
||||
if ($languageKey instanceof Locale) {
|
||||
$this->locale = $languageKey;
|
||||
} else {
|
||||
$this->locale = $this->locales->createLocale($languageKey);
|
||||
}
|
||||
$this->lang = $this->getTypo3LanguageKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the label with key $index from the $LOCAL_LANG array used as the second argument
|
||||
*
|
||||
* @param string $index Label key
|
||||
* @param TranslationFile $localLanguage $LOCAL_LANG array to get label key from
|
||||
*/
|
||||
protected function getLLL(string $index, array $localLanguage, bool $returnNullIfNotSet = false): ?string
|
||||
{
|
||||
if (isset($localLanguage[$this->lang][$index])) {
|
||||
$value = is_string($localLanguage[$this->lang][$index])
|
||||
? $localLanguage[$this->lang][$index]
|
||||
: $localLanguage[$this->lang][$index][0];
|
||||
} else {
|
||||
$value = $returnNullIfNotSet ? null : '';
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main and most often used method.
|
||||
*
|
||||
* Resolve strings like these:
|
||||
*
|
||||
* ```
|
||||
* 'LLL:EXT:core/Resources/Private/Language/locallang_custom.xlf:labels.depth_0'
|
||||
* 'LLL:core.custom:labels.depth_0'
|
||||
* 'core.custom:labels.depth_0' // LLL: prefix is optional
|
||||
* ```
|
||||
*
|
||||
* This looks up the given .xlf file path or translation domain in the 'core' extension for label labels.depth_0
|
||||
*
|
||||
* The LLL: prefix is optional. If the input contains a colon (:), it will be treated as a label reference.
|
||||
* If no colon is found, the input string is returned as-is (constant non-localizable label).
|
||||
*
|
||||
* Only the plain string contents of a language key, like "Record title: %s" are returned.
|
||||
* Placeholder interpolation must be performed separately, for example via `sprintf()`, like
|
||||
* `LocalizationUtility::translate()` does internally (which should only be used in Extbase
|
||||
* context)
|
||||
*
|
||||
* Example:
|
||||
* Label is defined in `EXT:my_ext/Resources/Private/Language/locallang.xlf` as:
|
||||
*
|
||||
* ```
|
||||
* <trans-unit id="downloaded_times">
|
||||
* <source>downloaded %d times from %s locations</source>
|
||||
* </trans-unit>
|
||||
* ```
|
||||
*
|
||||
* The following code example assumes `$this->request` to hold the current request object.
|
||||
* There are several ways to create the LanguageService using the Factory, depending on the
|
||||
* context. Please adjust this example to your use case:
|
||||
*
|
||||
* ```
|
||||
* $language = $this->request->getAttribute('language');
|
||||
* $languageService =
|
||||
* GeneralUtility::makeInstance(LanguageServiceFactory::class)
|
||||
* ->createFromSiteLanguage($language);
|
||||
* $label = sprintf(
|
||||
* $languageService->sL(
|
||||
* 'LLL:EXT:my_ext/Resources/Private/Language/locallang.xlf:downloaded_times'
|
||||
* ),
|
||||
* 27,
|
||||
* 'several'
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* This will result in `$label` to contain `'downloaded 27 times from several locations'`.
|
||||
*
|
||||
* @param string $input Label key/reference
|
||||
* @see LocalizationUtility::translate()
|
||||
*/
|
||||
public function sL($input): string
|
||||
{
|
||||
$input = (string)$input;
|
||||
// early return for empty input to avoid cache and language file reading on first hit.
|
||||
if ($input === '') {
|
||||
return $input;
|
||||
}
|
||||
|
||||
$trimmedInput = trim($input);
|
||||
$hasLLLPrefix = str_starts_with($trimmedInput, 'LLL:');
|
||||
$restStr = $trimmedInput;
|
||||
|
||||
// Remove the LLL: prefix if present
|
||||
if ($hasLLLPrefix) {
|
||||
$restStr = substr($trimmedInput, 4);
|
||||
}
|
||||
|
||||
$extensionPrefix = '';
|
||||
// Check if ll-file is referred to by extension path (EXT:)
|
||||
if (PathUtility::isExtensionPath(trim($restStr))) {
|
||||
$restStr = substr(trim($restStr), 4);
|
||||
$extensionPrefix = 'EXT:';
|
||||
}
|
||||
|
||||
$parts = explode(':', trim($restStr), 2);
|
||||
if (isset($parts[1])) {
|
||||
// Handle both domain references and file paths
|
||||
if ($extensionPrefix === '') {
|
||||
// This could be a domain reference (e.g., "core.tabs:general")
|
||||
// The file path resolution happens in LocalizationFactory
|
||||
$fileReference = $parts[0];
|
||||
} else {
|
||||
// Traditional EXT: file path
|
||||
$fileReference = $extensionPrefix . $parts[0];
|
||||
}
|
||||
$result = (string)$this->translate($parts[1], $fileReference);
|
||||
if ($hasLLLPrefix) {
|
||||
return $result;
|
||||
}
|
||||
// If LLL: prefix was not used, we return the input as-is if no translation was found
|
||||
return $result !== '' ? $result : $input;
|
||||
|
||||
}
|
||||
|
||||
// No colon found
|
||||
// If LLL: prefix was used, return empty string (original behavior for invalid references)
|
||||
// Otherwise, return input as-is (constant non-localizable label)
|
||||
return $hasLLLPrefix ? '' : $input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a label by its full reference string.
|
||||
*
|
||||
* Resolves TYPO3 label reference strings in the formats:
|
||||
*
|
||||
* 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_0'
|
||||
* 'EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_0'
|
||||
* 'core.messages:labels.depth_0'
|
||||
*
|
||||
* The LLL: prefix is optional and stripped before resolution.
|
||||
*
|
||||
* Unlike sL(), this method:
|
||||
* - Returns null when the label reference cannot be resolved
|
||||
* - Supports argument interpolation (sprintf-style or ICU MessageFormat)
|
||||
* - Supports locale overrides per call
|
||||
* - Supports a default value fallback
|
||||
*/
|
||||
public function label(string $reference, array $arguments = [], ?string $default = null, Locale|string|null $locale = null): string|\Stringable|null
|
||||
{
|
||||
$reference = trim($reference);
|
||||
if ($reference === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
// Remove the LLL: prefix if present
|
||||
if (str_starts_with($reference, 'LLL:')) {
|
||||
$reference = substr($reference, 4);
|
||||
}
|
||||
|
||||
$extensionPrefix = '';
|
||||
if (PathUtility::isExtensionPath($reference)) {
|
||||
$reference = substr($reference, 4);
|
||||
$extensionPrefix = 'EXT:';
|
||||
}
|
||||
|
||||
$parts = explode(':', $reference, 2);
|
||||
if (!isset($parts[1])) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$domain = $extensionPrefix !== '' ? $extensionPrefix . $parts[0] : $parts[0];
|
||||
return $this->translate($parts[1], $domain, $arguments, $default, $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a label by its identifier and domain.
|
||||
*
|
||||
* This is different from sL() as it can also return null, and expects a domain (can be a file reference as well).
|
||||
* NULL is returned when the "id" is not found.
|
||||
*
|
||||
* @param string $id The label identifier/key
|
||||
* @param string $domain The translation domain (file reference like 'EXT:core/Resources/Private/Language/locallang.xlf'
|
||||
* or semantic domain like 'core.messages'). For ICU MessageFormat, suffix with '+intl-icu'.
|
||||
* @param array $arguments Optional arguments for placeholder replacement. For sprintf-style messages,
|
||||
* pass indexed values. For ICU messages, pass named values (e.g., ['count' => 5]).
|
||||
* @param string|null $default Optional default value
|
||||
* @param Locale|string|null $locale Optional locale override. If null, uses the service's configured locale.
|
||||
* @return string|\Stringable|null The translated string, or null if the label was not found
|
||||
*/
|
||||
public function translate(string $id, string $domain, array $arguments = [], ?string $default = null, Locale|string|null $locale = null): string|\Stringable|null
|
||||
{
|
||||
$cacheIdentifier = 'labels_' . $this->locale . '_' . md5($domain . ':' . $id);
|
||||
$result = $this->runtimeCache->get($cacheIdentifier);
|
||||
if (!is_string($result) && !is_null($result)) {
|
||||
// Only log deprecations when the label is written to the cache for the first time
|
||||
if (str_ends_with($id, '.x-unused')) {
|
||||
trigger_error(
|
||||
'Label reference ' . $id . ' in domain ' . $domain . ' is deprecated.',
|
||||
E_USER_DEPRECATED
|
||||
);
|
||||
}
|
||||
$labelsFromDomain = $this->readLLfile($domain);
|
||||
if (is_array($this->overrideLabels[$domain] ?? null)) {
|
||||
$labelsFromDomain = array_replace_recursive($labelsFromDomain, $this->overrideLabels[$domain]);
|
||||
}
|
||||
$result = $this->getLLL($id, $labelsFromDomain, true);
|
||||
if ($result === null) {
|
||||
$result = $this->getLLL($id . '.x-unused', $labelsFromDomain, true);
|
||||
if ($result !== null) {
|
||||
// Only log deprecations when the label is written to the cache for the first time
|
||||
trigger_error(
|
||||
'Label reference ' . $id . ' in domain ' . $domain . ' is deprecated.',
|
||||
E_USER_DEPRECATED
|
||||
);
|
||||
}
|
||||
}
|
||||
// Check if a value was explicitly set to "" via TypoScript, if so, we need to ensure that this is "" and not null
|
||||
if (isset($this->overrideLabels[$domain][$id]) && $this->overrideLabels[$domain][$id] === '') {
|
||||
$result = '';
|
||||
}
|
||||
$this->runtimeCache->set($cacheIdentifier, $result);
|
||||
}
|
||||
if ($result === '' || $result === null) {
|
||||
return $default !== null ? $default : $result;
|
||||
}
|
||||
if ($arguments !== []) {
|
||||
// Check if we should use ICU format (when using named arguments)
|
||||
if (!array_is_list($arguments)) {
|
||||
return $this->formatIcuMessage($result, $arguments);
|
||||
}
|
||||
|
||||
// Use sprintf format (positional arguments with numeric keys)
|
||||
try {
|
||||
// We use vsprintf() over sprintf() here on purpose.
|
||||
// The reason is that only sprintf() will return an error message if the number of arguments does not match
|
||||
// the number of placeholders in the format string. Whereas, vsprintf would silently return nothing.
|
||||
return vsprintf($result, $arguments);
|
||||
} catch (\ValueError $e) {
|
||||
// @todo: we could at some point add a logger or a custom exception if needed, and hand over the $result differently
|
||||
throw new \ValueError($result, 1765396511, $e);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a message using ICU MessageFormat.
|
||||
* This supports plural forms, select patterns, and other ICU MessageFormat features.
|
||||
*
|
||||
* Example message: "{count, plural, one {# file} other {# files}}"
|
||||
* Example arguments: ['count' => 5]
|
||||
* Result: "5 files"
|
||||
*/
|
||||
private function formatIcuMessage(string $message, array $arguments): string
|
||||
{
|
||||
$locale = $this->locale?->posixFormatted() ?? 'en_US';
|
||||
$formatted = \MessageFormatter::formatMessage($locale, $message, $arguments);
|
||||
if ($formatted === false) {
|
||||
// If formatting fails, return the original message
|
||||
// This can happen with invalid ICU patterns
|
||||
return $message;
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates prepared labels which are handed in, and also uses the fallback if no language is given.
|
||||
* This is common in situations such as page TSconfig where labels or references to labels are used.
|
||||
* @internal not part of TYPO3 Core API for the time being.
|
||||
*/
|
||||
public function translateLabel(array|string $input, string $fallback): string
|
||||
{
|
||||
if (is_array($input) && isset($input[$this->lang])) {
|
||||
return $this->sL((string)$input[$this->lang]);
|
||||
}
|
||||
if (is_string($input)) {
|
||||
return $this->sL($input);
|
||||
}
|
||||
return $this->sL($fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all labels from a resource/file and returns them in a translated fashion.
|
||||
* @return array<string, string>
|
||||
* @internal not part of TYPO3 Core API for the time being.
|
||||
*/
|
||||
public function getLabelsFromResource(string $fileReferenceOrDomain): array
|
||||
{
|
||||
$labelArray = [];
|
||||
$labelsFromFile = $this->readLLfile($fileReferenceOrDomain);
|
||||
foreach ($labelsFromFile['default'] as $key => $value) {
|
||||
$labelArray[$key] = $this->getLLL($key, $labelsFromFile);
|
||||
}
|
||||
return $labelArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Includes a locallang file and returns the labels found inside.
|
||||
*
|
||||
* @param string $fileReferenceOrDomain Input is a file-reference to be a 'local_lang' file containing a $LOCAL_LANG array
|
||||
* @return TranslationFile value of $LOCAL_LANG found in the included file, empty if none found
|
||||
*/
|
||||
protected function readLLfile(string $fileReferenceOrDomain): array
|
||||
{
|
||||
// Translate a possible domain into a fileReference
|
||||
$cacheIdentifier = 'labels_file_' . md5($fileReferenceOrDomain . (string)$this->locale);
|
||||
$cacheEntry = $this->runtimeCache->get($cacheIdentifier);
|
||||
if (is_array($cacheEntry)) {
|
||||
return $cacheEntry;
|
||||
}
|
||||
$mainLanguageKey = $this->getTypo3LanguageKey();
|
||||
|
||||
$allLabels = [
|
||||
$mainLanguageKey => $this->localizationFactory->getParsedData($fileReferenceOrDomain, $this->locale),
|
||||
];
|
||||
if (!isset($allLabels['default'])) {
|
||||
// Ensure default labels are additionally set.
|
||||
// @todo: Remove with use of Symfony Translator catalogue format.
|
||||
// Replace the use of 'array-keys' of 'default' in LanguageService::getLabelsFromResource()
|
||||
$allLabels['default'] = $this->localizationFactory->getParsedData($fileReferenceOrDomain, 'default');
|
||||
}
|
||||
|
||||
$this->runtimeCache->set($cacheIdentifier, $allLabels);
|
||||
return $allLabels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define custom labels which can be overridden for a given file. This is typically
|
||||
* the case for TypoScript plugins.
|
||||
*
|
||||
* @param TypoScriptLabels $labels
|
||||
*/
|
||||
public function overrideLabels(string $fileRef, array $labels): void
|
||||
{
|
||||
/** @var TypoScriptLabels $localLanguage */
|
||||
$localLanguage = [
|
||||
// Default is kept for fallback purposes when coming from TypoScript
|
||||
'en' => $labels['en'] ?? $labels['default'] ?? [],
|
||||
];
|
||||
$mainLanguageKey = $this->getTypo3LanguageKey();
|
||||
// Special handling for legacy reasons:
|
||||
// Default and EN were historically the same. It is valid though to have an EN(-XX)-XLF translation.
|
||||
// Therefore, copy the overrides of "default" over to "en-*", if no specific overrides exist for this yet.
|
||||
if (str_starts_with($mainLanguageKey, 'en') && !isset($labels[$mainLanguageKey])) {
|
||||
$localLanguage[$mainLanguageKey] = $localLanguage['en'];
|
||||
}
|
||||
if ($mainLanguageKey !== 'default') {
|
||||
$allLocales = array_merge([$mainLanguageKey], $this->locale->getDependencies());
|
||||
$allLocales = array_unique($allLocales);
|
||||
$allLocales = array_reverse($allLocales);
|
||||
foreach ($allLocales as $language) {
|
||||
if (isset($labels[$language])) {
|
||||
$localLanguage[$mainLanguageKey] = array_replace_recursive($localLanguage[$mainLanguageKey] ?? [], $labels[$language]);
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->overrideLabels[$fileRef] = $localLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrites labels that are set via TypoScript.
|
||||
*
|
||||
* TS labels have to be configured like:
|
||||
* plugin.tx_myextension._LOCAL_LANG.languageKey.key = value
|
||||
*
|
||||
* @internal not part of TYPO3 Core API.
|
||||
* @return TypoScriptLabels
|
||||
*/
|
||||
public function loadTypoScriptLabelsFromExtension(string $extensionName, FrontendTypoScript $typoScript, string $pluginName = ''): array
|
||||
{
|
||||
$extensionName = str_replace('_', '', $extensionName);
|
||||
$extensionName = strtolower($extensionName);
|
||||
|
||||
$allLabels = $typoScript->getSetupArray()['plugin.']['tx_' . $extensionName . '.']['_LOCAL_LANG.'] ?? [];
|
||||
if ($pluginName !== '') {
|
||||
$allLabels = array_replace_recursive(
|
||||
$allLabels,
|
||||
$typoScript->getSetupArray()['plugin.']['tx_' . $extensionName . '_' . strtolower($pluginName) . '.']['_LOCAL_LANG.'] ?? [],
|
||||
);
|
||||
}
|
||||
$typoScriptService = GeneralUtility::makeInstance(TypoScriptService::class);
|
||||
$allLabels = $typoScriptService->convertTypoScriptArrayToPlainArray($allLabels);
|
||||
$finalLabels = [];
|
||||
foreach ($allLabels as $languageKey => $labels) {
|
||||
foreach ($labels ?? [] as $labelKey => $labelValue) {
|
||||
if (is_string($labelValue)) {
|
||||
$finalLabels[$languageKey][$labelKey] = $labelValue;
|
||||
} elseif (is_array($labelValue)) {
|
||||
$labelValue = $typoScriptService->flattenTypoScriptLabelArray($labelValue, $labelKey);
|
||||
foreach ($labelValue as $key => $value) {
|
||||
$finalLabels[$languageKey][$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $finalLabels;
|
||||
}
|
||||
|
||||
public function getLocale(): ?Locale
|
||||
{
|
||||
return $this->locale;
|
||||
}
|
||||
|
||||
private function getTypo3LanguageKey(): string
|
||||
{
|
||||
return $this->locale?->getName() ?? 'en';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization;
|
||||
|
||||
use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
|
||||
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
|
||||
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
|
||||
|
||||
readonly class LanguageServiceFactory
|
||||
{
|
||||
public function __construct(
|
||||
protected Locales $locales,
|
||||
protected LocalizationFactory $localizationFactory,
|
||||
protected FrontendInterface $runtimeCache
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Factory method to create a language service object.
|
||||
*
|
||||
* @param Locale|string $locale the locale
|
||||
*/
|
||||
public function create(Locale|string $locale): LanguageService
|
||||
{
|
||||
$obj = new LanguageService($this->locales, $this->localizationFactory, $this->runtimeCache);
|
||||
$obj->init($locale instanceof Locale ? $locale : $this->locales->createLocale($locale));
|
||||
return $obj;
|
||||
}
|
||||
|
||||
public function createFromUserPreferences(?AbstractUserAuthentication $user): LanguageService
|
||||
{
|
||||
return $this->create($this->locales->createLocaleFromUserPreferences($user));
|
||||
}
|
||||
|
||||
public function createFromSiteLanguage(SiteLanguage $language): LanguageService
|
||||
{
|
||||
// createLocale from a string takes care of resolving the automatic dependencies of e.g. "de_AT" to also check for "de"
|
||||
// and also validates if TYPO3 supports the original language (at least in TYPO3 v12, there is a fixed list of
|
||||
// allowed language keys)
|
||||
return $this->create((string)$language->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a LanguageService based on the current backend user's preferences.
|
||||
*
|
||||
* @internal don't use this in your own code, as we implicitly have a global dependency here.
|
||||
*/
|
||||
public function createForBackendUser(): LanguageService
|
||||
{
|
||||
return $this->createFromUserPreferences($GLOBALS['BE_USER'] ?? null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization\Loader;
|
||||
|
||||
use Symfony\Component\Translation\Exception\InvalidResourceException;
|
||||
use Symfony\Component\Translation\Loader\LoaderInterface;
|
||||
use Symfony\Component\Translation\MessageCatalogue;
|
||||
use TYPO3\CMS\Core\Localization\Exception\InvalidXmlFileException;
|
||||
|
||||
/**
|
||||
* TYPO3-specific XLIFF file loader that implements Symfony's LoaderInterface
|
||||
* while maintaining TYPO3's specific features like approval states and plurals.
|
||||
*
|
||||
* This loader only converts ONE SINGLE FILE. It is not responsible for detecting the file location of
|
||||
* translation! This is done - currently - by the LocalizationFactory and should be moved to a dedicated class.
|
||||
*
|
||||
* This loader expects that the source is always english, and the translation is something else.
|
||||
*
|
||||
* @internal This class is not part of the TYPO3 Core API.
|
||||
*/
|
||||
final readonly class XliffLoader implements LoaderInterface
|
||||
{
|
||||
public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue
|
||||
{
|
||||
if (!is_string($resource)) {
|
||||
throw new InvalidXmlFileException('XliffLoader only accepts string types (file names, XML content).', 1757588428);
|
||||
}
|
||||
|
||||
if (!$this->isXmlString($resource)) {
|
||||
if (!file_exists($resource)) {
|
||||
throw new InvalidXmlFileException('File "' . $resource . '" not found.', 1757588429);
|
||||
}
|
||||
|
||||
if (!is_file($resource)) {
|
||||
throw new InvalidResourceException('The given resource is neither a file nor an XLIFF string "' . $resource . '".', 1757588430);
|
||||
}
|
||||
}
|
||||
|
||||
$rootXmlNode = $this->convertResourceToXml($resource);
|
||||
return $this->parseXliffFromRoot($rootXmlNode, $locale, $domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse XLIFF file and return TYPO3-compatible data structure
|
||||
*/
|
||||
private function convertResourceToXml(string $resource): \SimpleXMLElement
|
||||
{
|
||||
if ($this->isXmlString($resource)) {
|
||||
$xmlContent = $resource;
|
||||
} else {
|
||||
$xmlContent = @file_get_contents($resource);
|
||||
if ($xmlContent === false) {
|
||||
throw new InvalidXmlFileException('The path provided does not point to an existing and accessible file.', 1757537341);
|
||||
}
|
||||
}
|
||||
$rootXmlNode = @simplexml_load_string($xmlContent, \SimpleXMLElement::class, LIBXML_NOWARNING);
|
||||
if ($rootXmlNode === false) {
|
||||
$xmlError = libxml_get_last_error();
|
||||
throw new InvalidXmlFileException(
|
||||
'The path provided does not point to an existing and accessible well-formed XML file. Reason: ' . $xmlError->message . ' in ' . $resource . ', line ' . $xmlError->line,
|
||||
1757537342
|
||||
);
|
||||
}
|
||||
return $rootXmlNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse XLIFF content from root element and build a catalogue
|
||||
*/
|
||||
private function parseXliffFromRoot(\SimpleXMLElement $root, string $locale, string $domain): MessageCatalogue
|
||||
{
|
||||
$catalogue = new MessageCatalogue($locale);
|
||||
$version = $this->getXliffVersion($root);
|
||||
|
||||
if ($version === '2.0') {
|
||||
$this->parseXliff2($root, $catalogue, $domain);
|
||||
} else {
|
||||
// Default to XLIFF 1.2 parsing
|
||||
$this->parseXliff1($root, $catalogue, $domain);
|
||||
}
|
||||
|
||||
return $catalogue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect XLIFF version from the root element
|
||||
*/
|
||||
private function getXliffVersion(\SimpleXMLElement $root): string
|
||||
{
|
||||
$namespaces = $root->getNamespaces(true);
|
||||
|
||||
// Check if XLIFF 2.x namespace is present (matches 2.0, 2.1, 2.2, etc.)
|
||||
foreach ($namespaces as $namespace) {
|
||||
if (str_starts_with($namespace, 'urn:oasis:names:tc:xliff:document:2.')) {
|
||||
return '2.0';
|
||||
}
|
||||
}
|
||||
|
||||
// Check version attribute
|
||||
$version = (string)$root['version'];
|
||||
if (str_starts_with($version, '2.')) {
|
||||
return '2.0';
|
||||
}
|
||||
|
||||
// Default to 1.2
|
||||
return '1.2';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse XLIFF 1.2 format
|
||||
*/
|
||||
private function parseXliff1(\SimpleXMLElement $root, MessageCatalogue $catalogue, string $domain): void
|
||||
{
|
||||
$fileTag = $root->file;
|
||||
$isDefaultLanguage = !isset($fileTag['target-language']); // Default language from XLIFF template (no target element)
|
||||
$bodyOfFileTag = $root->file->body;
|
||||
$requireApprovedLocalizations = (bool)($GLOBALS['TYPO3_CONF_VARS']['LANG']['requireApprovedLocalizations'] ?? true);
|
||||
|
||||
if ($bodyOfFileTag instanceof \SimpleXMLElement) {
|
||||
foreach ($bodyOfFileTag->children() as $translationElement) {
|
||||
$deprecated = false;
|
||||
/** @var \SimpleXMLElement $translationElement */
|
||||
if ($translationElement->getName() === 'trans-unit' && !isset($translationElement['restype'])) {
|
||||
// Regular translation unit
|
||||
$id = (string)$translationElement['id'];
|
||||
$deprecated = isset($translationElement['x-unused-since']);
|
||||
if ($isDefaultLanguage) {
|
||||
// Default language from XLIFF template (no target element)
|
||||
$sourceElement = $translationElement->source;
|
||||
$translation = $this->extractText($sourceElement, $translationElement);
|
||||
$catalogue->set($id . ($deprecated ? '.x-unused' : ''), $translation, $domain);
|
||||
} else {
|
||||
$approved = (string)($translationElement['approved'] ?? 'yes');
|
||||
if (!$requireApprovedLocalizations || $approved === 'yes') {
|
||||
$catalogue->set($id . ($deprecated ? '.x-unused' : ''), $this->extractText($translationElement->target, $translationElement), $domain);
|
||||
}
|
||||
}
|
||||
} elseif ($translationElement->getName() === 'group' && isset($translationElement['restype']) && (string)$translationElement['restype'] === 'x-gettext-plurals') {
|
||||
// Translation with plural forms
|
||||
$parsedTranslationElement = [];
|
||||
foreach ($translationElement->children() as $translationPluralForm) {
|
||||
/** @var \SimpleXMLElement $translationPluralForm */
|
||||
if ($translationPluralForm->getName() === 'trans-unit') {
|
||||
$deprecated = isset($translationPluralForm['x-unused-since']);
|
||||
// Extract plural form index from ID like "1[0]", "1[1]"
|
||||
$formIndex = substr((string)$translationPluralForm['id'], strpos((string)$translationPluralForm['id'], '[') + 1, -1);
|
||||
if ($isDefaultLanguage) {
|
||||
// Default language from XLIFF template (no target element)
|
||||
$parsedTranslationElement[(int)$formIndex] = $this->extractText($translationPluralForm->source, $translationPluralForm);
|
||||
} else {
|
||||
$approved = (string)($translationPluralForm['approved'] ?? 'yes');
|
||||
if (!$requireApprovedLocalizations || $approved === 'yes') {
|
||||
$parsedTranslationElement[(int)$formIndex] = $this->extractText($translationPluralForm->target, $translationPluralForm);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($parsedTranslationElement !== []) {
|
||||
if (isset($translationElement['id'])) {
|
||||
$id = (string)$translationElement['id'];
|
||||
} else {
|
||||
$id = (string)$translationElement->{'trans-unit'}[0]['id'];
|
||||
$id = substr($id, 0, (int)strpos($id, '['));
|
||||
}
|
||||
// Handle plurals - Symfony uses ICU format
|
||||
$catalogue->set($id . ($deprecated ? '.x-unused' : ''), $this->convertToIcuPlural(array_values($parsedTranslationElement)), $domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse XLIFF 2.0 format
|
||||
*/
|
||||
private function parseXliff2(\SimpleXMLElement $root, MessageCatalogue $catalogue, string $domain): void
|
||||
{
|
||||
$requireApprovedLocalizations = (bool)($GLOBALS['TYPO3_CONF_VARS']['LANG']['requireApprovedLocalizations'] ?? true);
|
||||
|
||||
$ns = $root->getDocNamespaces();
|
||||
$ns = reset($ns) ?: 'urn:oasis:names:tc:xliff:document:2.0';
|
||||
// Register the XLIFF 2.0 namespace
|
||||
$root->registerXPathNamespace('xliff', $ns);
|
||||
$isDefaultLanguage = !isset($root['trgLang']); // Default language from XLIFF template (no target element)
|
||||
|
||||
// Get all file elements
|
||||
$files = $root->xpath('//xliff:file');
|
||||
if ($files === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$file->registerXPathNamespace('xliff', $ns);
|
||||
|
||||
// Get all unit elements within this file
|
||||
$units = $file->xpath('.//xliff:unit');
|
||||
if ($units === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($units as $unit) {
|
||||
$unit->registerXPathNamespace('xliff', $ns);
|
||||
$unitId = (string)$unit['id'];
|
||||
|
||||
// Check if this is a plural unit (contains multiple segments)
|
||||
$segments = $unit->xpath('.//xliff:segment');
|
||||
if ($segments === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (count($segments) === 1) {
|
||||
// Regular translation unit
|
||||
$segment = $segments[0];
|
||||
$segment->registerXPathNamespace('xliff', $ns);
|
||||
|
||||
$source = $segment->xpath('.//xliff:source');
|
||||
$target = $segment->xpath('.//xliff:target');
|
||||
|
||||
$deprecated = ((string)($segment['subState'] ?? '')) === 'deprecated';
|
||||
|
||||
if ($isDefaultLanguage) {
|
||||
// Default language from XLIFF template (no target element)
|
||||
$translation = $this->extractText($source[0], $segment);
|
||||
$catalogue->set($unitId . ($deprecated ? '.x-unused' : ''), $translation, $domain);
|
||||
} else {
|
||||
// Check approval state (XLIFF 2.0 uses 'state' attribute on segment)
|
||||
$approved = 'yes';
|
||||
if (isset($segment['state'])) {
|
||||
$state = (string)$segment['state'];
|
||||
// XLIFF 2.0 states: initial, translated, reviewed, final
|
||||
// We consider 'final' as approved, others depend on config
|
||||
if ($state === 'initial' || $state === 'translated') {
|
||||
$approved = 'no';
|
||||
}
|
||||
}
|
||||
|
||||
if (!$requireApprovedLocalizations || $approved === 'yes') {
|
||||
if ($target !== false && isset($target[0])) {
|
||||
$catalogue->set($unitId . ($deprecated ? '.x-unused' : ''), $this->extractText($target[0], $segment), $domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Plural forms (multiple segments)
|
||||
$parsedTranslationElement = [];
|
||||
$formIndex = 0;
|
||||
$deprecated = false;
|
||||
|
||||
foreach ($segments as $segment) {
|
||||
$segment->registerXPathNamespace('xliff', $ns);
|
||||
|
||||
$source = $segment->xpath('.//xliff:source');
|
||||
$target = $segment->xpath('.//xliff:target');
|
||||
$deprecated = $deprecated || ((string)($segment['subState'] ?? '')) === 'deprecated';
|
||||
|
||||
if ($isDefaultLanguage) {
|
||||
// Default language from XLIFF template (no target element)
|
||||
$parsedTranslationElement[$formIndex] = $this->extractText($source[0], $segment);
|
||||
} else {
|
||||
$approved = 'yes';
|
||||
if (isset($segment['state'])) {
|
||||
$state = (string)$segment['state'];
|
||||
if ($state === 'initial' || $state === 'translated') {
|
||||
$approved = 'no';
|
||||
}
|
||||
}
|
||||
|
||||
if (!$requireApprovedLocalizations || $approved === 'yes') {
|
||||
if ($target !== false && isset($target[0])) {
|
||||
$parsedTranslationElement[$formIndex] = $this->extractText($target[0], $segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
$formIndex++;
|
||||
}
|
||||
|
||||
if ($parsedTranslationElement !== []) {
|
||||
$catalogue->set($unitId . ($deprecated ? '.x-unused' : ''), $this->convertToIcuPlural(array_values($parsedTranslationElement)), $domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert plural forms to ICU plural format
|
||||
* This is a simplified conversion - could be enhanced based on actual usage
|
||||
*/
|
||||
private function convertToIcuPlural(array $pluralValues): string
|
||||
{
|
||||
if (count($pluralValues) === 1) {
|
||||
return $pluralValues[0];
|
||||
}
|
||||
|
||||
// Simple mapping: [0] = one, [1] = other
|
||||
$icuFormat = '';
|
||||
if (isset($pluralValues[0])) {
|
||||
$icuFormat .= '{0, plural, one {' . $pluralValues[0] . '}';
|
||||
}
|
||||
if (isset($pluralValues[1])) {
|
||||
$icuFormat .= ' other {' . $pluralValues[1] . '}';
|
||||
}
|
||||
$icuFormat .= '}';
|
||||
|
||||
return $icuFormat;
|
||||
}
|
||||
|
||||
private function isXmlString(string $resource): bool
|
||||
{
|
||||
return str_starts_with($resource, '<?xml');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from an XML element, respecting xml:space attribute.
|
||||
* Per XML spec: xml:space="preserve" keeps whitespace as-is,
|
||||
* otherwise whitespace should be normalized (multiple spaces/newlines collapsed to single space).
|
||||
* see https://www.w3.org/TR/xml/#sec-white-space
|
||||
*/
|
||||
private function extractText(\SimpleXMLElement $element, ?\SimpleXMLElement $parentElement = null): string
|
||||
{
|
||||
$text = (string)$element;
|
||||
|
||||
// Check xml:space on the element itself or parent (trans-unit/unit/segment)
|
||||
$xmlSpace = $this->getXmlSpaceAttribute($element, $parentElement);
|
||||
|
||||
if ($xmlSpace !== 'preserve') {
|
||||
// Normalize whitespace: collapse multiple whitespace characters to single space
|
||||
$text = preg_replace('/\s+/', ' ', $text);
|
||||
$text = trim($text);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the xml:space attribute value, checking element and parent (attribute is inherited per XML spec).
|
||||
*/
|
||||
private function getXmlSpaceAttribute(\SimpleXMLElement $element, ?\SimpleXMLElement $parent): string
|
||||
{
|
||||
// Check element's xml:space attribute
|
||||
$attributes = $element->attributes('xml', true);
|
||||
if (isset($attributes['space'])) {
|
||||
return (string)$attributes['space'];
|
||||
}
|
||||
|
||||
// Check parent's xml:space attribute (inherited per XML spec)
|
||||
if ($parent !== null) {
|
||||
$parentAttributes = $parent->attributes('xml', true);
|
||||
if (isset($parentAttributes['space'])) {
|
||||
return (string)$parentAttributes['space'];
|
||||
}
|
||||
}
|
||||
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization;
|
||||
|
||||
/**
|
||||
* A representation of
|
||||
* language key (based on ISO 639-1 / ISO 639-2)
|
||||
* - the optional four-letter script code that can follow the language code according to the Unicode ISO 15924 Registry (e.g. Hans in zh_Hans)
|
||||
* - region / country (based on ISO 3166-1)
|
||||
* separated with a "-".
|
||||
*
|
||||
* This conforms to IETF - RFC 5646 (see https://datatracker.ietf.org/doc/rfc5646/) in a simplified form.
|
||||
*/
|
||||
class Locale implements \Stringable
|
||||
{
|
||||
// taken from https://meta.wikimedia.org/wiki/Template:List_of_language_names_ordered_by_code
|
||||
protected const RIGHT_TO_LEFT_LANGUAGE_CODES = [
|
||||
'ar', // Arabic
|
||||
'arc', // Aramaic
|
||||
'arz', // Egyptian Arabic
|
||||
'ckb', // Kurdish (Sorani)
|
||||
'dv', // Divehi
|
||||
'fa', // Persian
|
||||
'ha', // Hausa
|
||||
'he', // Hebrew
|
||||
'khw', // Khowar
|
||||
'ks', // Kashmiri
|
||||
'ps', // Pashto
|
||||
'sd', // Sindhi
|
||||
'ur', // Urdu
|
||||
'uz-AF', // Uzbeki Afghanistan
|
||||
'yi', // Yiddish
|
||||
];
|
||||
protected string $locale;
|
||||
protected string $languageCode;
|
||||
protected ?string $languageScript = null;
|
||||
protected ?string $countryCode = null;
|
||||
protected ?string $codeSet = null;
|
||||
// see https://wiki.archlinux.org/title/locale#Generating_locales
|
||||
protected ?string $charsetModifier = null;
|
||||
|
||||
/**
|
||||
* List of language dependencies for an actual language. This setting is used for local variants of a language
|
||||
* that depend on their "main" language, like Brazilian Portuguese or Canadian French.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $dependencies = [];
|
||||
|
||||
/**
|
||||
* Only use the constructor directly if you know what you are doing and want to create custom locales with custom dependencies.
|
||||
* Otherwise, instantiate a new Locale object via Locales->createLocale() as it deals with registered dependencies automatically.
|
||||
*/
|
||||
public function __construct(
|
||||
string $locale = 'en',
|
||||
array $dependencies = []
|
||||
) {
|
||||
$locale = $this->normalize($locale);
|
||||
if (str_contains($locale, '@')) {
|
||||
[$locale, $this->charsetModifier] = explode('@', $locale);
|
||||
}
|
||||
if (str_contains($locale, '.')) {
|
||||
[$locale, $this->codeSet] = explode('.', $locale);
|
||||
}
|
||||
if (strtolower($locale) === 'c') {
|
||||
$this->codeSet = 'C';
|
||||
$locale = 'en';
|
||||
} elseif (strtolower($locale) === 'posix') {
|
||||
$this->codeSet = 'POSIX';
|
||||
$locale = 'en';
|
||||
}
|
||||
if (str_contains($locale, '-')) {
|
||||
[$this->languageCode, $tail] = explode('-', $locale, 2);
|
||||
if (str_contains($tail, '-')) {
|
||||
[$this->languageScript, $this->countryCode] = explode('-', $tail);
|
||||
} elseif (strlen($tail) === 4) {
|
||||
$this->languageScript = $tail;
|
||||
} else {
|
||||
$this->countryCode = $tail ?: null;
|
||||
}
|
||||
$this->languageCode = strtolower($this->languageCode);
|
||||
$this->languageScript = $this->languageScript ? ucfirst(strtolower($this->languageScript)) : null;
|
||||
$this->countryCode = $this->countryCode ? strtoupper($this->countryCode) : null;
|
||||
} else {
|
||||
$this->languageCode = strtolower($locale);
|
||||
}
|
||||
|
||||
$this->locale = $this->languageCode . ($this->languageScript ? '-' . $this->languageScript : '') . ($this->countryCode ? '-' . $this->countryCode : '');
|
||||
$this->dependencies = array_map($this->normalize(...), $dependencies);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->locale;
|
||||
}
|
||||
|
||||
public function getLanguageCode(): string
|
||||
{
|
||||
return $this->languageCode;
|
||||
}
|
||||
|
||||
public function isRightToLeftLanguageDirection(): bool
|
||||
{
|
||||
return in_array($this->languageCode, self::RIGHT_TO_LEFT_LANGUAGE_CODES, true) || in_array($this->locale, self::RIGHT_TO_LEFT_LANGUAGE_CODES, true);
|
||||
}
|
||||
|
||||
public function getLanguageScriptCode(): ?string
|
||||
{
|
||||
return $this->languageScript;
|
||||
}
|
||||
|
||||
public function getCountryCode(): ?string
|
||||
{
|
||||
return $this->countryCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the locale as ISO/IEC 15897 format, including a possible POSIX charset
|
||||
* "cs_CZ.UTF-8"
|
||||
* see https://en.wikipedia.org/wiki/ISO/IEC_15897
|
||||
* https://en.wikipedia.org/wiki/Locale_(computer_software)#POSIX_platforms
|
||||
* @internal
|
||||
*/
|
||||
public function posixFormatted(): string
|
||||
{
|
||||
$charsetModifier = $this->charsetModifier ? '@' . $this->charsetModifier : '';
|
||||
if ($this->codeSet === 'C' || $this->codeSet === 'POSIX') {
|
||||
return $this->codeSet . $charsetModifier;
|
||||
}
|
||||
$formatted = $this->languageCode;
|
||||
if ($this->countryCode) {
|
||||
$formatted .= '_' . $this->countryCode;
|
||||
}
|
||||
if ($this->codeSet) {
|
||||
$formatted .= '.' . $this->codeSet;
|
||||
}
|
||||
return $formatted . $charsetModifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function getPosixCodeSet(): ?string
|
||||
{
|
||||
return $this->codeSet;
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return $this->dependencies;
|
||||
}
|
||||
|
||||
protected function normalize(string $locale): string
|
||||
{
|
||||
if ($locale === 'default') {
|
||||
return 'en';
|
||||
}
|
||||
if (str_contains($locale, '_')) {
|
||||
$locale = str_replace('_', '-', $locale);
|
||||
}
|
||||
|
||||
return $locale;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->locale;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
|
||||
use TYPO3\CMS\Core\Core\Environment;
|
||||
use TYPO3\CMS\Core\Http\ApplicationType;
|
||||
use TYPO3\CMS\Core\Log\LogManager;
|
||||
use TYPO3\CMS\Core\SingletonInterface;
|
||||
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
|
||||
use TYPO3\CMS\Core\Utility\CommandUtility;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Defines all available TYPO3 system languages, as they differ from actual ISO 639-1 codes.
|
||||
* User-defined system languages can be added to $GLOBALS['TYPO3_CONF_VARS']['SYS']['localization']['locales']['user']
|
||||
*
|
||||
* These system languages are used for determining the proper language labels of XLF files.
|
||||
*/
|
||||
class Locales implements SingletonInterface
|
||||
{
|
||||
/**
|
||||
* Supported TYPO3 languages with locales
|
||||
*
|
||||
* @var array<non-empty-string, non-empty-string>
|
||||
*/
|
||||
protected array $languages = [
|
||||
'en' => 'English',
|
||||
'af' => 'Afrikaans',
|
||||
'ar' => 'Arabic',
|
||||
'bs' => 'Bosnian',
|
||||
'bg' => 'Bulgarian',
|
||||
'ca' => 'Catalan',
|
||||
'ch' => 'Chinese (Simple)',
|
||||
'cs' => 'Czech',
|
||||
'cy' => 'Welsh',
|
||||
'da' => 'Danish',
|
||||
'de' => 'German',
|
||||
'el' => 'Greek',
|
||||
'eo' => 'Esperanto',
|
||||
'es' => 'Spanish',
|
||||
'et' => 'Estonian',
|
||||
'eu' => 'Basque',
|
||||
'fa' => 'Persian',
|
||||
'fi' => 'Finnish',
|
||||
'fo' => 'Faroese',
|
||||
'fr' => 'French',
|
||||
'fr_CA' => 'French (Canada)',
|
||||
'ga' => 'Irish Gaelic',
|
||||
'gd' => 'Scottish Gaelic',
|
||||
'gl' => 'Galician',
|
||||
'he' => 'Hebrew',
|
||||
'hi' => 'Hindi',
|
||||
'hr' => 'Croatian',
|
||||
'hu' => 'Hungarian',
|
||||
'is' => 'Icelandic',
|
||||
'it' => 'Italian',
|
||||
'ja' => 'Japanese',
|
||||
'ka' => 'Georgian',
|
||||
'kl' => 'Greenlandic',
|
||||
'km' => 'Khmer',
|
||||
'ko' => 'Korean',
|
||||
'lb' => 'Luxembourgish',
|
||||
'lo' => 'Lao',
|
||||
'lt' => 'Lithuanian',
|
||||
'lv' => 'Latvian',
|
||||
'mi' => 'Maori',
|
||||
'mk' => 'Macedonian',
|
||||
'ms' => 'Malay',
|
||||
'mt' => 'Maltese',
|
||||
'nl' => 'Dutch',
|
||||
'no' => 'Norwegian',
|
||||
'pl' => 'Polish',
|
||||
'pt' => 'Portuguese',
|
||||
'pt_BR' => 'Brazilian Portuguese',
|
||||
'ro' => 'Romanian',
|
||||
'ru' => 'Russian',
|
||||
'rw' => 'Kinyarwanda',
|
||||
'sk' => 'Slovak',
|
||||
'sl' => 'Slovenian',
|
||||
'sn' => 'Shona (Bantu)',
|
||||
'sq' => 'Albanian',
|
||||
'sr' => 'Serbian',
|
||||
'sv' => 'Swedish',
|
||||
'th' => 'Thai',
|
||||
'tr' => 'Turkish',
|
||||
'uk' => 'Ukrainian',
|
||||
'vi' => 'Vietnamese',
|
||||
'zh' => 'Chinese (Traditional)',
|
||||
'zh_CN' => 'Chinese (Simplified)',
|
||||
'zh_HK' => 'Chinese (Simplified Hong Kong)',
|
||||
'zh_Hans_CN' => 'Chinese (Simplified Han)',
|
||||
];
|
||||
|
||||
/**
|
||||
* Dependencies for locales.
|
||||
* By default, locales with a country/region suffix such as "de_AT" will automatically have the "de"
|
||||
* locale as fallback. This way TYPO3 only needs to know about the actual "base" language, however
|
||||
* also allows to use country-specific languages.
|
||||
* However, when a specific locale such as "lb" has a dependency to a different "de" suffix, this should
|
||||
* is defined here.
|
||||
* With
|
||||
* $GLOBALS['TYPO3_CONF_VARS']['SYS']['localization']['locales']['dependencies']
|
||||
* it is possible to extend the dependency list.
|
||||
*
|
||||
* Example:
|
||||
* If "lb" is chosen, but no label was found, a fallback to the label in "de" is used.
|
||||
*/
|
||||
protected array $localeDependencies = [
|
||||
'lb' => ['de'],
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Allow user-defined locales
|
||||
foreach ($GLOBALS['TYPO3_CONF_VARS']['SYS']['localization']['locales']['user'] ?? [] as $locale => $name) {
|
||||
if (!is_string($locale) || $locale === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($this->languages[$locale])) {
|
||||
$this->languages[$locale] = $name;
|
||||
}
|
||||
}
|
||||
// Merge user-provided locale dependencies
|
||||
if (is_array($GLOBALS['TYPO3_CONF_VARS']['SYS']['localization']['locales']['dependencies'] ?? null)) {
|
||||
$this->localeDependencies = array_replace_recursive(
|
||||
$this->localeDependencies,
|
||||
$GLOBALS['TYPO3_CONF_VARS']['SYS']['localization']['locales']['dependencies']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function createLocale(string $localeKey, ?array $alternativeDependencies = null): Locale
|
||||
{
|
||||
if (strpos($localeKey, '.')) {
|
||||
[$sanitizedLocaleKey] = explode('.', $localeKey);
|
||||
}
|
||||
// Find the requested language in this list based on the $languageKey
|
||||
// Language is found. Configure it:
|
||||
if ($localeKey === 'en' || $this->isValidLanguageKey($sanitizedLocaleKey ?? $localeKey)) {
|
||||
return new Locale($localeKey, $alternativeDependencies ?? $this->getLocaleDependencies($sanitizedLocaleKey ?? $localeKey));
|
||||
}
|
||||
return new Locale();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the locales.
|
||||
* @return array<int, non-empty-string>
|
||||
*/
|
||||
public function getLocales(): array
|
||||
{
|
||||
return array_keys($this->languages);
|
||||
}
|
||||
|
||||
public function isValidLanguageKey(string $locale): bool
|
||||
{
|
||||
// "en" implicitly equals "default", so this is OK
|
||||
if ($locale === 'en' || $locale === 'default') {
|
||||
return true;
|
||||
}
|
||||
if (!isset($this->languages[$locale])) {
|
||||
// the given locale is not found in the current locales, let us see if
|
||||
// the base language (iso-639-1) is in the list of supported locales.
|
||||
if (str_contains($locale, '_')) {
|
||||
[$baseIsoCodeLanguageKey] = explode('_', $locale);
|
||||
return $this->isValidLanguageKey($baseIsoCodeLanguageKey);
|
||||
}
|
||||
if (str_contains($locale, '-')) {
|
||||
[$baseIsoCodeLanguageKey] = explode('-', $locale);
|
||||
return $this->isValidLanguageKey($baseIsoCodeLanguageKey);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the supported languages indexed by their corresponding locale.
|
||||
* @return array<non-empty-string, non-empty-string>
|
||||
*/
|
||||
public function getLanguages(): array
|
||||
{
|
||||
return $this->languages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all ISO codes / TYPO3 languages that have active language packs, including "en" (English).
|
||||
* @return array<int, non-empty-string>
|
||||
*/
|
||||
public function getActiveLanguages(): array
|
||||
{
|
||||
return array_merge(
|
||||
['en'],
|
||||
array_filter(array_values($GLOBALS['TYPO3_CONF_VARS']['LANG']['availableLocales'] ?? []))
|
||||
);
|
||||
}
|
||||
|
||||
public function isLanguageKeyAvailable(string $languageKey): bool
|
||||
{
|
||||
return in_array($languageKey, $this->getActiveLanguages()) || is_dir(Environment::getLabelsPath() . '/' . $languageKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dependencies of a given locale, if any.
|
||||
*
|
||||
* @return array<int, non-empty-string>
|
||||
*/
|
||||
public function getLocaleDependencies(string $locale): array
|
||||
{
|
||||
$dependencies = [];
|
||||
if (isset($this->localeDependencies[$locale])) {
|
||||
$dependencies = $this->localeDependencies[$locale];
|
||||
// Search for dependencies recursively
|
||||
$localeDependencies = $dependencies;
|
||||
foreach ($localeDependencies as $dependency) {
|
||||
if (isset($this->localeDependencies[$dependency])) {
|
||||
$dependencies = array_merge($dependencies, $this->getLocaleDependencies($dependency));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Use automatic dependency resolving.
|
||||
// "de_AT" automatically has a dependency on "de".
|
||||
// but only do this if the actual "de_AT" does not have a custom dependency already defined in
|
||||
// $this->localeDependencies
|
||||
if ($dependencies === [] && str_contains($locale, '_')) {
|
||||
[$languageIsoCode] = explode('_', $locale);
|
||||
// "en" and "default" is always implicitly the default fallback dependency
|
||||
if ($languageIsoCode !== 'en') {
|
||||
$dependencies[] = $languageIsoCode;
|
||||
$dependencies = array_merge($dependencies, $this->getLocaleDependencies($languageIsoCode));
|
||||
}
|
||||
} elseif ($dependencies === [] && str_contains($locale, '-')) {
|
||||
[$languageIsoCode] = explode('-', $locale);
|
||||
// "en" and "default" is always implicitly the default fallback dependency
|
||||
if ($languageIsoCode !== 'en') {
|
||||
$dependencies[] = $languageIsoCode;
|
||||
$dependencies = array_merge($dependencies, $this->getLocaleDependencies($languageIsoCode));
|
||||
}
|
||||
}
|
||||
return array_unique($dependencies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the language codes that we get from the client (usually HTTP_ACCEPT_LANGUAGE)
|
||||
* into a TYPO3-readable language code
|
||||
*
|
||||
* @param string $languageCodesList List of language codes. something like 'de,en-us;q=0.9,de-de;q=0.7,es-cl;q=0.6,en;q=0.4,es;q=0.3,zh;q=0.1'
|
||||
* @return non-empty-string A preferred language that TYPO3 supports, or "en" as fallback if none found
|
||||
*/
|
||||
public function getPreferredClientLanguage(string $languageCodesList): string
|
||||
{
|
||||
$allLanguageCodesFromLocales = [];
|
||||
foreach ($this->languages as $locale => $localeTitle) {
|
||||
$locale = str_replace('_', '-', $locale);
|
||||
$allLanguageCodesFromLocales[$locale] = $locale;
|
||||
}
|
||||
$selectedLanguage = 'en';
|
||||
$preferredLanguages = GeneralUtility::trimExplode(',', $languageCodesList);
|
||||
// Order the preferred languages after they key
|
||||
$sortedPreferredLanguages = [];
|
||||
foreach ($preferredLanguages as $preferredLanguage) {
|
||||
$quality = 1.0;
|
||||
if (str_contains($preferredLanguage, ';q=')) {
|
||||
[$preferredLanguage, $quality] = explode(';q=', $preferredLanguage);
|
||||
}
|
||||
$sortedPreferredLanguages[$preferredLanguage] = $quality;
|
||||
}
|
||||
// Loop through the languages, with the highest priority first
|
||||
arsort($sortedPreferredLanguages, SORT_NUMERIC);
|
||||
foreach ($sortedPreferredLanguages as $preferredLanguage => $quality) {
|
||||
if (isset($allLanguageCodesFromLocales[$preferredLanguage])) {
|
||||
$selectedLanguage = $allLanguageCodesFromLocales[$preferredLanguage];
|
||||
break;
|
||||
}
|
||||
// Strip the country code from the end
|
||||
[$preferredLanguage] = explode('-', $preferredLanguage);
|
||||
if (isset($allLanguageCodesFromLocales[$preferredLanguage])) {
|
||||
$selectedLanguage = $allLanguageCodesFromLocales[$preferredLanguage];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return str_replace('-', '_', $selectedLanguage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setting locale based on a SiteLanguage's defined locale. Used for frontend rendering.
|
||||
*
|
||||
* @return bool whether the locale was found on the system (and could be set properly) or not
|
||||
*/
|
||||
public static function setSystemLocaleFromSiteLanguage(SiteLanguage $siteLanguage): bool
|
||||
{
|
||||
$locale = $siteLanguage->getLocale()->posixFormatted();
|
||||
if ($locale === '') {
|
||||
return false;
|
||||
}
|
||||
return self::setLocale($locale, $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal not part of TYPO3 Core API, don't use outside of TYPO3 Core
|
||||
*/
|
||||
public static function getAllSystemLocales(): array
|
||||
{
|
||||
$disabledFunctions = GeneralUtility::trimExplode(',', (string)ini_get('disable_functions'), true);
|
||||
if (in_array('exec', $disabledFunctions, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rawOutput = [];
|
||||
CommandUtility::exec('locale -a', $rawOutput);
|
||||
|
||||
sort($rawOutput, SORT_NATURAL);
|
||||
$locales = [];
|
||||
$usedLocaleBasenames = [];
|
||||
foreach ($rawOutput as $item) {
|
||||
// do not show C/POSIX in the list of locales, as this is the default anyway
|
||||
$obj = new Locale($item);
|
||||
if ($obj->getPosixCodeSet() === 'C' || $obj->getPosixCodeSet() === 'POSIX') {
|
||||
continue;
|
||||
}
|
||||
// Skip locales with appended language or country code (e.g. "de_DE.UTF-8", "de_DE.ISO8859-1").
|
||||
// The user should only choose "de_DE".
|
||||
if (in_array($obj->getName(), $usedLocaleBasenames, true)) {
|
||||
continue;
|
||||
}
|
||||
$locales[] = $item;
|
||||
$usedLocaleBasenames[] = $obj->getName();
|
||||
}
|
||||
|
||||
return $locales;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method, which calls itself again, in order to avoid multiple logging issues.
|
||||
* The main reason for this method is that it calls itself again by trying again to set
|
||||
* the locale. Due to sensible defaults, people used the locale "de_AT.utf-8" with the POSIX platform
|
||||
* (see https://en.wikipedia.org/wiki/Locale_(computer_software)#POSIX_platforms) in their site configuration,
|
||||
* even though the target system has "de_AT" and not "de_AT.UTF-8" defined.
|
||||
* "setLocale()" is now called again without the POSIX platform suffix and is checked again if the locale
|
||||
* is then available, and then logs the failed information.
|
||||
*/
|
||||
protected static function setLocale(string $locale, string $localeStringForTrigger): bool
|
||||
{
|
||||
$incomingLocale = $locale;
|
||||
$availableLocales = GeneralUtility::trimExplode(',', $locale, true);
|
||||
// If LC_NUMERIC is set e.g. to 'de_DE' PHP parses float values locale-aware resulting in strings with comma
|
||||
// as decimal point which causes problems with value conversions - so we set all locale types except LC_NUMERIC
|
||||
// @see https://bugs.php.net/bug.php?id=53711
|
||||
$locale = setlocale(LC_COLLATE, ...$availableLocales);
|
||||
if ($locale) {
|
||||
// As str_* methods are locale aware and turkish has no upper case I
|
||||
// Class autoloading and other checks depending on case changing break with turkish locale LC_CTYPE
|
||||
// @see http://bugs.php.net/bug.php?id=35050
|
||||
if (!str_starts_with($locale, 'tr')) {
|
||||
setlocale(LC_CTYPE, ...$availableLocales);
|
||||
}
|
||||
setlocale(LC_MONETARY, ...$availableLocales);
|
||||
setlocale(LC_TIME, ...$availableLocales);
|
||||
} else {
|
||||
// Retry again without the "utf-8" POSIX platform suffix if this is given.
|
||||
if (str_contains($incomingLocale, '.')) {
|
||||
[$localeWithoutModifier] = explode('.', $incomingLocale);
|
||||
return self::setLocale($localeWithoutModifier, $incomingLocale);
|
||||
}
|
||||
if ($localeStringForTrigger === $locale) {
|
||||
GeneralUtility::makeInstance(LogManager::class)
|
||||
->getLogger(__CLASS__)
|
||||
->error('Locale "' . htmlspecialchars($localeStringForTrigger) . '" not found.');
|
||||
} else {
|
||||
GeneralUtility::makeInstance(LogManager::class)
|
||||
->getLogger(__CLASS__)
|
||||
->error('Locale "' . htmlspecialchars($localeStringForTrigger) . '" and "' . htmlspecialchars($incomingLocale) . '" not found.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function createLocaleFromRequest(?ServerRequestInterface $request): Locale
|
||||
{
|
||||
$languageServiceFactory = GeneralUtility::makeInstance(LanguageServiceFactory::class);
|
||||
if ($request !== null && ApplicationType::fromRequest($request)->isFrontend()) {
|
||||
// @todo: the string conversion is needed for the time being, as long as SiteLanguage does not contain
|
||||
// the full locale with all fallbacks, then getTypo3Language() also needs to be removed.
|
||||
$localeString = (string)($request->getAttribute('language')?->getTypo3Language()
|
||||
?? $request->getAttribute('site')->getDefaultLanguage()->getTypo3Language());
|
||||
return $this->createLocale($localeString);
|
||||
}
|
||||
return $languageServiceFactory->createFromUserPreferences($GLOBALS['BE_USER'] ?? null)->getLocale();
|
||||
}
|
||||
|
||||
public function createLocaleFromUserPreferences(?AbstractUserAuthentication $user): Locale
|
||||
{
|
||||
if ($user && ($user->user['lang'] ?? false)) {
|
||||
return $this->createLocale($user->user['lang']);
|
||||
}
|
||||
return $this->createLocale('en');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Translation\MessageCatalogueInterface;
|
||||
use Symfony\Component\Translation\Translator;
|
||||
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
|
||||
use TYPO3\CMS\Core\Localization\Exception\FileNotFoundException;
|
||||
|
||||
/**
|
||||
* This class acts currently as facade around SymfonyTranslator.
|
||||
* User-land code should use LanguageService for the time being, and this class should not be exposed directly.
|
||||
*
|
||||
* Ideally, consider using a runtime cache if needed, if not using LanguageService.
|
||||
*
|
||||
* Hand in the locale to load, or english ("en").
|
||||
*
|
||||
* What it does:
|
||||
* - Caches on a system-level cache
|
||||
* - Handles loading default (= english) before translated files
|
||||
* - Handles file name juggling of translated files.
|
||||
* - Handles localization overrides via $GLOBALS['TYPO3_CONF_VARS']['LANG']['resourceOverrides']
|
||||
*
|
||||
* This class only deals with full files, "resources" a.k.a. "translation domains" right now. It does not care about
|
||||
* the actual identifier WITHIN this label bag.
|
||||
*
|
||||
* The main issue with this class is that it does not resolve proper dependencies, thus the fallback logic
|
||||
* is marode. You can see this when checking for ArrayUtility both here and in LanguageService.
|
||||
*
|
||||
* @phpstan-type TranslationPlural array<int, string>
|
||||
* @phpstan-type TranslationLabel array<string, string|TranslationPlural>
|
||||
*/
|
||||
readonly class LocalizationFactory
|
||||
{
|
||||
protected const MOVED_FILES = [
|
||||
// Files that have been moved to a new location.
|
||||
// Add entries as: 'EXT:old/path/file.xlf' => 'EXT:new/path/file.xlf'
|
||||
];
|
||||
|
||||
protected const DEPRECATED_FILES = [
|
||||
// Files that are deprecated and should no longer be referenced.
|
||||
// Add entries as: 'EXT:ext/Resources/Private/Language/file.xlf'
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected Translator $translator,
|
||||
#[Autowire(service: 'cache.l10n')]
|
||||
protected FrontendInterface $systemCache,
|
||||
#[Autowire(service: 'cache.runtime')]
|
||||
protected FrontendInterface $runtimeCache,
|
||||
protected TranslationDomainMapper $translationDomainMapper,
|
||||
protected LabelFileResolver $labelFileResolver,
|
||||
protected TranslationDomainResolver $translationDomainResolver,
|
||||
) {
|
||||
foreach ($GLOBALS['TYPO3_CONF_VARS']['LANG']['loader'] ?? [] as $key => $loader) {
|
||||
if (class_exists($loader)) {
|
||||
$this->translator->addLoader($key, new $loader());
|
||||
}
|
||||
}
|
||||
$this->translator->setFallbackLocales(['default']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Not part of TYPO3 Core API. Do not use outside of TYPO3 Core as this method may vanish at any time.
|
||||
*/
|
||||
public function isLanguageFileDeprecated(string $fileReference): bool
|
||||
{
|
||||
return in_array($fileReference, self::DEPRECATED_FILES)
|
||||
// @phpstan-ignore isset.offset (MOVED_FILES is intentionally empty for now, remove this once a first entry is added)
|
||||
|| isset(self::MOVED_FILES[$fileReference]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload files into Symfony Translator without retrieving catalogues.
|
||||
*
|
||||
* This is used during cache warmup to batch all addResource() calls before
|
||||
* any getCatalogue() calls, avoiding O(n²) catalogue rebuilds.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public function warmupTranslatorResource(string $fileReference, Locale $locale): void
|
||||
{
|
||||
[$fileReference, $domainName, $allLanguageKeysAsOrderedFallback] = $this->computeFileDomainAndFallbacks($fileReference, $locale);
|
||||
$this->loadLanguagesIntoSymfonyTranslator($fileReference, $domainName, $allLanguageKeysAsOrderedFallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns parsed data from a given file and language key.
|
||||
*
|
||||
* @param string $fileReference Input is a file-reference (see \TYPO3\CMS\Core\Utility\GeneralUtility::getFileAbsFileName). That file is expected to be a supported locallang file format
|
||||
* @param Locale|string|null $locale Locale with dependencies or language key. Null value is set to 'en' with fallback 'default'. @internal Language key as string loads language data with "en" and "default" as the default fallback dependency.
|
||||
* @param bool $renewCache Recompute data and renew cache entry.
|
||||
*
|
||||
* @return TranslationLabel
|
||||
*/
|
||||
public function getParsedData(string $fileReference, Locale|string|null $locale, bool $renewCache = false): array
|
||||
{
|
||||
if ($locale === null) {
|
||||
$locale = new Locale('en', ['default']);
|
||||
}
|
||||
if (is_string($locale)) {
|
||||
// Load language data with fallback "en" and "default", as these are always implicitly the default fallback dependencies.
|
||||
$locale = new Locale($locale);
|
||||
}
|
||||
$languageKey = $locale->getName();
|
||||
|
||||
[$fileReference, $domainName, $allLanguageKeysAsOrderedFallback] = $this->computeFileDomainAndFallbacks($fileReference, $locale);
|
||||
$systemCacheIdentifier = md5($domainName . $languageKey . serialize($allLanguageKeysAsOrderedFallback));
|
||||
|
||||
// If the content is in system cache, put it in runtime cache and use it
|
||||
if (!$renewCache) {
|
||||
$labels = $this->systemCache->get($systemCacheIdentifier);
|
||||
if (is_array($labels)) {
|
||||
return $labels;
|
||||
}
|
||||
}
|
||||
|
||||
// Add files for all locales to Symfony Translator catalogue - order does not matter here.
|
||||
$this->loadLanguagesIntoSymfonyTranslator($fileReference, $domainName, $allLanguageKeysAsOrderedFallback);
|
||||
|
||||
// Set order of fallback locales in Symfony Translator.
|
||||
if ($this->translator->getFallbackLocales() !== $allLanguageKeysAsOrderedFallback) {
|
||||
// Performance: Setting fallbacks clears all catalogues, which results in computational expensive regeneration of catalogues!
|
||||
$this->translator->setFallbackLocales($allLanguageKeysAsOrderedFallback);
|
||||
}
|
||||
$labels = $this->loadWithSymfonyTranslator($languageKey, $domainName);
|
||||
|
||||
// Cache processed data
|
||||
$this->systemCache->set($systemCacheIdentifier, $labels);
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares file reference, domain, language fallbacks
|
||||
*
|
||||
* @return array{string, string, array<string>}
|
||||
*/
|
||||
protected function computeFileDomainAndFallbacks(string $fileReference, Locale $locale): array
|
||||
{
|
||||
if (in_array($fileReference, self::DEPRECATED_FILES)) {
|
||||
trigger_error(
|
||||
sprintf('The file "%s" is deprecated. Please use a label from a different language file instead.', $fileReference),
|
||||
E_USER_DEPRECATED
|
||||
);
|
||||
}
|
||||
// @phpstan-ignore isset.offset (MOVED_FILES is intentionally empty for now, remove this once a first entry is added)
|
||||
if (isset(self::MOVED_FILES[$fileReference])) {
|
||||
trigger_error('The file ' . $fileReference . ' has been moved to ' . self::MOVED_FILES[$fileReference] . '. Please update your code accordingly.', E_USER_DEPRECATED);
|
||||
$fileReference = self::MOVED_FILES[$fileReference];
|
||||
}
|
||||
|
||||
$fileReference = $this->translationDomainMapper->mapDomainToFileName($fileReference);
|
||||
$domainName = $this->translationDomainResolver->mapFileNameToDomain($fileReference);
|
||||
$allLanguageKeysAsOrderedFallback = $this->computeAllLanguageKeys($locale);
|
||||
|
||||
return [$fileReference, $domainName, $allLanguageKeysAsOrderedFallback];
|
||||
}
|
||||
|
||||
protected function computeAllLanguageKeys(Locale $locale): array
|
||||
{
|
||||
if ($locale->getName() === 'default') {
|
||||
return ['default'];
|
||||
}
|
||||
|
||||
$mainLocales = [$locale->getName()];
|
||||
$dependencyLocales = $locale->getDependencies();
|
||||
|
||||
// Firstly, remove 'default' if exists. 'en' must be added before 'default'.
|
||||
if (($keyDefault = array_search('default', $dependencyLocales, true)) !== false) {
|
||||
unset($dependencyLocales[$keyDefault]);
|
||||
}
|
||||
// 'en' and 'default' is always added as the default fallback dependency
|
||||
$allLocales = array_merge($mainLocales, $dependencyLocales, ['en', 'default']);
|
||||
$allLocales = array_unique($allLocales);
|
||||
return $allLocales;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load languages into Symfony Translator
|
||||
*/
|
||||
protected function loadLanguagesIntoSymfonyTranslator(string $fileReference, string $domainName, array $allLanguageKeysAsOrderedFallback): void
|
||||
{
|
||||
// Add files for all locales to Symfony Translator catalogue - order does not matter here.
|
||||
foreach ($allLanguageKeysAsOrderedFallback as $currentLanguageKey) {
|
||||
$this->loadFilesIntoSymfonyTranslator($fileReference, $currentLanguageKey, $domainName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load files into Symfony Translator
|
||||
*/
|
||||
protected function loadFilesIntoSymfonyTranslator(string $fileReference, string $languageKey, string $domainName): void
|
||||
{
|
||||
// Early exit if this file+locale combination has already been fully processed (including overrides).
|
||||
// This avoids redundant resolveFileReference() and getOverrideFilePaths() calls when processing
|
||||
// fallback locales that have already been loaded for previous files.
|
||||
$loadedCacheIdentifier = 'localization-factory-loaded-' . md5($fileReference . '-' . $languageKey . '-' . $domainName);
|
||||
if ($this->runtimeCache->has($loadedCacheIdentifier)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Firstly, load language into catalogue.
|
||||
try {
|
||||
$this->addFileReferenceToTranslator($fileReference, $languageKey, $domainName);
|
||||
} catch (FileNotFoundException) {
|
||||
// Run localization override, regardless of file reference not found.
|
||||
}
|
||||
|
||||
// Finally, apply localization overrides.
|
||||
$overrideFiles = $this->labelFileResolver->getOverrideFilePaths($fileReference, $languageKey);
|
||||
foreach ($overrideFiles as $overrideFile) {
|
||||
try {
|
||||
$this->addFileReferenceToTranslator($overrideFile, $languageKey, $domainName);
|
||||
} catch (FileNotFoundException) {
|
||||
}
|
||||
}
|
||||
|
||||
$this->runtimeCache->set($loadedCacheIdentifier, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the catalogue and convert to TYPO3 format
|
||||
*
|
||||
* @return TranslationLabel
|
||||
*/
|
||||
protected function loadWithSymfonyTranslator(string $languageKey, string $domainName): array
|
||||
{
|
||||
$catalogue = $this->getMessageCatalogue($languageKey);
|
||||
return $this->convertCatalogueToLegacyFormat($catalogue, $domainName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load complete catalogue for locale using Symfony Translator
|
||||
*/
|
||||
protected function getMessageCatalogue(string $locale): MessageCatalogueInterface
|
||||
{
|
||||
return $this->translator->getCatalogue($locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds translations of one resource to Symfony Translator
|
||||
*
|
||||
* @throws FileNotFoundException
|
||||
*/
|
||||
protected function addFileReferenceToTranslator(string $fileReference, string $locale, string $domainName): void
|
||||
{
|
||||
$actualSourcePath = $this->labelFileResolver->resolveFileReference($fileReference, $locale);
|
||||
if ($actualSourcePath === null) {
|
||||
// No file found. This might be the case if there is no localized version.
|
||||
return;
|
||||
}
|
||||
// Add the resource to Symfony Translator, if not added yet.
|
||||
$cacheIdentifier = 'symfony-translator-localization-factory-' . md5($actualSourcePath . '-' . $locale . '-' . $domainName);
|
||||
if (!$this->runtimeCache->has($cacheIdentifier)) {
|
||||
// @todo: we need to be more flexible with the file ending here.
|
||||
$fileExtension = (string)pathinfo($actualSourcePath, PATHINFO_EXTENSION);
|
||||
$this->translator->addResource($fileExtension ?: 'xlf', $actualSourcePath, $locale, $domainName);
|
||||
$this->runtimeCache->set($cacheIdentifier, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Symfony MessageCatalogue to TYPO3's legacy format
|
||||
*
|
||||
* @return TranslationLabel
|
||||
*/
|
||||
protected function convertCatalogueToLegacyFormat(MessageCatalogueInterface $catalogue, string $domain): array
|
||||
{
|
||||
$result = [];
|
||||
$fallbackCatalogue = $catalogue->getFallbackCatalogue();
|
||||
if ($fallbackCatalogue !== null) {
|
||||
$result = $this->convertCatalogueToLegacyFormat($fallbackCatalogue, $domain);
|
||||
}
|
||||
foreach ($catalogue->all($domain) as $key => $value) {
|
||||
// Check if this is a plural form (contains ICU format)
|
||||
if (str_contains($value, '{0, plural,')) {
|
||||
$result[$key] = $this->parseIcuPlural($value);
|
||||
} else {
|
||||
// Regular translation
|
||||
$result[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple parser for ICU plural format - extracts plural values
|
||||
*
|
||||
* @return TranslationPlural
|
||||
*/
|
||||
protected function parseIcuPlural(string $icuString): array
|
||||
{
|
||||
$plurals = [];
|
||||
|
||||
// Extract content within plural braces
|
||||
if (preg_match('/\{0, plural,(.+)\}$/', $icuString, $matches)) {
|
||||
$content = trim($matches[1]);
|
||||
|
||||
// Parse forms like "one {text1} other {text2}"
|
||||
if (preg_match_all('/(\w+)\s*\{([^}]+)\}/', $content, $formMatches, PREG_SET_ORDER)) {
|
||||
foreach ($formMatches as $match) {
|
||||
$form = $match[1];
|
||||
$text = $match[2];
|
||||
|
||||
// Map ICU forms to indices (simplified mapping)
|
||||
$index = match ($form) {
|
||||
'one' => 0,
|
||||
'other' => 1,
|
||||
default => count($plurals)
|
||||
};
|
||||
|
||||
$plurals[$index] = $text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $plurals;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
|
||||
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
|
||||
use TYPO3\CMS\Core\Site\Entity\NullSite;
|
||||
use TYPO3\CMS\Core\Site\SiteFinder;
|
||||
|
||||
/**
|
||||
* Provides ItemsProcFunc fields for special population of available TYPO3 system languages
|
||||
* @internal
|
||||
*/
|
||||
#[Autoconfigure(public: true)]
|
||||
final readonly class TcaSystemLanguageCollector
|
||||
{
|
||||
public function __construct(
|
||||
private Locales $locales,
|
||||
private SiteFinder $siteFinder,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Populate languages and group by available languages of the Language packs
|
||||
*/
|
||||
public function populateAvailableSystemLanguagesForBackend(array &$fieldInformation): void
|
||||
{
|
||||
$languageItems = $this->locales->getLanguages();
|
||||
$availableLanguages = [];
|
||||
$unavailableLanguages = [];
|
||||
foreach ($languageItems as $languageKey => $name) {
|
||||
if ($this->locales->isLanguageKeyAvailable($languageKey)) {
|
||||
$availableLanguages[] = ['label' => $name, 'value' => $languageKey, 'group' => 'installed'];
|
||||
} else {
|
||||
$unavailableLanguages[] = ['label' => $name, 'value' => $languageKey, 'group' => 'unavailable'];
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure ordering of the items
|
||||
$fieldInformation['items'] = array_merge($availableLanguages, $unavailableLanguages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a list of all languages available for ALL sites.
|
||||
* In case no site configuration can be found in the system,
|
||||
* a fallback is used to add at least the default language.
|
||||
*
|
||||
* Used by be_users and be_groups for their `allowed_languages` column.
|
||||
*/
|
||||
public function populateAvailableSiteLanguages(array &$fieldInformation): void
|
||||
{
|
||||
$allLanguages = [];
|
||||
foreach ($this->getAllSites() as $site) {
|
||||
foreach ($site->getAllLanguages() as $language) {
|
||||
$languageId = $language->getLanguageId();
|
||||
if (isset($allLanguages[$languageId])) {
|
||||
// Language already provided by another site, just add the label separately
|
||||
$allLanguages[$languageId]['label'] .= ', ' . $language->getTitle() . ' [Site: ' . $site->getIdentifier() . ']';
|
||||
continue;
|
||||
}
|
||||
$allLanguages[$languageId] = [
|
||||
'label' => $language->getTitle() . ' [Site: ' . $site->getIdentifier() . ']',
|
||||
'value' => $languageId,
|
||||
'icon' => $language->getFlagIdentifier(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($allLanguages !== []) {
|
||||
ksort($allLanguages);
|
||||
foreach ($allLanguages as $item) {
|
||||
$fieldInformation['items'][] = $item;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback if no site configuration exists
|
||||
$recordPid = (int)($fieldInformation['row']['pid'] ?? 0);
|
||||
$languages = (new NullSite())->getAvailableLanguages($this->getBackendUser(), false, $recordPid);
|
||||
|
||||
foreach ($languages as $languageId => $language) {
|
||||
$fieldInformation['items'][] = [
|
||||
'label' => $language->getTitle(),
|
||||
'value' => $languageId,
|
||||
'icon' => $language->getFlagIdentifier(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function getAllSites(): array
|
||||
{
|
||||
return $this->siteFinder->getAllSites();
|
||||
}
|
||||
|
||||
private function getBackendUser(): BackendUserAuthentication
|
||||
{
|
||||
return $GLOBALS['BE_USER'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization;
|
||||
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
|
||||
use TYPO3\CMS\Core\Core\Environment;
|
||||
use TYPO3\CMS\Core\Localization\Event\BeforeLabelResourceResolvedEvent;
|
||||
use TYPO3\CMS\Core\Package\PackageInterface;
|
||||
use TYPO3\CMS\Core\Package\PackageManager;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Maps between translation domains and label resource file paths.
|
||||
*
|
||||
* Translation domains provide a shorter, semantic alternative to verbose file paths for
|
||||
* referencing label resources (XLF files).
|
||||
*
|
||||
* Domain Format: package[.subdomain[.subdomain...]].resource
|
||||
*
|
||||
* Domain Generation Rules:
|
||||
* - Extension language files: [package].[subdir].[filename]
|
||||
* - Site Set files: [package].sets.[setName]
|
||||
* - Subdirectories are represented as dot-separated parts
|
||||
* - Special handling for locale-prefixed files
|
||||
*
|
||||
* - subdirectory "Resources/Private/Language" is omitted
|
||||
* - subdirectory "Configuration/Sets/{set-name}/labels.xlf" is replaced with "sets.{set-name}"
|
||||
*
|
||||
* UpperCamelCase is converted to snake_case.
|
||||
*
|
||||
* "locallang.xlf" is mapped to "messages"
|
||||
* "locallang_{mysuffix}.xlf" is replaced via "mysuffix" (keeping underscores)
|
||||
*
|
||||
* Locale prefixes (e.g. "de.locallang.xlf") are ignored in the domain name.
|
||||
*
|
||||
* Package Identifier Resolution:
|
||||
* - Uses extension keys: "core", "backend"
|
||||
* - Composer package names can be used as input and will be resolved to extension keys
|
||||
*
|
||||
* Once a domain for a package is requested, the cache for this package is built.
|
||||
*
|
||||
* @internal not part of TYPO3's public API.
|
||||
*/
|
||||
readonly class TranslationDomainMapper
|
||||
{
|
||||
public function __construct(
|
||||
protected PackageManager $packageManager,
|
||||
protected LabelFileResolver $labelFileResolver,
|
||||
protected TranslationDomainResolver $translationDomainResolver,
|
||||
#[Autowire(service: 'cache.l10n')]
|
||||
protected FrontendInterface $labelCache,
|
||||
protected EventDispatcherInterface $eventDispatcher,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fetches all found label files, this way, we can use the domain to populate the label cache
|
||||
* of a Package, as it also COULD include the package.
|
||||
*
|
||||
* When multiple files map to the same domain (e.g., locallang.xlf and messages.xlf both map
|
||||
* to ".messages"), files without the "locallang" prefix/suffix have precedence.
|
||||
*
|
||||
* Precedence order:
|
||||
* 1. Files without "locallang" prefix (e.g., messages.xlf, tabs.xlf)
|
||||
* 2. Files with "locallang_" prefix (e.g., locallang_toolbar.xlf)
|
||||
* 3. Plain locallang.xlf
|
||||
*/
|
||||
public function findLabelResourcesInPackage(string $packageKey): array
|
||||
{
|
||||
$packageKey = $this->packageManager->getPackageKeyFromComposerName($packageKey);
|
||||
$cacheIdentifier = 'translation-domains-of-package-' . $packageKey;
|
||||
$allLabelFilesOfPackage = $this->labelCache->get($cacheIdentifier);
|
||||
if (is_array($allLabelFilesOfPackage)) {
|
||||
return $allLabelFilesOfPackage;
|
||||
}
|
||||
$allLabelFilesOfPackage = $this->labelFileResolver->getAllLabelFilesOfPackage($packageKey);
|
||||
$domains = [];
|
||||
$domainPriorities = [];
|
||||
$package = $this->packageManager->getPackage($packageKey);
|
||||
foreach ($allLabelFilesOfPackage['default'] ?? [] as $file) {
|
||||
// Make path relative
|
||||
$file = $this->getRelativePath($file, $packageKey, $package);
|
||||
|
||||
$domain = $this->translationDomainResolver->mapFileNameToDomain($file);
|
||||
$priority = $this->getFilePriority($file);
|
||||
|
||||
// Only set/overwrite if this file has higher or equal priority
|
||||
if (!isset($domains[$domain]) || $priority >= $domainPriorities[$domain]) {
|
||||
$domains[$domain] = $file;
|
||||
$domainPriorities[$domain] = $priority;
|
||||
}
|
||||
}
|
||||
$event = new BeforeLabelResourceResolvedEvent(
|
||||
$packageKey,
|
||||
$domains
|
||||
);
|
||||
$event = $this->eventDispatcher->dispatch($event);
|
||||
$this->labelCache->set($cacheIdentifier, $event->domains);
|
||||
return $event->domains;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find label resources in a package grouped by locale.
|
||||
*
|
||||
* Returns an array where keys are locale codes and values are arrays of domain => resource mappings.
|
||||
* Applies the same precedence rules as findLabelResourcesInPackage().
|
||||
*
|
||||
* @return array<string, array<string, string>> Array grouped by locale, then domain => resource
|
||||
*/
|
||||
public function findLabelResourcesInPackageGroupedByLocale(string $packageKey): array
|
||||
{
|
||||
$packageKey = $this->packageManager->getPackageKeyFromComposerName($packageKey);
|
||||
$package = $this->packageManager->getPackage($packageKey);
|
||||
$allLabelFilesOfPackage = $this->labelFileResolver->getAllLabelFilesOfPackage($packageKey);
|
||||
|
||||
$domainsByLocale = [];
|
||||
$prioritiesByLocale = [];
|
||||
|
||||
foreach ($allLabelFilesOfPackage as $locale => $files) {
|
||||
$domainsByLocale[$locale] = [];
|
||||
$prioritiesByLocale[$locale] = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$file = $this->getRelativePath($file, $packageKey, $package);
|
||||
$domain = $this->translationDomainResolver->mapFileNameToDomain($file);
|
||||
$priority = $this->getFilePriority($file);
|
||||
|
||||
// Only set/overwrite if this file has higher or equal priority
|
||||
if (!isset($domainsByLocale[$locale][$domain]) || $priority >= $prioritiesByLocale[$locale][$domain]) {
|
||||
$domainsByLocale[$locale][$domain] = $file;
|
||||
$prioritiesByLocale[$locale][$domain] = $priority;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $domainsByLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine file priority for domain collision resolution.
|
||||
*
|
||||
* Higher priority wins when multiple files map to the same domain.
|
||||
*
|
||||
* Priority levels:
|
||||
* - 3: Files without "locallang" prefix (messages.xlf, tabs.xlf)
|
||||
* - 2: Files with "locallang_" prefix (locallang_toolbar.xlf)
|
||||
* - 1: Plain locallang.xlf
|
||||
*
|
||||
* This ensures modern naming (messages.xlf) takes precedence over legacy (locallang.xlf).
|
||||
*/
|
||||
protected function getFilePriority(string $filePath): int
|
||||
{
|
||||
$fileName = basename($filePath);
|
||||
$fileNameWithoutExtension = $this->translationDomainResolver->getFileReferenceWithoutExtension($fileName);
|
||||
|
||||
// Remove locale prefix if present (e.g., "de.locallang.xlf" -> "locallang.xlf")
|
||||
$locale = $this->translationDomainResolver->getLocaleFromLanguageFile($fileName);
|
||||
if ($locale !== null) {
|
||||
$fileNameWithoutExtension = substr($fileNameWithoutExtension, strlen($locale) + 1);
|
||||
}
|
||||
|
||||
// Priority 3: Files without "locallang" prefix (e.g., messages.xlf, tabs.xlf)
|
||||
if (!str_contains($fileNameWithoutExtension, 'locallang')) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
// Priority 2: Files with "locallang_" prefix (e.g., locallang_toolbar.xlf)
|
||||
if (str_starts_with($fileNameWithoutExtension, 'locallang_')) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Priority 1: Plain locallang.xlf
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a translation domain to a label resource file reference.
|
||||
*
|
||||
* Examples:
|
||||
* - "core.messages" -> "EXT:core/Resources/Private/Language/locallang.xlf"
|
||||
* - "backend.toolbar" -> "EXT:backend/Resources/Private/Language/locallang_toolbar.xlf"
|
||||
* - "core.form.tabs" -> "EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf"
|
||||
* - "felogin.sets.felogin" -> "EXT:felogin/Configuration/Sets/Felogin/labels.xlf"
|
||||
*
|
||||
* Invalid domain names return their values directly.
|
||||
* 1. More than one ":" included
|
||||
* 2. Domain Name contains other than [a-z0-9_.] characters
|
||||
*/
|
||||
public function mapDomainToFileName(string $domain): string
|
||||
{
|
||||
// In order to be deterministic, we need to find the proper file name to reference
|
||||
// now. It IS possible that the incoming domain is actually valid, so we skip it.
|
||||
|
||||
// If it's already an EXT: reference or absolute path, return as-is
|
||||
if (str_starts_with($domain, 'EXT:') || GeneralUtility::isAllowedAbsPath($domain)) {
|
||||
return $domain;
|
||||
}
|
||||
if (!$this->translationDomainResolver->isValidDomainName($domain)) {
|
||||
return $domain;
|
||||
}
|
||||
|
||||
// Parse domain into extension key part and resource part
|
||||
[$extensionKey, $resourcePart] = explode('.', $domain, 2);
|
||||
|
||||
try {
|
||||
$allDomainsInPackage = $this->findLabelResourcesInPackage($extensionKey);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return $domain;
|
||||
}
|
||||
|
||||
// Fall back to domain, in case it's just a file reference.
|
||||
return $allDomainsInPackage[$domain] ?? $domain;
|
||||
}
|
||||
|
||||
protected function getRelativePath(string $file, string $packageKey, PackageInterface $package): string
|
||||
{
|
||||
if (str_starts_with($file, $package->getPackagePath())) {
|
||||
$file = substr($file, strlen($package->getPackagePath()));
|
||||
$file = 'EXT:' . $packageKey . '/' . $file;
|
||||
} elseif (str_starts_with($file, Environment::getProjectPath())) {
|
||||
$file = substr($file, strlen(Environment::getProjectPath()) + 1);
|
||||
}
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization;
|
||||
|
||||
/**
|
||||
* Pure utility class for resolving translation domains from file paths.
|
||||
*
|
||||
* This class contains stateless methods for:
|
||||
* - Converting file paths to translation domains
|
||||
* - Validating domain names
|
||||
* - Extracting locale prefixes from file names
|
||||
* - Stripping file extensions
|
||||
*
|
||||
* It has no dependencies on other localization classes, making it safe to
|
||||
* inject into both LabelFileResolver and TranslationDomainMapper without
|
||||
* creating circular dependencies.
|
||||
*
|
||||
* @internal not part of TYPO3's public API.
|
||||
*/
|
||||
readonly class TranslationDomainResolver
|
||||
{
|
||||
/**
|
||||
* Maps a label resource file reference to a translation domain.
|
||||
*
|
||||
* Examples:
|
||||
* - "EXT:core/Resources/Private/Language/locallang.xlf" -> "core.messages"
|
||||
* - "EXT:backend/Resources/Private/Language/locallang_toolbar.xlf" -> "backend.toolbar"
|
||||
* - "EXT:felogin/Configuration/Sets/Felogin/labels.xlf" -> "felogin.sets.felogin"
|
||||
*/
|
||||
public function mapFileNameToDomain(string $fileName): string
|
||||
{
|
||||
// Extract extension key from EXT: path
|
||||
try {
|
||||
$extensionKey = $this->extractExtensionKey($fileName);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return $fileName;
|
||||
}
|
||||
|
||||
// Transform file path to resource name
|
||||
$resourceName = $this->transformFilePathToResource($fileName, $extensionKey);
|
||||
return $extensionKey . '.' . $resourceName;
|
||||
}
|
||||
|
||||
/**
|
||||
* A valid domain consists of lowercase letters, numbers, underscores only
|
||||
* and at least one dot in between.
|
||||
*/
|
||||
public function isValidDomainName(string $domain): bool
|
||||
{
|
||||
return (bool)preg_match('/^[a-z0-9_]+(\.[a-z0-9_]+)+$/', $domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the file extension from a file reference.
|
||||
*
|
||||
* Examples:
|
||||
* - "EXT:core/Resources/Private/Language/locallang.xlf" -> "EXT:core/Resources/Private/Language/locallang"
|
||||
* - "locallang.xlf" -> "locallang"
|
||||
*/
|
||||
public function getFileReferenceWithoutExtension(string $fileReference): string
|
||||
{
|
||||
return preg_replace('/\\.[a-z0-9]+$/i', '', $fileReference) ?? $fileReference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the locale prefix from a language file name.
|
||||
*
|
||||
* If a file is called "de_AT.locallang.xlf", this method returns "de_AT".
|
||||
* If there is no locale prefix, NULL is returned.
|
||||
*
|
||||
* However, for files like "db.xlf", "db" should not be detected as locale.
|
||||
*
|
||||
* Examples:
|
||||
* - "de.locallang.xlf" -> "de"
|
||||
* - "de_AT.locallang.xlf" -> "de_AT"
|
||||
* - "fr-CA.messages.xlf" -> "fr-CA"
|
||||
* - "locallang.xlf" -> null
|
||||
* - "db.xlf" -> null
|
||||
*/
|
||||
public function getLocaleFromLanguageFile(string $fileName): ?string
|
||||
{
|
||||
if (substr_count($fileName, '.') > 1 && preg_match('/^[a-z]{2}([_-][A-z]{2,3})?\./', $fileName)) {
|
||||
return substr($fileName, 0, strpos($fileName, '.'));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a file path to a resource name (for domain).
|
||||
*
|
||||
* Examples:
|
||||
* - "Resources/Private/Language/locallang.xlf" -> "messages"
|
||||
* - "Resources/Private/Language/locallang_toolbar.xlf" -> "toolbar"
|
||||
* - "Resources/Private/Language/Form/locallang_tabs.xlf" -> "form.tabs"
|
||||
* - "Configuration/Sets/Felogin/labels.xlf" -> "sets.felogin"
|
||||
* - "EXT:example/ContentBlocks/ContentElements/simple-relation/language/labels.xlf" -> "content_blocks.content_elements.simple_relation.language.labels"
|
||||
*/
|
||||
protected function transformFilePathToResource(string $filePath, string $extensionKey): string
|
||||
{
|
||||
$isExtensionPath = false;
|
||||
|
||||
// Remove EXT:extensionKey/ prefix
|
||||
$prefix = 'EXT:' . $extensionKey . '/';
|
||||
if (str_starts_with($filePath, $prefix)) {
|
||||
$isExtensionPath = true;
|
||||
$filePath = substr($filePath, strlen($prefix));
|
||||
}
|
||||
|
||||
// Remove locale prefix if present (e.g., "de.locallang.xlf" -> "locallang.xlf")
|
||||
$fileName = basename($filePath);
|
||||
$locale = $this->getLocaleFromLanguageFile($fileName);
|
||||
if ($locale !== null) {
|
||||
$fileName = substr($fileName, strlen($locale) + 1);
|
||||
$filePath = dirname($filePath) . '/' . $fileName;
|
||||
}
|
||||
|
||||
// Handle site sets: Configuration/Sets/{Name}/labels.xlf -> sets.{name}
|
||||
if (preg_match('#Configuration/Sets/([^/]+)/labels\.xlf$#', $filePath, $matches)) {
|
||||
$setName = $this->upperCamelCaseToSnakeCase($matches[1]);
|
||||
return 'sets.' . $setName;
|
||||
}
|
||||
|
||||
// Clean standard language files: Resources/Private/Language/...
|
||||
if (str_starts_with($filePath, 'Resources/Private/Language/')) {
|
||||
$isExtensionPath = true;
|
||||
$filePath = substr($filePath, strlen('Resources/Private/Language/'));
|
||||
}
|
||||
|
||||
// Handle extension paths
|
||||
if ($isExtensionPath) {
|
||||
// Split into directory and filename
|
||||
$pathParts = explode('/', $filePath);
|
||||
$fileName = array_pop($pathParts);
|
||||
|
||||
// Convert directories from UpperCamelCase to snake_case
|
||||
$resourceParts = array_map($this->upperCamelCaseToSnakeCase(...), $pathParts);
|
||||
|
||||
// Transform filename
|
||||
$fileNameWithoutExtension = $this->getFileReferenceWithoutExtension($fileName);
|
||||
|
||||
if ($fileNameWithoutExtension === 'locallang') {
|
||||
$resourceParts[] = 'messages';
|
||||
} elseif (str_starts_with($fileNameWithoutExtension, 'locallang_')) {
|
||||
// Remove locallang_ prefix (keep snake_case as-is)
|
||||
$suffix = substr($fileNameWithoutExtension, strlen('locallang_'));
|
||||
$suffix = $this->upperCamelCaseToSnakeCase($suffix);
|
||||
$resourceParts[] = $suffix;
|
||||
} else {
|
||||
// Use filename as-is, converted to snake_case
|
||||
$resourceParts[] = $this->upperCamelCaseToSnakeCase($fileNameWithoutExtension);
|
||||
}
|
||||
|
||||
return implode('.', $resourceParts);
|
||||
}
|
||||
|
||||
// Fallback: use filename without extension
|
||||
$fileNameWithoutExtension = $this->getFileReferenceWithoutExtension(basename($filePath));
|
||||
return $this->upperCamelCaseToSnakeCase($fileNameWithoutExtension);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the extension key from an EXT: file reference.
|
||||
*/
|
||||
protected function extractExtensionKey(string $filePath): string
|
||||
{
|
||||
if (!str_starts_with($filePath, 'EXT:')) {
|
||||
throw new \InvalidArgumentException('File path must start with "EXT:"', 1729000001);
|
||||
}
|
||||
|
||||
$withoutPrefix = substr($filePath, 4);
|
||||
$slashPos = strpos($withoutPrefix, '/');
|
||||
if ($slashPos === false) {
|
||||
throw new \InvalidArgumentException('Invalid EXT: reference format', 1729000002);
|
||||
}
|
||||
|
||||
return substr($withoutPrefix, 0, $slashPos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts UpperCamelCase to snake_case.
|
||||
*
|
||||
* Examples: "SudoMode" -> "sudo_mode", "Form" -> "form"
|
||||
*/
|
||||
protected function upperCamelCaseToSnakeCase(string $input): string
|
||||
{
|
||||
// Insert underscores before uppercase letters (except at start) and convert to lowercase
|
||||
$result = preg_replace('/([a-z0-9])([A-Z])/', '$1_$2', $input);
|
||||
$result = strtolower($result ?? $input);
|
||||
return str_replace('-', '_', $result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Core\Localization;
|
||||
|
||||
/**
|
||||
* Interface for translation services.
|
||||
*
|
||||
* This interface provides a clean abstraction for translating labels in TYPO3.
|
||||
* It uses ICU MessageFormat style where pluralization and other complex formatting
|
||||
* logic is embedded in the message string itself, not in the API signature.
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* // Simple translation
|
||||
* $translator->translate('button.save', 'backend.messages');
|
||||
*
|
||||
* // Translation with arguments (sprintf-style placeholders)
|
||||
* $translator->translate('record.count', 'backend.messages', [5]);
|
||||
*
|
||||
* // ICU MessageFormat style for plurals (message: "{count, plural, one {# item} other {# items}}")
|
||||
* $translator->translate('items.count', 'backend.messages+intl-icu', ['count' => 5]);
|
||||
*
|
||||
* @see https://unicode-org.github.io/icu/userguide/format_parse/messages/ ICU MessageFormat
|
||||
*/
|
||||
interface TranslatorInterface
|
||||
{
|
||||
/**
|
||||
* Translate a label by its identifier and domain.
|
||||
*
|
||||
* @param string $id The label identifier/key
|
||||
* @param string $domain The translation domain (file reference like 'EXT:core/Resources/Private/Language/locallang.xlf'
|
||||
* or semantic domain like 'core.messages'). For ICU MessageFormat, suffix with '+intl-icu'.
|
||||
* @param array $arguments Optional arguments for placeholder replacement. For sprintf-style messages,
|
||||
* pass indexed values. For ICU messages, pass named values (e.g., ['count' => 5]).
|
||||
* @param string|null $default Optional default value
|
||||
* @param Locale|string|null $locale Optional locale override. If null, uses the service's configured locale.
|
||||
* @return string|\Stringable|null The translated string, or null if the label was not found
|
||||
*/
|
||||
public function translate(string $id, string $domain, array $arguments = [], ?string $default = null, Locale|string|null $locale = null): string|\Stringable|null;
|
||||
|
||||
/**
|
||||
* Translate a label by its full reference string.
|
||||
*
|
||||
* Resolves TYPO3 label reference strings in the formats:
|
||||
*
|
||||
* 'core.messages:labels.depth_0'
|
||||
* 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_0'
|
||||
* 'EXT:core/Resources/Private/Language/locallang_core.xlf:labels.depth_0'
|
||||
*
|
||||
* The LLL: prefix is optional and stripped before resolution.
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* // Simple reference
|
||||
* $translator->label('myext.messages:button.save');
|
||||
*
|
||||
* // Simple reference (discouraged with file reference)
|
||||
* $translator->label('LLL:EXT:my_ext/Resources/Private/Language/locallang.xlf:button.save');
|
||||
*
|
||||
* // With arguments
|
||||
* $translator->label('core.messages:record.count', [5]);
|
||||
*
|
||||
* // With default value
|
||||
* $translator->label('core.messages:missing.key', [], 'Fallback text');
|
||||
*
|
||||
* @param string $reference The label reference string (with or without LLL: prefix)
|
||||
* @param array $arguments Optional arguments for placeholder replacement
|
||||
* @param string|null $default Optional default value returned when the label is not found
|
||||
* @param Locale|string|null $locale Optional locale override. If null, uses the service's configured locale.
|
||||
* @return string|\Stringable|null The translated string, the default value, or null if the label was not found
|
||||
*/
|
||||
public function label(string $reference, array $arguments = [], ?string $default = null, Locale|string|null $locale = null): string|\Stringable|null;
|
||||
}
|
||||
Reference in New Issue
Block a user