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:
Kevin Papst
2025-05-26 20:59:39 +02:00
committed by GitHub
parent 87168de4f1
commit e69ac6968b
9 changed files with 282 additions and 154 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
parameters:
locale: en
# can be regenerated with: bin/console kimai:reset:locales
kimai_locales: ['ar', 'cs', 'da', 'de', 'de_CH', 'el', 'en', 'eo', 'es', 'eu', 'fa', 'fi', 'fo', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'nb_NO', 'nl', 'pa', 'pl', 'pt', 'pt_BR', 'ro', 'ru', 'sk', 'sl', 'sv', 'ta', 'tr', 'uk', 'vi', 'zh_CN', 'zh_Hant', 'zh_Hant_TW', 'cs_CZ', 'da_DK', 'da_GL', 'de_AT', 'de_BE', 'de_CH', 'de_DE', 'de_IT', 'de_LI', 'de_LU', 'el_CY', 'el_GR', 'en_AE', 'en_AG', 'en_AI', 'en_AS', 'en_AT', 'en_AU', 'en_BB', 'en_BE', 'en_BI', 'en_BM', 'en_BS', 'en_BW', 'en_BZ', 'en_CA', 'en_CC', 'en_CH', 'en_CK', 'en_CM', 'en_CX', 'en_CY', 'en_DE', 'en_DG', 'en_DK', 'en_DM', 'en_ER', 'en_FI', 'en_FJ', 'en_FK', 'en_FM', 'en_GB', 'en_GD', 'en_GG', 'en_GH', 'en_GI', 'en_GM', 'en_GU', 'en_GY', 'en_HK', 'en_ID', 'en_IE', 'en_IL', 'en_IM', 'en_IN', 'en_IO', 'en_JE', 'en_JM', 'en_KE', 'en_KI', 'en_KN', 'en_KY', 'en_LC', 'en_LR', 'en_LS', 'en_MG', 'en_MH', 'en_MO', 'en_MP', 'en_MS', 'en_MT', 'en_MU', 'en_MV', 'en_MW', 'en_MY', 'en_NA', 'en_NF', 'en_NG', 'en_NH', 'en_NL', 'en_NR', 'en_NU', 'en_NZ', 'en_PG', 'en_PH', 'en_PK', 'en_PN', 'en_PR', 'en_PW', 'en_RH', 'en_RW', 'en_SB', 'en_SC', 'en_SD', 'en_SE', 'en_SG', 'en_SH', 'en_SI', 'en_SL', 'en_SS', 'en_SX', 'en_SZ', 'en_TC', 'en_TK', 'en_TO', 'en_TT', 'en_TV', 'en_TZ', 'en_UG', 'en_UM', 'en_US', 'en_VC', 'en_VG', 'en_VI', 'en_VU', 'en_WS', 'en_ZA', 'en_ZM', 'en_ZW', 'es_AR', 'es_BO', 'es_BR', 'es_BZ', 'es_CL', 'es_CO', 'es_CR', 'es_CU', 'es_DO', 'es_EA', 'es_EC', 'es_ES', 'es_GQ', 'es_GT', 'es_HN', 'es_IC', 'es_MX', 'es_NI', 'es_PA', 'es_PE', 'es_PH', 'es_PR', 'es_PY', 'es_SV', 'es_US', 'es_UY', 'es_VE', 'eu_ES', 'fa_AF', 'fa_IR', 'fi_FI', 'fo_DK', 'fo_FO', 'fr_BE', 'fr_BF', 'fr_BI', 'fr_BJ', 'fr_BL', 'fr_CA', 'fr_CD', 'fr_CF', 'fr_CG', 'fr_CH', 'fr_CI', 'fr_CM', 'fr_DJ', 'fr_DZ', 'fr_FR', 'fr_GA', 'fr_GF', 'fr_GN', 'fr_GP', 'fr_GQ', 'fr_HT', 'fr_KM', 'fr_LU', 'fr_MA', 'fr_MC', 'fr_MF', 'fr_MG', 'fr_ML', 'fr_MQ', 'fr_MR', 'fr_MU', 'fr_NC', 'fr_NE', 'fr_PF', 'fr_PM', 'fr_RE', 'fr_RW', 'fr_SC', 'fr_SN', 'fr_SY', 'fr_TD', 'fr_TG', 'fr_TN', 'fr_VU', 'fr_WF', 'fr_YT', 'he_IL', 'hr_BA', 'hr_HR', 'hu_HU', 'it_CH', 'it_IT', 'it_SM', 'it_VA', 'ja_JP', 'ko_CN', 'ko_KP', 'ko_KR', 'nl_AW', 'nl_BE', 'nl_BQ', 'nl_CW', 'nl_NL', 'nl_SR', 'nl_SX', 'pl_PL', 'pt_AO', 'pt_BR', 'pt_CH', 'pt_CV', 'pt_GQ', 'pt_GW', 'pt_LU', 'pt_MO', 'pt_MZ', 'pt_PT', 'pt_ST', 'pt_TL', 'ro_MD', 'ro_RO', 'ru_BY', 'ru_KG', 'ru_KZ', 'ru_MD', 'ru_RU', 'ru_UA', 'sk_SK', 'sv_AX', 'sv_FI', 'sv_SE', 'tr_CY', 'tr_TR', 'uk_UA', 'vi_VN']
kimai_locales: ['ar', 'cs', 'da', 'de', 'de_CH', 'el', 'en', 'eo', 'es', 'eu', 'fa', 'fi', 'fo', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'nb_NO', 'nl', 'pa', 'pl', 'pt', 'pt_BR', 'ro', 'ru', 'sk', 'sl', 'sv', 'ta', 'tr', 'uk', 'vi', 'zh_CN', 'zh_Hant', 'zh_Hant_TW', 'cs_CZ', 'da_DK', 'da_GL', 'de_AT', 'de_BE', 'de_CH', 'de_DE', 'de_IT', 'de_LI', 'de_LU', 'el_CY', 'el_GR', 'en_AE', 'en_AG', 'en_AI', 'en_AS', 'en_AT', 'en_AU', 'en_BB', 'en_BE', 'en_BI', 'en_BM', 'en_BS', 'en_BW', 'en_BZ', 'en_CA', 'en_CC', 'en_CH', 'en_CK', 'en_CM', 'en_CX', 'en_CY', 'en_CZ', 'en_DE', 'en_DG', 'en_DK', 'en_DM', 'en_ER', 'en_ES', 'en_FI', 'en_FJ', 'en_FK', 'en_FM', 'en_FR', 'en_GB', 'en_GD', 'en_GG', 'en_GH', 'en_GI', 'en_GM', 'en_GS', 'en_GU', 'en_GY', 'en_HK', 'en_HU', 'en_ID', 'en_IE', 'en_IL', 'en_IM', 'en_IN', 'en_IO', 'en_IT', 'en_JE', 'en_JM', 'en_KE', 'en_KI', 'en_KN', 'en_KY', 'en_LC', 'en_LR', 'en_LS', 'en_MG', 'en_MH', 'en_MO', 'en_MP', 'en_MS', 'en_MT', 'en_MU', 'en_MV', 'en_MW', 'en_MY', 'en_NA', 'en_NF', 'en_NG', 'en_NH', 'en_NL', 'en_NO', 'en_NR', 'en_NU', 'en_NZ', 'en_PG', 'en_PH', 'en_PK', 'en_PL', 'en_PN', 'en_PR', 'en_PT', 'en_PW', 'en_RH', 'en_RO', 'en_RW', 'en_SB', 'en_SC', 'en_SD', 'en_SE', 'en_SG', 'en_SH', 'en_SI', 'en_SK', 'en_SL', 'en_SS', 'en_SX', 'en_SZ', 'en_TC', 'en_TK', 'en_TO', 'en_TT', 'en_TV', 'en_TZ', 'en_UG', 'en_UM', 'en_US', 'en_VC', 'en_VG', 'en_VI', 'en_VU', 'en_WS', 'en_ZA', 'en_ZM', 'en_ZW', 'es_AR', 'es_BO', 'es_BR', 'es_BZ', 'es_CL', 'es_CO', 'es_CR', 'es_CU', 'es_DO', 'es_EA', 'es_EC', 'es_ES', 'es_GQ', 'es_GT', 'es_HN', 'es_IC', 'es_MX', 'es_NI', 'es_PA', 'es_PE', 'es_PH', 'es_PR', 'es_PY', 'es_SV', 'es_US', 'es_UY', 'es_VE', 'eu_ES', 'fa_AF', 'fa_IR', 'fi_FI', 'fo_DK', 'fo_FO', 'fr_BE', 'fr_BF', 'fr_BI', 'fr_BJ', 'fr_BL', 'fr_CA', 'fr_CD', 'fr_CF', 'fr_CG', 'fr_CH', 'fr_CI', 'fr_CM', 'fr_DJ', 'fr_DZ', 'fr_FR', 'fr_GA', 'fr_GF', 'fr_GN', 'fr_GP', 'fr_GQ', 'fr_HT', 'fr_KM', 'fr_LU', 'fr_MA', 'fr_MC', 'fr_MF', 'fr_MG', 'fr_ML', 'fr_MQ', 'fr_MR', 'fr_MU', 'fr_NC', 'fr_NE', 'fr_PF', 'fr_PM', 'fr_RE', 'fr_RW', 'fr_SC', 'fr_SN', 'fr_SY', 'fr_TD', 'fr_TG', 'fr_TN', 'fr_VU', 'fr_WF', 'fr_YT', 'he_IL', 'hr_BA', 'hr_HR', 'hu_HU', 'it_CH', 'it_IT', 'it_SM', 'it_VA', 'ja_JP', 'ko_CN', 'ko_KP', 'ko_KR', 'nl_AW', 'nl_BE', 'nl_BQ', 'nl_CW', 'nl_NL', 'nl_SR', 'nl_SX', 'pl_PL', 'pt_AO', 'pt_BR', 'pt_CH', 'pt_CV', 'pt_GQ', 'pt_GW', 'pt_LU', 'pt_MO', 'pt_MZ', 'pt_PT', 'pt_ST', 'pt_TL', 'ro_MD', 'ro_RO', 'ru_BY', 'ru_KG', 'ru_KZ', 'ru_MD', 'ru_RU', 'ru_UA', 'sk_SK', 'sv_AX', 'sv_FI', 'sv_SE', 'tr_CY', 'tr_TR', 'uk_UA', 'vi_VN']
services:
# default configuration for services in *this* file

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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),

