- 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
263 lines
9.9 KiB
PHP
263 lines
9.9 KiB
PHP
<?php
|
|
|
|
/*
|
|
* This file is part of the Kimai time-tracking app.
|
|
*
|
|
* For the full copyright and license information, please view the LICENSE
|
|
* file that was distributed with this source code.
|
|
*/
|
|
|
|
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;
|
|
use Symfony\Component\Console\Output\OutputInterface;
|
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
|
use Symfony\Component\Intl\Locales;
|
|
|
|
/**
|
|
* Command used to create the locale definition.
|
|
*
|
|
* We do NOT calculate that on every system again, because we want to make sure that we have the same
|
|
* settings in every environment. Some environments (e.g. Github-Actions) have diverging settings from
|
|
* the local system.
|
|
*
|
|
* @codeCoverageIgnore
|
|
*/
|
|
#[AsCommand(name: 'kimai:reset:locales', description: 'Regenerate the locale definition file')]
|
|
final class RegenerateLocalesCommand extends Command
|
|
{
|
|
/**
|
|
* @var string[]
|
|
*/
|
|
private array $rtlLocales = ['ar', 'fa', 'he'];
|
|
/**
|
|
* new locales were added here, to shrink the list a little bit
|
|
* this can be removed in the future, if there will ever be the need for it
|
|
*
|
|
* @var string[]
|
|
*/
|
|
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.
|
|
*
|
|
* @var string[]
|
|
*/
|
|
private array $addLocaleToList = ['zh_Hant_TW'];
|
|
/**
|
|
* A list of locales that will NOT be activated, as not enough translations exist by now.
|
|
*
|
|
* @var string[]
|
|
*/
|
|
private array $skipLocale = ['ca', 'et'];
|
|
|
|
public function __construct(
|
|
private readonly string $projectDirectory,
|
|
private readonly string $kernelEnvironment
|
|
)
|
|
{
|
|
parent::__construct();
|
|
}
|
|
|
|
public function isEnabled(): bool
|
|
{
|
|
return $this->kernelEnvironment !== 'prod';
|
|
}
|
|
|
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
|
{
|
|
$io = new SymfonyStyle($input, $output);
|
|
|
|
// find all available locales from the translation filenames
|
|
$translationFilenames = glob($this->projectDirectory . DIRECTORY_SEPARATOR . 'translations/*.xlf');
|
|
if ($translationFilenames === false) {
|
|
$io->error('Failed reading translation files');
|
|
|
|
return Command::FAILURE;
|
|
}
|
|
$firstLevelLocales = [];
|
|
foreach ($translationFilenames as $file) {
|
|
$l = explode('.', basename($file))[1];
|
|
if (\in_array($l, $this->skipLocale, true)) {
|
|
continue;
|
|
}
|
|
$firstLevelLocales[] = $l;
|
|
}
|
|
$firstLevelLocales = array_unique(array_merge($firstLevelLocales, $this->addLocaleToList));
|
|
|
|
$io->title('First level locales found');
|
|
$io->writeln(implode('|', $firstLevelLocales));
|
|
|
|
$secondLevel = [];
|
|
foreach (Locales::getLocales() as $localeCode) {
|
|
$locale = explode('_', $localeCode);
|
|
if (\count($locale) === 2 && !\in_array($locale[0], $this->noRegionCode, true)) {
|
|
$baseLocale = $locale[0];
|
|
if (\in_array($baseLocale, $firstLevelLocales)) {
|
|
$regionCode = $locale[1];
|
|
if (!is_numeric($regionCode)) {
|
|
$secondLevel[] = $localeCode;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
sort($firstLevelLocales);
|
|
sort($secondLevel);
|
|
|
|
// keep the locales that have translation files at the beginning
|
|
// the config is than easier to read and the locales will be sorted in the UI anyway
|
|
$locales = array_merge($firstLevelLocales, $secondLevel);
|
|
|
|
$appLocales = [];
|
|
|
|
// make sure all allowed locales are registered
|
|
foreach ($locales as $locale) {
|
|
if (!Locales::exists($locale)) {
|
|
continue;
|
|
}
|
|
|
|
$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, LocaleFormatter::DATE_PATTERN, \IntlDateFormatter::NONE);
|
|
$shortTime = new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, LocaleFormatter::TIME_PATTERN);
|
|
|
|
$settings['date'] = $shortDate->getPattern();
|
|
if ($settings['date'] === false) {
|
|
$io->error('Invalid date pattern for locale: ' . $locale);
|
|
continue;
|
|
}
|
|
$settings['time'] = $shortTime->getPattern();
|
|
if ($settings['time'] === false) {
|
|
$io->error('Invalid time pattern for locale: ' . $locale);
|
|
continue;
|
|
}
|
|
|
|
// 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) {
|
|
$rtlLocale = substr($rtlLocale, 0, strpos($rtlLocale, '_'));
|
|
}
|
|
|
|
$settings['rtl'] = \in_array($rtlLocale, $this->rtlLocales, true);
|
|
|
|
// pre-fill all formats with the default locale settings
|
|
$appLocales[$locale] = $settings;
|
|
|
|
$timeFormats[$settings['time']] = $settings['time'];
|
|
$dateFormats[$settings['date']] = $settings['date'];
|
|
}
|
|
|
|
$removableDuplicates = [];
|
|
foreach ($appLocales as $locale => $setting) {
|
|
$localeParts = explode('_', $locale);
|
|
if (\count($localeParts) === 1) {
|
|
continue;
|
|
}
|
|
// e.g. norwegian just exists with region code
|
|
if (!\array_key_exists($localeParts[0], $appLocales)) {
|
|
continue;
|
|
}
|
|
$baseLocaleSettings = $appLocales[$localeParts[0]];
|
|
if ($baseLocaleSettings['time'] !== $setting['time']) {
|
|
continue;
|
|
}
|
|
if ($baseLocaleSettings['date'] !== $setting['date']) {
|
|
continue;
|
|
}
|
|
if ($setting['translation'] === true) {
|
|
continue;
|
|
}
|
|
if ($baseLocaleSettings['rtl'] !== $setting['rtl']) {
|
|
continue;
|
|
}
|
|
$removableDuplicates[] = $locale;
|
|
}
|
|
|
|
/*
|
|
$io->title('Redundant locales that could be skipped');
|
|
$io->writeln(implode('|', $removableDuplicates));
|
|
|
|
foreach ($removableDuplicates as $duplicate) {
|
|
unset($appLocales[$duplicate]);
|
|
}
|
|
*/
|
|
|
|
// in the future this list should be reduced to the list of available translations, but for a long time users
|
|
// could choose from the entire list of all locales, so we likely have to keep that forever ...
|
|
$listOfLocales = array_map(fn ($locale) => "'$locale'", $locales);
|
|
$filename = 'config/services.yaml';
|
|
$targetFile = $this->projectDirectory . DIRECTORY_SEPARATOR . $filename;
|
|
$content = file_get_contents($targetFile);
|
|
if ($content === false) {
|
|
$io->error('Failed reading configuration file at ' . $filename);
|
|
} else {
|
|
$content = preg_replace(
|
|
'/^(\s*kimai_locales:\s*\[).*?(\])$/m',
|
|
'${1}' . implode(', ', $listOfLocales) . '${2}',
|
|
$content
|
|
);
|
|
|
|
file_put_contents($targetFile, $content);
|
|
|
|
$io->success('Replaced locale definitions in: ' . $filename);
|
|
}
|
|
|
|
ksort($appLocales);
|
|
|
|
$filename = 'config/locales.php';
|
|
$targetFile = $this->projectDirectory . DIRECTORY_SEPARATOR . $filename;
|
|
|
|
$content = '<?php return ' . var_export($appLocales, true) . ';';
|
|
$content = str_replace('array (', '[', $content);
|
|
$content = str_replace(')', ']', $content);
|
|
|
|
file_put_contents($targetFile, $content);
|
|
|
|
$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;
|
|
}
|
|
}
|