Improved Locales (#5497)
- Switch Chinese time format to 24-hours - fixes #5496 - Use always 4 chars for year - Use one `HH` instead of `H`, so we don't have to convert during runtime - Update all locales - Allow seeing time and date format in help UI - Fix RTL in UI - Show stats about found formats in Locales command
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use App\Utils\LocaleFormatter;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -41,7 +42,7 @@ final class RegenerateLocalesCommand extends Command
|
||||
*/
|
||||
private array $noRegionCode = ['ar', 'id', 'pa', 'sl', 'ca', 'ta'];
|
||||
/**
|
||||
* A list of locales that will be activated, no matter if translation files exist for them.
|
||||
* A list of locales that will be activated no matter if translation files exist for them.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
@@ -122,14 +123,17 @@ final class RegenerateLocalesCommand extends Command
|
||||
$appLocales[$locale] = LocaleService::DEFAULT_SETTINGS;
|
||||
}
|
||||
|
||||
$timeFormats = [];
|
||||
$dateFormats = [];
|
||||
|
||||
// make sure all keys are registered for every locale
|
||||
foreach ($appLocales as $locale => $settings) {
|
||||
$settings['translation'] = \in_array($locale, $firstLevelLocales, true);
|
||||
|
||||
// these are completely new since v2
|
||||
// calculate everything with IntlFormatter
|
||||
$shortDate = new \IntlDateFormatter($locale, \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE);
|
||||
$shortTime = new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::SHORT);
|
||||
$shortDate = new \IntlDateFormatter($locale, LocaleFormatter::DATE_PATTERN, \IntlDateFormatter::NONE);
|
||||
$shortTime = new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, LocaleFormatter::TIME_PATTERN);
|
||||
|
||||
$settings['date'] = $shortDate->getPattern();
|
||||
if ($settings['date'] === false) {
|
||||
@@ -142,14 +146,28 @@ final class RegenerateLocalesCommand extends Command
|
||||
continue;
|
||||
}
|
||||
|
||||
// see https://github.com/kimai/kimai/issues/4402 - Korean time format failed parsing
|
||||
// special case when time pattern starts with A / a => this will lead to an error
|
||||
// CHINESE: contains the format character B - see https://github.com/kimai/kimai/issues/5496
|
||||
// It is an equivalent for "a" and acts like am/pm but will be prefixed instead of written after the time.
|
||||
// This clashes with PHP Date format "B" (Swatch Internet time) and fails in other places, so we convert it into 24-hour format.
|
||||
if (str_contains($settings['time'], 'Bh')) {
|
||||
$settings['time'] = str_replace('Bh', 'H', $settings['time']);
|
||||
}
|
||||
|
||||
// KOREAN: time format failed parsing - see https://github.com/kimai/kimai/issues/4402
|
||||
// Special case where time-patterns start with A / a => this will lead to an error
|
||||
// \DateTimeImmutable::getLastErrors() => Meridian can only come after an hour has been found
|
||||
if (str_contains($settings['time'], 'a ')) {
|
||||
$settings['time'] = str_replace('a ', '', $settings['time']) . ' a';
|
||||
}
|
||||
$settings['time'] = str_replace("\u{202f}", ' ', $settings['time']);
|
||||
|
||||
// keep it simple, we don't need to convert it during runtime
|
||||
$settings['time'] = str_replace('HH', 'H', $settings['time']);
|
||||
$settings['time'] = str_replace('H', 'HH', $settings['time']);
|
||||
|
||||
// format the year always with 4 letters - ISO-8601
|
||||
$settings['date'] = str_replace('yy', 'y', $settings['date']);
|
||||
|
||||
// make sure that sub-locales of a RTL language are also flagged as RTL
|
||||
$rtlLocale = $locale;
|
||||
if (substr_count($rtlLocale, '_') === 1) {
|
||||
@@ -160,6 +178,9 @@ final class RegenerateLocalesCommand extends Command
|
||||
|
||||
// pre-fill all formats with the default locale settings
|
||||
$appLocales[$locale] = $settings;
|
||||
|
||||
$timeFormats[$settings['time']] = $settings['time'];
|
||||
$dateFormats[$settings['date']] = $settings['date'];
|
||||
}
|
||||
|
||||
$removableDuplicates = [];
|
||||
@@ -230,6 +251,12 @@ final class RegenerateLocalesCommand extends Command
|
||||
|
||||
$io->success('Created new locale definition at: ' . $filename);
|
||||
|
||||
$io->writeln(\sprintf('Found %s date formats:', \count($dateFormats)));
|
||||
$io->listing(array_keys($dateFormats));
|
||||
|
||||
$io->writeln(\sprintf('Found %s time formats:', \count($timeFormats)));
|
||||
$io->listing(array_keys($timeFormats));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,13 @@
|
||||
namespace App\Configuration;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Utils\Duration;
|
||||
|
||||
final class LocaleService
|
||||
{
|
||||
public const DEFAULT_SETTINGS = [
|
||||
'date' => 'dd.MM.y',
|
||||
'time' => 'HH:mm',
|
||||
'time' => 'H:mm',
|
||||
'rtl' => false,
|
||||
'translation' => false,
|
||||
];
|
||||
@@ -55,10 +56,7 @@ final class LocaleService
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the locale specific date format, which should be used in combination with the twig filter "|date".
|
||||
*
|
||||
* @param string $locale
|
||||
* @return string
|
||||
* Returns the locale-specific date format, which should be used in combination with the twig filter "|date".
|
||||
*/
|
||||
public function getDateFormat(string $locale): string
|
||||
{
|
||||
@@ -66,10 +64,7 @@ final class LocaleService
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the locale specific time format, which should be used in combination with the twig filter "|time".
|
||||
*
|
||||
* @param string $locale
|
||||
* @return string
|
||||
* Returns the locale-specific time format, which should be used in combination with the twig filter "|time".
|
||||
*/
|
||||
public function getTimeFormat(string $locale): string
|
||||
{
|
||||
@@ -77,10 +72,7 @@ final class LocaleService
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the locale specific datetime format, which should be used in combination with the twig filter "|date".
|
||||
*
|
||||
* @param string $locale
|
||||
* @return string
|
||||
* Returns the locale-specific datetime format, which should be used in combination with the twig filter "|date".
|
||||
*/
|
||||
public function getDateTimeFormat(string $locale): string
|
||||
{
|
||||
@@ -89,13 +81,10 @@ final class LocaleService
|
||||
|
||||
/**
|
||||
* Returns the format used in the "|duration" twig filter to display a Timesheet duration.
|
||||
*
|
||||
* @param string $locale
|
||||
* @return string
|
||||
*/
|
||||
public function getDurationFormat(string $locale): string
|
||||
{
|
||||
return '%h:%m';
|
||||
return Duration::FORMAT_DEFAULT;
|
||||
}
|
||||
|
||||
public function isRightToLeft(string $locale): bool
|
||||
|
||||
@@ -32,6 +32,8 @@ final class HelpController extends AbstractController
|
||||
$table = new DataTable('help_locales', new BaseQuery());
|
||||
$table->addColumn('name', ['class' => 'alwaysVisible', 'orderBy' => false]);
|
||||
$table->addColumn('description', ['class' => 'd-none', 'orderBy' => false]);
|
||||
$table->addColumn('date_format', ['class' => 'd-none w-min', 'orderBy' => false, 'title' => 'Date format']);
|
||||
$table->addColumn('time_format', ['class' => 'd-none w-min', 'orderBy' => false, 'title' => 'Time format']);
|
||||
$table->addColumn('language', ['class' => 'd-none w-min', 'orderBy' => false]);
|
||||
$table->addColumn('date', ['class' => 'alwaysVisible w-min', 'orderBy' => false]);
|
||||
$table->addColumn('time', ['class' => 'alwaysVisible w-min text-center', 'orderBy' => false]);
|
||||
@@ -73,6 +75,8 @@ final class HelpController extends AbstractController
|
||||
'language' => $locale,
|
||||
'name' => Locales::getName($locale, $locale),
|
||||
'description' => Locales::getName($locale, $requestLocale),
|
||||
'time_format' => $service->getTimeFormat($locale),
|
||||
'date_format' => $service->getDateFormat($locale),
|
||||
'date' => $formatter->dateShort($now),
|
||||
'time' => $formatter->time($now),
|
||||
'duration' => $formatter->duration(46120),
|
||||
|
||||
@@ -56,6 +56,9 @@ final class FormFormatConverter
|
||||
|
||||
$pattern = $format;
|
||||
|
||||
// special case for Chinese
|
||||
$pattern = str_replace('B', '', $pattern);
|
||||
|
||||
// special case fr_CA
|
||||
$pattern = str_replace('\\\\h', '*****', $pattern);
|
||||
$pattern = str_replace('\\h', '*****', $pattern);
|
||||
|
||||
@@ -18,10 +18,33 @@ use NumberFormatter;
|
||||
use Symfony\Component\Intl\Currencies;
|
||||
|
||||
/**
|
||||
* Use this class to format values into locale specific representations.
|
||||
* Use this class to format values into locale-specific representations.
|
||||
*/
|
||||
final class LocaleFormatter
|
||||
{
|
||||
/**
|
||||
* Special locales to test:
|
||||
*
|
||||
* - el h:mm a 9:41 π.μ.
|
||||
* - fr_CA HH 'h' mm 09 h 41
|
||||
* - ko h:mm a 10:18 오전
|
||||
* - tr_CY h:mm a 9:41 ÖÖ
|
||||
* - pa h:mm a 10:18 ਪੂ.ਦੁ.
|
||||
* - pt_MO h:mm a 9:41 da manhã
|
||||
* - zh_Hant Bh:mm 上午9:41
|
||||
* - zh_Hant_TW Bh:mm 上午9:41
|
||||
*/
|
||||
// IntlDateFormatter::RELATIVE_* - not usable for times
|
||||
// IntlDateFormatter::LONG - with seconds and timezone, and translation for h and min if locale supports it
|
||||
// IntlDateFormatter::MEDIUM - with seconds, and translation for h and min if locale supports it
|
||||
// IntlDateFormatter::SHORT - no seconds, no timezone - but translates e.g. am/pm to locale specific like zh_Hant
|
||||
public const TIME_PATTERN = \IntlDateFormatter::SHORT;
|
||||
// IntlDateFormatter::RELATIVE_* - translates words for "today" and "yesterday"
|
||||
// IntlDateFormatter::LONG - translates the month name
|
||||
// IntlDateFormatter::MEDIUM - date as we likely want to use it, but with translations if locale supports it, e.g. 2025年5月26日
|
||||
// IntlDateFormatter::SHORT - date with dots, but year with two numbers in some locales causing conflicts e.g., in invoices
|
||||
public const DATE_PATTERN = \IntlDateFormatter::SHORT;
|
||||
|
||||
private ?Duration $durationFormatter = null;
|
||||
private ?IntlDateFormatter $dateFormatter = null;
|
||||
private ?IntlDateFormatter $dateTimeFormatter = null;
|
||||
@@ -44,10 +67,7 @@ final class LocaleFormatter
|
||||
return $this->durationDecimal($duration);
|
||||
}
|
||||
|
||||
return $this->formatDuration(
|
||||
$this->getSecondsForDuration($duration),
|
||||
$this->localeService->getDurationFormat($this->locale)
|
||||
);
|
||||
return $this->formatDuration($this->getSecondsForDuration($duration));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,13 +104,13 @@ final class LocaleFormatter
|
||||
return (int) $duration;
|
||||
}
|
||||
|
||||
private function formatDuration(int $seconds, string $format): string
|
||||
private function formatDuration(int $seconds): string
|
||||
{
|
||||
if ($this->durationFormatter === null) {
|
||||
$this->durationFormatter = new Duration();
|
||||
}
|
||||
|
||||
return $this->durationFormatter->format($seconds, $format);
|
||||
return $this->durationFormatter->format($seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,8 +190,8 @@ final class LocaleFormatter
|
||||
if (null === $this->dateFormatter) {
|
||||
$this->dateFormatter = new IntlDateFormatter(
|
||||
$this->locale,
|
||||
IntlDateFormatter::MEDIUM,
|
||||
IntlDateFormatter::MEDIUM,
|
||||
self::DATE_PATTERN,
|
||||
IntlDateFormatter::NONE,
|
||||
date_default_timezone_get(),
|
||||
IntlDateFormatter::GREGORIAN,
|
||||
$this->localeService->getDateFormat($this->locale)
|
||||
@@ -204,8 +224,8 @@ final class LocaleFormatter
|
||||
if (null === $this->dateTimeFormatter) {
|
||||
$this->dateTimeFormatter = new IntlDateFormatter(
|
||||
$this->locale,
|
||||
IntlDateFormatter::MEDIUM,
|
||||
IntlDateFormatter::MEDIUM,
|
||||
self::DATE_PATTERN,
|
||||
self::TIME_PATTERN,
|
||||
date_default_timezone_get(),
|
||||
IntlDateFormatter::GREGORIAN,
|
||||
$this->localeService->getDateTimeFormat($this->locale)
|
||||
@@ -255,8 +275,8 @@ final class LocaleFormatter
|
||||
if (null === $this->timeFormatter) {
|
||||
$this->timeFormatter = new IntlDateFormatter(
|
||||
$this->locale,
|
||||
IntlDateFormatter::MEDIUM,
|
||||
IntlDateFormatter::MEDIUM,
|
||||
IntlDateFormatter::NONE,
|
||||
self::TIME_PATTERN,
|
||||
date_default_timezone_get(),
|
||||
IntlDateFormatter::GREGORIAN,
|
||||
$this->localeService->getTimeFormat($this->locale)
|
||||
|
||||
Reference in New Issue
Block a user