View File

@@ -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);

View File

@@ -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)

View File

@@ -1,6 +1,10 @@
{% extends 'datatable.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block datatable_column_attr %}
{%- if entry.rtl %} dir="rtl"{% endif -%}
{% endblock %}
{% block datatable_column_value %}
{% if column == 'name' %}
{{ entry.name }}
@@ -18,6 +22,10 @@
{{ entry.decimal }}
{% elseif column == 'money' %}
{{ entry.money }}
{% elseif column == 'time_format' %}
{{ entry.time_format }}
{% elseif column == 'date_format' %}
{{ entry.date_format }}
{% elseif column == 'hour_24' %}
{{ widgets.label_boolean(entry.hour_24) }}
{% elseif column == 'rtl' %}

View File

@@ -68,20 +68,20 @@ class AppExtensionTest extends TestCase
'kimai.data_dir' => '/tmp/',
'kimai.languages' => [
'en' => [
'date' => 'M/d/yy',
'date' => 'M/d/y',
'time' => 'h:mm a',
'rtl' => false,
'translation' => true,
],
'de' => [
'date' => 'dd.MM.yy',
'date' => 'dd.MM.y',
'time' => 'HH:mm',
'rtl' => false,
'translation' => true,
],
'he' => [
'date' => 'd.M.y',
'time' => 'H:mm',
'time' => 'HH:mm',
'rtl' => true,
'translation' => true,
],