Split user language (UI translation) from locale (formatted values) (#4595)

This commit is contained in:
Kevin Papst
2024-01-30 00:09:53 +01:00
committed by GitHub
parent 12ef19df28
commit df3ca9d5a9
39 changed files with 685 additions and 881 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,7 @@
parameters:
locale: en
# the original list of all locales is the next line, it can be used to regenerate the locale list in case new locales will be added
#app_locales: ar|cs|da|de|el|en|eo|es|eu|fa|fi|fo|fr|he|hr|hu|it|ja|ko|nb_NO|nl|pl|pt|ro|ru|sk|sv|tr|uk|vi|zh_CN|zh_Hant
app_locales: ar|cs|da|de|el|en|eo|es|eu|fa|fi|fo|fr|he|hr|hu|it|ja|ko|nb_NO|nl|pl|pt|pt_BR|ro|ru|sk|sv|tr|uk|vi|zh_CN|zh_Hant|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_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_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_NL|en_NR|en_NU|en_NZ|en_PG|en_PH|en_PK|en_PN|en_PR|en_PW|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_EC|es_ES|es_GQ|es_GT|es_HN|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_KP|ko_KR|nl_AW|nl_BE|nl_BQ|nl_CW|nl_NL|nl_SR|nl_SX|pl_PL|pt_AO|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|vi_VN
# can be regenerated with "bin/console kimai:reset:locales"
app_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|tr|uk|vi|zh_CN|zh_Hant|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
services:
# default configuration for services in *this* file

View File

@@ -492,11 +492,6 @@ parameters:
count: 1
path: src/Command/RegenerateLocalesCommand.php
-
message: "#^Property App\\\\Command\\\\RegenerateLocalesCommand\\:\\:\\$rtlLocales type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Command/RegenerateLocalesCommand.php
-
message: "#^Cannot call method find\\(\\) on Symfony\\\\Component\\\\Console\\\\Application\\|null\\.$#"
count: 4
@@ -652,26 +647,6 @@ parameters:
count: 1
path: src/Configuration/LdapConfiguration.php
-
message: "#^Method App\\\\Configuration\\\\LocaleService\\:\\:__construct\\(\\) has parameter \\$languageSettings with no value type specified in iterable type array\\.$#"
count: 1
path: src/Configuration/LocaleService.php
-
message: "#^Method App\\\\Configuration\\\\LocaleService\\:\\:getDateFormat\\(\\) should return string but returns bool\\|string\\.$#"
count: 1
path: src/Configuration/LocaleService.php
-
message: "#^Method App\\\\Configuration\\\\LocaleService\\:\\:getTimeFormat\\(\\) should return string but returns bool\\|string\\.$#"
count: 1
path: src/Configuration/LocaleService.php
-
message: "#^Method App\\\\Configuration\\\\LocaleService\\:\\:isRightToLeft\\(\\) should return bool but returns bool\\|string\\.$#"
count: 1
path: src/Configuration/LocaleService.php
-
message: "#^Method App\\\\Configuration\\\\SamlConfiguration\\:\\:getAttributeMapping\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -1607,11 +1582,6 @@ parameters:
count: 1
path: src/Entity/User.php
-
message: "#^Method App\\\\Entity\\\\User\\:\\:getLocale\\(\\) should return string but returns bool\\|float\\|int\\|string\\|null\\.$#"
count: 1
path: src/Entity/User.php
-
message: "#^Method App\\\\Entity\\\\User\\:\\:getMemberships\\(\\) return type with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#"
count: 1
@@ -5407,11 +5377,6 @@ parameters:
count: 1
path: src/Utils/FileHelper.php
-
message: "#^Property App\\\\Utils\\\\FormFormatConverter\\:\\:\\$formatConvertRules type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Utils/FormFormatConverter.php
-
message: "#^Property App\\\\Utils\\\\JavascriptFormatConverter\\:\\:\\$formatConvertRules type has no value type specified in iterable type array\\.$#"
count: 1

View File

@@ -83,7 +83,7 @@ final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerI
$user = $this->security->getUser();
if ($user !== null) {
$locale = $user->getLocale();
$locale = $user->getLanguage();
}
if (null !== $error->getPlural()) {

View File

@@ -29,15 +29,22 @@ use Symfony\Component\Intl\Locales;
#[AsCommand(name: 'kimai:reset:locales')]
final class RegenerateLocalesCommand extends Command
{
private string $defaultDate = 'dd.MM.y';
private string $defaultTime = 'HH:mm';
private array $rtlLocales = [
'ar' => true,
'fa' => true,
'he' => true,
];
/**
* @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'];
public function __construct(private LocaleService $localeService, private string $projectDirectory, private string $kernelEnvironment)
public function __construct(
private readonly string $projectDirectory,
private readonly string $kernelEnvironment
)
{
parent::__construct();
}
@@ -55,32 +62,44 @@ final class RegenerateLocalesCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$locales = $this->localeService->getAllLocales();
// detect all registered locales and allow to choose them as well, so people get to
// choose the language for translation with the correct format of their location
/*
// 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) {
$firstLevelLocales[] = explode('.', basename($file))[1];
}
$firstLevelLocales = array_unique($firstLevelLocales);
$io->title('Locales found from translation files');
$io->writeln(implode('|', $firstLevelLocales));
$secondLevel = [];
foreach (Locales::getLocales() as $locale) {
if (substr_count($locale, '_') === 1) {
$baseLocale = substr($locale, 0, strpos($locale, '_'));
if (in_array($baseLocale, $locales)) {
$subLocale = substr($locale, strpos($locale, '_') + 1);
if (!is_numeric($subLocale)) {
$secondLevel[] = $locale;
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;
}
}
}
}
$locales = array_merge($locales, $secondLevel);
*/
sort($firstLevelLocales);
sort($secondLevel);
// keep the locales that have translation filesat the begin
// the config is than easier to read and the locales will be sorted in the UI anyway
$locales = array_merge($firstLevelLocales, $secondLevel);
$appLocales = [];
$defaults = [
'date' => $this->defaultDate,
'time' => $this->defaultTime,
'rtl' => false,
];
// make sure all allowed locales are registered
foreach ($locales as $locale) {
@@ -88,11 +107,13 @@ final class RegenerateLocalesCommand extends Command
continue;
}
$appLocales[$locale] = $defaults;
$appLocales[$locale] = LocaleService::DEFAULT_SETTINGS;
}
// 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);
@@ -115,14 +136,50 @@ final class RegenerateLocalesCommand extends Command
$rtlLocale = substr($rtlLocale, 0, strpos($rtlLocale, '_'));
}
if (\array_key_exists($rtlLocale, $this->rtlLocales)) {
$settings['rtl'] = $this->rtlLocales[$rtlLocale];
}
$settings['rtl'] = \in_array($rtlLocale, $this->rtlLocales, true);
// pre-fill all formats with the default locale settings
$appLocales[$locale] = $settings;
}
$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 will 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 ...
$io->title('List of app_locales for services.yaml');
$io->writeln(implode('|', $locales));
ksort($appLocales);
$filename = 'config/locales.php';

View File

@@ -70,7 +70,7 @@ final class UserLoginLinkCommand extends Command
}
$request = new Request();
$request->setLocale($user->getLocale());
$request->setLocale($user->getLanguage());
$this->requestStack->push($request);
$loginLinkDetails = $this->loginLink->createLoginLink($user, $request);

View File

@@ -9,9 +9,21 @@
namespace App\Configuration;
use App\Entity\User;
final class LocaleService
{
public function __construct(private array $languageSettings)
public const DEFAULT_SETTINGS = [
'date' => 'dd.MM.y',
'time' => 'HH:mm',
'rtl' => false,
'translation' => false,
];
/**
* @param array<string, array{'date': string, 'time': string, 'translation': bool}> $languageSettings
*/
public function __construct(private readonly array $languageSettings)
{
}
@@ -25,6 +37,18 @@ final class LocaleService
return array_keys($this->languageSettings);
}
/**
* Returns an array with all language codes that have translations.
*
* @return string[]
*/
public function getTranslatedLocales(): array
{
return array_keys(array_filter($this->languageSettings, function (array $setting) {
return $setting['translation'];
}));
}
public function isKnownLocale(string $language): bool
{
return \in_array($language, $this->getAllLocales());
@@ -38,7 +62,7 @@ final class LocaleService
*/
public function getDateFormat(string $locale): string
{
return $this->getConfig('date', $locale);
return (string) $this->getConfig('date', $locale);
}
/**
@@ -49,7 +73,7 @@ final class LocaleService
*/
public function getTimeFormat(string $locale): string
{
return $this->getConfig('time', $locale);
return (string) $this->getConfig('time', $locale);
}
/**
@@ -76,7 +100,34 @@ final class LocaleService
public function isRightToLeft(string $locale): bool
{
return $this->getConfig('rtl', $locale);
return (bool) $this->getConfig('rtl', $locale);
}
public function isTranslated(string $locale): bool
{
return (bool) $this->getConfig('translation', $locale);
}
public function getNearestTranslationLocale(string $locale): string
{
if (!$this->isKnownLocale($locale)) {
$parts = explode('_', $locale);
if (\count($parts) !== 2 || \strlen($parts[0]) !== 2 || !$this->isKnownLocale($parts[0])) {
return User::DEFAULT_LANGUAGE;
}
$locale = $parts[0];
}
if (!$this->isTranslated($locale)) {
$base = explode('_', $locale)[0];
if (!$this->isTranslated($base)) {
return User::DEFAULT_LANGUAGE;
}
return $base;
}
return $locale;
}
public function is24Hour(string $locale): bool

View File

@@ -22,13 +22,13 @@ use App\Form\Type\CustomerTypePatternType;
use App\Form\Type\DatePickerType;
use App\Form\Type\DateTimeTextType;
use App\Form\Type\DayTimeType;
use App\Form\Type\LanguageType;
use App\Form\Type\MinuteIncrementType;
use App\Form\Type\ProjectTypePatternType;
use App\Form\Type\RoundingModeType;
use App\Form\Type\SkinType;
use App\Form\Type\TimezoneType;
use App\Form\Type\TrackingModeType;
use App\Form\Type\UserLanguageType;
use App\Form\Type\WeekDaysType;
use App\Form\Type\YesNoType;
use App\Timesheet\LockdownService;
@@ -504,7 +504,7 @@ final class SystemConfigurationController extends AbstractController
->setOptions(['help' => 'default_value_new']),
(new Configuration('defaults.user.language'))
->setLabel('language')
->setType(LanguageType::class)
->setType(UserLanguageType::class)
->setOptions(['help' => 'default_value_new']),
(new Configuration('defaults.user.theme'))
->setLabel('skin')

View File

@@ -11,9 +11,10 @@ namespace App\Controller;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Form\Type\LanguageType;
use App\Form\Type\SkinType;
use App\Form\Type\TimezoneType;
use App\Form\Type\UserLanguageType;
use App\Form\Type\UserLocaleType;
use App\Form\UserPasswordType;
use App\User\UserService;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
@@ -44,14 +45,16 @@ final class WizardController extends AbstractController
if ($wizard === 'profile') {
$data = [
UserPreference::LOCALE => $request->getLocale(),
UserPreference::LANGUAGE => $user->getPreferenceValue(UserPreference::LANGUAGE, $request->getLocale(), false),
UserPreference::LOCALE => $user->getPreferenceValue(UserPreference::LOCALE, $request->getLocale(), false),
UserPreference::TIMEZONE => $user->getTimezone(),
UserPreference::SKIN => $user->getSkin(),
'reload' => '0',
];
$form = $this->createFormBuilder($data)
->add(UserPreference::LOCALE, LanguageType::class)
->add(UserPreference::LANGUAGE, UserLanguageType::class)
->add(UserPreference::LOCALE, UserLocaleType::class, ['help' => null])
->add(UserPreference::TIMEZONE, TimezoneType::class)
->add(UserPreference::SKIN, SkinType::class)
->add('reload', HiddenType::class)
@@ -69,7 +72,8 @@ final class WizardController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
/** @var array<string, string> $data */
$data = $form->getData();
$user->setLanguage($data[UserPreference::LOCALE]);
$user->setLanguage($data[UserPreference::LANGUAGE]);
$user->setLocale($data[UserPreference::LOCALE]);
$user->setTimezone($data[UserPreference::TIMEZONE]);
$user->setPreferenceValue(UserPreference::SKIN, $data[UserPreference::SKIN]);
$user->setWizardAsSeen('profile');

View File

@@ -9,6 +9,7 @@
namespace App\DependencyInjection;
use App\Configuration\LocaleService;
use App\Kernel;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -112,12 +113,6 @@ final class AppExtension extends Extension
$settings = include $directory . DIRECTORY_SEPARATOR . 'config/locales.php';
$appLocales = [];
$defaults = [
'date' => 'dd.MM.y',
'time' => 'HH:mm',
'rtl' => false,
];
// make sure all allowed locales are registered
foreach ($locales as $locale) {
// unlikely that a locale disappears, but in case that a new symfony update comes with changed locales
@@ -125,7 +120,7 @@ final class AppExtension extends Extension
continue;
}
$appLocales[$locale] = $defaults;
$appLocales[$locale] = LocaleService::DEFAULT_SETTINGS;
if (\array_key_exists($locale, $settings)) {
$appLocales[$locale] = array_merge($appLocales[$locale], $settings[$locale]);

View File

@@ -318,7 +318,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
}
/**
* Read-only list of of all visible user preferences.
* Read-only list of all visible user preferences.
*
* @internal only for API usage
* @return UserPreference[]
@@ -334,6 +334,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
$skip = [
UserPreference::TIMEZONE,
UserPreference::LOCALE,
UserPreference::LANGUAGE,
UserPreference::SKIN,
'calendar_initial_view',
'login_initial_view',
@@ -406,13 +407,22 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return null;
}
/**
* The locale used for formatting number, money, dates and times
*/
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('language')]
#[Serializer\SerializedName('locale')]
#[Serializer\Groups(['User_Entity'])]
#[OA\Property(type: 'string')]
public function getLocale(): string
{
return $this->getPreferenceValue(UserPreference::LOCALE, User::DEFAULT_LANGUAGE, false);
// uses language as fallback, because the language was here before
return (string) $this->getPreferenceValue(UserPreference::LOCALE, $this->getLanguage(), false);
}
public function setLocale(?string $locale): void
{
$this->setPreferenceValue(UserPreference::LOCALE, $locale ?? User::DEFAULT_LANGUAGE);
}
#[Serializer\VirtualProperty]
@@ -424,17 +434,21 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return $this->getPreferenceValue(UserPreference::TIMEZONE, date_default_timezone_get(), false);
}
/**
* The locale used for translations
*/
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('language')]
#[Serializer\Groups(['User_Entity'])]
#[OA\Property(type: 'string')]
public function getLanguage(): string
{
return $this->getLocale();
return (string) $this->getPreferenceValue(UserPreference::LANGUAGE, User::DEFAULT_LANGUAGE, false);
}
public function setLanguage(?string $language): void
{
if ($language === null) {
$language = User::DEFAULT_LANGUAGE;
}
$this->setPreferenceValue(UserPreference::LOCALE, $language);
$this->setPreferenceValue(UserPreference::LANGUAGE, $language ?? User::DEFAULT_LANGUAGE);
}
public function isFirstDayOfWeekSunday(): bool

View File

@@ -28,7 +28,8 @@ class UserPreference
public const HOURLY_RATE = 'hourly_rate';
public const INTERNAL_RATE = 'internal_rate';
public const SKIN = 'skin';
public const LOCALE = 'language';
public const LANGUAGE = 'language';
public const LOCALE = 'locale';
public const TIMEZONE = 'timezone';
public const FIRST_WEEKDAY = 'first_weekday';
public const WORK_HOURS_MONDAY = 'work_monday';

View File

@@ -24,7 +24,11 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInt
*/
final class ThemeOptionsSubscriber implements EventSubscriberInterface
{
public function __construct(private TokenStorageInterface $storage, private ContextHelper $helper, private LocaleService $localeService)
public function __construct(
private readonly TokenStorageInterface $storage,
private readonly ContextHelper $helper,
private readonly LocaleService $localeService
)
{
}
@@ -44,7 +48,7 @@ final class ThemeOptionsSubscriber implements EventSubscriberInterface
$this->helper->setAssetVersion((string) Constants::VERSION_ID);
if ($this->localeService->isRightToLeft(\Locale::getDefault())) {
if ($this->localeService->isRightToLeft($event->getRequest()->getLocale())) {
$this->helper->setIsRightToLeft(true);
}

View File

@@ -10,6 +10,7 @@
namespace App\EventSubscriber;
use App\Entity\User;
use App\Twig\LocaleFormatExtensions;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
@@ -18,7 +19,11 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class UserEnvironmentSubscriber implements EventSubscriberInterface
{
public function __construct(private TokenStorageInterface $tokenStorage, private AuthorizationCheckerInterface $auth)
public function __construct(
private readonly TokenStorageInterface $tokenStorage,
private readonly AuthorizationCheckerInterface $auth,
private readonly LocaleFormatExtensions $localeFormatExtensions
)
{
}
@@ -36,19 +41,21 @@ final class UserEnvironmentSubscriber implements EventSubscriberInterface
return;
}
// the locale depends on the request, not on the user configuration
\Locale::setDefault($event->getRequest()->getLocale());
$locale = $event->getRequest()->getLocale();
// ignore events like the toolbar where we do not have a token
if (null === ($token = $this->tokenStorage->getToken())) {
return;
// events like the toolbar might not have a token
if (null !== ($token = $this->tokenStorage->getToken())) {
$user = $token->getUser();
if ($user instanceof User) {
$locale = $user->getLocale();
date_default_timezone_set($user->getTimezone());
$user->initCanSeeAllData($this->auth->isGranted('view_all_data'));
}
}
$user = $token->getUser();
if ($user instanceof User) {
date_default_timezone_set($user->getTimezone());
$user->initCanSeeAllData($this->auth->isGranted('view_all_data'));
}
// the locale is primarily used for formatting values, so we depend on the user locale if available
\Locale::setDefault($locale);
$this->localeFormatExtensions->setLocale($locale);
}
}

View File

@@ -22,6 +22,7 @@ use App\Form\Type\InitialViewType;
use App\Form\Type\SkinType;
use App\Form\Type\TimezoneType;
use App\Form\Type\UserLanguageType;
use App\Form\Type\UserLocaleType;
use App\Form\Type\YesNoType;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
@@ -84,11 +85,16 @@ final class UserPreferenceSubscriber implements EventSubscriberInterface
->setSection('locale')
->setType(TimezoneType::class),
(new UserPreference(UserPreference::LOCALE, $this->systemConfiguration->getUserDefaultLanguage()))
(new UserPreference(UserPreference::LANGUAGE, $this->systemConfiguration->getUserDefaultLanguage()))
->setOrder(250)
->setSection('locale')
->setType(UserLanguageType::class),
(new UserPreference(UserPreference::LOCALE, $this->systemConfiguration->getUserDefaultLanguage()))
->setOrder(250)
->setSection('locale')
->setType(UserLocaleType::class),
(new UserPreference(UserPreference::FIRST_WEEKDAY, User::DEFAULT_FIRST_WEEKDAY))
->setOrder(300)
->setSection('locale')

View File

@@ -12,10 +12,9 @@ namespace App\Form\Helper;
use App\Configuration\LocaleService;
use App\Configuration\SystemConfiguration;
use App\Entity\Project;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final class ProjectHelper implements LocaleAwareInterface
final class ProjectHelper
{
public const PATTERN_NAME = '{name}';
public const PATTERN_COMMENT = '{comment}';
@@ -33,7 +32,11 @@ final class ProjectHelper implements LocaleAwareInterface
private bool $showEnd = false;
private ?string $locale = null;
public function __construct(private SystemConfiguration $configuration, private LocaleService $localeService, private TranslatorInterface $translator)
public function __construct(
private readonly SystemConfiguration $configuration,
private readonly LocaleService $localeService,
private readonly TranslatorInterface $translator
)
{
}
@@ -42,7 +45,7 @@ final class ProjectHelper implements LocaleAwareInterface
return $this->locale ?? \Locale::getDefault();
}
public function setLocale(string $locale): void
public function setLocale(?string $locale): void
{
$this->locale = $locale;
}

View File

@@ -13,6 +13,7 @@ use App\Configuration\LocaleService;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Intl\Locales;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -20,21 +21,32 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
final class LanguageType extends AbstractType
{
public function __construct(private LocaleService $localeService)
public function __construct(private readonly LocaleService $localeService)
{
}
public function configureOptions(OptionsResolver $resolver): void
{
$choices = [];
foreach ($this->localeService->getAllLocales() as $key) {
$name = ucfirst(Locales::getName($key, $key));
$choices[$name] = $key;
}
$resolver->setDefault('choices', function (Options $options) {
$choices = [];
if ($options['translated_only'] === true) {
$locales = $this->localeService->getTranslatedLocales();
} else {
$locales = $this->localeService->getAllLocales();
}
foreach ($locales as $key) {
$name = ucfirst(Locales::getName($key, $key));
$choices[$name] = $key;
}
return $choices;
});
$resolver->setDefaults([
'choices' => $choices,
'label' => 'language',
'translated_only' => false,
'choice_translation_domain' => false,
]);
}

View File

@@ -16,6 +16,8 @@ use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class TimePickerType extends AbstractType
@@ -40,6 +42,11 @@ final class TimePickerType extends AbstractType
]);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['format'] = $options['format'];
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer(

View File

@@ -9,30 +9,43 @@
namespace App\Form\Type;
use App\Configuration\LocaleService;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Custom form field type to select the user language.
* Custom form field type to select the user language, which is used to translate the UI.
* @extends AbstractType<string>
*/
final class UserLanguageType extends AbstractType
{
public function __construct(private UrlGeneratorInterface $router, private TranslatorInterface $translator)
public function __construct(private readonly LocaleService $localeService)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer(new CallbackTransformer(
function ($value) {
if ($value === null) {
return null;
}
return $this->localeService->getNearestTranslationLocale($value);
},
function ($value) {
return $value;
}
));
}
public function configureOptions(OptionsResolver $resolver): void
{
$route = $this->router->generate('help_locales');
$message = $this->translator->trans('user.language.help');
$moreLink = $this->translator->trans('help_locales');
$resolver->setDefaults([
'help_html' => true,
'help' => sprintf('%2$s <a href="%1$s" target="help_locales">%3$s</a>', $route, $message, $moreLink)
'label' => 'language',
'translated_only' => true,
]);
}

View File

@@ -0,0 +1,47 @@
<?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\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Custom form field type to select the user locale, which is used to format date/time/money/number values.
*
* @extends AbstractType<string>
*/
final class UserLocaleType extends AbstractType
{
public function __construct(
private readonly UrlGeneratorInterface $router,
private readonly TranslatorInterface $translator
)
{
}
public function configureOptions(OptionsResolver $resolver): void
{
$route = $this->router->generate('help_locales');
$moreLink = $this->translator->trans('help_locales');
$resolver->setDefaults([
'label' => 'locale',
'help_html' => true,
'help' => sprintf('<a href="%1$s" target="help_locales">%2$s</a>', $route, $moreLink)
]);
}
public function getParent(): string
{
return LanguageType::class;
}
}

View File

@@ -15,6 +15,7 @@ use App\Form\Type\AvatarType;
use App\Form\Type\MailType;
use App\Form\Type\TimezoneType;
use App\Form\Type\UserLanguageType;
use App\Form\Type\UserLocaleType;
use App\Form\Type\UserType;
use App\Form\Type\YesNoType;
use Symfony\Component\Form\AbstractType;
@@ -80,6 +81,10 @@ class UserEditType extends AbstractType
'required' => true,
]);
$builder->add('locale', UserLocaleType::class, [
'required' => true,
]);
$builder->add('timezone', TimezoneType::class, [
'required' => true,
]);

View File

@@ -24,11 +24,11 @@ final class FormFormatConverter
* This defines the mapping between ICU date format and PHP Date format.
*
* @see https://www.php.net/manual/en/datetime.format.php
* @var array
* @var array<string, string>
*/
private static array $formatConvertRules = [
// Litepicker interprets a year like 22 as 1922 instead of 2022
// so we have to make sure that it is always a4-digit year
// so we have to make sure that it is always a 4-digit year
"'h'" => "\h", // special format for fr_CA which includes 'h' as character
'yy' => 'yyyy',
'y' => 'yyyy',
@@ -47,10 +47,6 @@ final class FormFormatConverter
/**
* This works with ICU and DateTime format.
*
* @param string $format
* @param bool $html
* @return string
*/
public function convertToPattern(string $format, bool $html = true): string
{
@@ -83,8 +79,8 @@ final class FormFormatConverter
$pattern = str_replace('g', self::PATTERN_HOUR_SINGLE, $pattern);
$pattern = str_replace('i', self::PATTERN_MINUTES, $pattern);
$pattern = str_replace('mm', self::PATTERN_MINUTES, $pattern);
$pattern = str_replace('A', '(AM|PM){1}', $pattern);
$pattern = str_replace('a', '(AM|PM){1}', $pattern);
$pattern = str_replace('A', '(AM|PM|am|pm){1}', $pattern);
$pattern = str_replace('a', '(AM|PM|am|pm){1}', $pattern);
$pattern = str_replace('*****', 'h', $pattern);
if (!$html) {

View File

@@ -139,6 +139,7 @@
{%- endblock date_widget %}
{% block time_widget -%}
{%- set user_format = format -%}
{%- set format = locale_format('time') -%}
{%- set jsFormat = format|js_format -%}
{%- set attr = attr|merge({'pattern': format|pattern, 'autocomplete': 'off', 'data-timepicker': 'on', 'data-format': jsFormat, 'placeholder': jsFormat}) -%}
@@ -155,7 +156,7 @@
{% for index in [0, 1, 2, 3] %}
<div class="dropdown-menu-column" style="min-width: 4rem">
{% for value in time_presets %}
{% set value = value|time %}
{% set value = value|date_format(user_format) %}
{% if loop.index0 % 4 == index %}
<a class="dropdown-item justify-content-center" href="#" data-form-widget="copy-data" data-target="#{{ form.vars.id }}" data-value="{{ value }}" data-event="change">{{ value }}</a>
{% endif %}

View File

@@ -364,6 +364,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
'roles' => ['result' => 'array', 'type' => 'string'],
'initials' => 'string',
'language' => 'string',
'locale' => 'string',
'timezone' => 'string',
'accountNumber' => '@string',
'memberships' => ['result' => 'array', 'type' => 'TeamMembership'],

View File

@@ -17,65 +17,102 @@ use PHPUnit\Framework\TestCase;
*/
class LocaleServiceTest extends TestCase
{
protected function getSut(array $settings)
protected function getSut(array $settings): LocaleService
{
return new LocaleService($settings);
}
protected function getDefaultSettings()
/**
* @return array<string, array{'date': string, 'time': string, 'rtl': bool, 'translation': bool}>
*/
protected function getDefaultSettings(): array
{
return [
'de' => [
'date' => 'd.m.Y',
'duration' => '%h:%m',
'time' => 'H:i',
'rtl' => false,
'translation' => true,
],
'en' => [
'date' => 'Y-m-d',
'duration' => '%h:%m',
'time' => 'H:i:s',
'rtl' => false,
'translation' => true,
],
'en_AU' => [
'date' => 'Y-m-d',
'time' => 'H:i:s',
'rtl' => false,
'translation' => false,
],
'pt_BR' => [
'date' => 'd-m-Y',
'duration' => '%h:%m',
'time' => 'HH:mm',
'rtl' => false,
'translation' => true,
],
'it' => [
'date' => 'd.m.Y',
'duration' => '%h:%m',
'time' => 'HH:mm',
'rtl' => false,
'translation' => true,
],
'fr' => [
'date' => 'd/m/Y',
'duration' => '%h h %m',
'time' => 'HH:mm',
'rtl' => false,
'translation' => true,
],
'fr_BE' => [
'date' => 'd/MM/yy',
'time' => 'HH:mm',
'rtl' => false,
'translation' => false,
],
'fr_CA' => [
'date' => 'y-MM-dd',
'time' => 'HH \'h\' mm',
'rtl' => false,
'translation' => true,
],
'es' => [
'date' => 'd.m.Y',
'duration' => '%h:%m',
'time' => 'HH:mm',
'rtl' => false,
'translation' => true,
],
'ru' => [
'date' => 'd.m.Y',
'duration' => '%h:%m',
'time' => 'HH:mm',
'rtl' => false,
'translation' => true,
],
'ar' => [
'date' => 'Y-m-d',
'duration' => '%h:%m',
'time' => 'HH:mm',
'rtl' => true,
'translation' => true,
],
'hu' => [
'date' => 'Y.m.d.',
'duration' => '%h:%m',
'time' => 'HH:mm',
'rtl' => false,
'translation' => true,
],
];
}
public function testGetAllLocales()
public function testGetAllLocales(): void
{
$sut = $this->getSut([]);
$this->assertEquals([], $sut->getAllLocales());
$sut = $this->getSut($this->getDefaultSettings());
$this->assertEquals(['de', 'en', 'pt_BR', 'it', 'fr', 'es', 'ru', 'ar', 'hu'], $sut->getAllLocales());
$this->assertEquals(['de', 'en', 'en_AU', 'pt_BR', 'it', 'fr', 'fr_BE', 'fr_CA', 'es', 'ru', 'ar', 'hu'], $sut->getAllLocales());
}
public function testInvalidLocaleWithGivenLocale()
public function testInvalidLocaleWithGivenLocale(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Unknown locale given: xx');
@@ -84,28 +121,54 @@ class LocaleServiceTest extends TestCase
$sut->getDateFormat('xx');
}
public function testGetDurationFormat()
public function testGetDurationFormat(): void
{
$sut = $this->getSut($this->getDefaultSettings());
$this->assertEquals('%h:%m', $sut->getDurationFormat('de'));
}
public function testGetDateFormat()
public function testGetDateFormat(): void
{
$sut = $this->getSut($this->getDefaultSettings());
$this->assertEquals('d.m.Y', $sut->getDateFormat('de'));
}
public function testGetDateTimeFormat()
public function testGetDateTimeFormat(): void
{
$sut = $this->getSut($this->getDefaultSettings());
$this->assertEquals('d.m.Y H:i', $sut->getDateTimeFormat('de'));
}
public function testGetTimeFormat()
public function testGetTimeFormat(): void
{
$sut = $this->getSut($this->getDefaultSettings());
$this->assertEquals('H:i', $sut->getTimeFormat('de'));
$this->assertEquals('H:i:s', $sut->getTimeFormat('en'));
}
/**
* @dataProvider getNearestTranslationLocaleData
*/
public function testGetNearestTranslationLocale(string $locale, string $expected): void
{
$sut = $this->getSut($this->getDefaultSettings());
$actual = $sut->getNearestTranslationLocale($locale);
$this->assertEquals($expected, $actual);
}
/**
* @return array<int, array<int, string>>
*/
public function getNearestTranslationLocaleData(): array
{
return [
['de', 'de'], // registered and translated: use it
['pt_BR', 'pt_BR'], // registered and translated: use it
['fr_CA', 'fr_CA'], // registered and translated: use it
['fr_BE', 'fr'], // not translated, fallback to base locale
['fr_DZ', 'fr'], // not registered region locale, but fallback to base locale
['en_AU', 'en'], // not translated, fallback to base locale
['uk_UA', 'en'], // not registered locales fallback to en
];
}
}

View File

@@ -422,7 +422,8 @@ class ProfileControllerTest extends ControllerBaseTest
$data = [
UserPreference::TIMEZONE => ['value' => 'America/Creston'],
UserPreference::LOCALE => ['value' => 'ar'],
UserPreference::LANGUAGE => ['value' => 'ar'],
UserPreference::LOCALE => ['value' => 'ru'],
UserPreference::FIRST_WEEKDAY => ['value' => 'sunday'],
UserPreference::SKIN => ['value' => 'dark'],
];
@@ -453,9 +454,10 @@ class ProfileControllerTest extends ControllerBaseTest
$this->assertEquals($expectedInternalRate, $user->getPreferenceValue(UserPreference::INTERNAL_RATE));
$this->assertEquals('America/Creston', $user->getPreferenceValue(UserPreference::TIMEZONE));
$this->assertEquals('America/Creston', $user->getTimezone());
$this->assertEquals('ar', $user->getPreferenceValue(UserPreference::LOCALE));
$this->assertEquals('ar', $user->getPreferenceValue(UserPreference::LANGUAGE));
$this->assertEquals('ru', $user->getPreferenceValue(UserPreference::LOCALE));
$this->assertEquals('ru', $user->getLocale());
$this->assertEquals('ar', $user->getLanguage());
$this->assertEquals('ar', $user->getLocale());
$this->assertEquals('dark', $user->getPreferenceValue(UserPreference::SKIN));
$this->assertEquals('sunday', $user->getPreferenceValue(UserPreference::FIRST_WEEKDAY));
$this->assertEquals('sunday', $user->getFirstDayOfWeek());

View File

@@ -71,26 +71,31 @@ class AppExtensionTest extends TestCase
'date' => 'M/d/yy',
'time' => 'h:mm a',
'rtl' => false,
'translation' => true,
],
'de' => [
'date' => 'dd.MM.yy',
'time' => 'HH:mm',
'rtl' => false,
'translation' => true,
],
'he' => [
'date' => 'd.M.y',
'time' => 'H:mm',
'rtl' => true,
'translation' => true,
],
'tr' => [
'date' => 'd.MM.y',
'time' => 'HH:mm',
'rtl' => false,
'translation' => true,
],
'zh_CN' => [
'date' => 'y/M/d',
'time' => 'HH:mm',
'rtl' => false,
'translation' => true,
],
],
'kimai.invoice.documents' => [

View File

@@ -43,6 +43,7 @@ class UserTest extends TestCase
self::assertNull($user->getPasswordRequestedAt());
self::assertFalse($user->hasTotpSecret());
self::assertNull($user->getTotpSecret());
self::assertEquals(User::DEFAULT_LANGUAGE, $user->getLanguage());
self::assertEquals(User::DEFAULT_LANGUAGE, $user->getLocale());
self::assertFalse($user->hasTeamAssignment());
self::assertFalse($user->canSeeAllData());
@@ -288,13 +289,20 @@ class UserTest extends TestCase
public function testGetLocale(): void
{
$sut = new User();
self::assertEquals(User::DEFAULT_LANGUAGE, $sut->getLocale());
$user = new User();
self::assertEquals(User::DEFAULT_LANGUAGE, $user->getLocale());
$language = new UserPreference(UserPreference::LOCALE, 'fr');
$sut->addPreference($language);
self::assertEquals('fr', $sut->getLocale());
self::assertEquals('en', $user->getLanguage());
self::assertEquals('en', $user->getLocale());
$user->setLanguage('it');
self::assertEquals('it', $user->getLanguage());
self::assertEquals('it', $user->getLocale());
$user->setLocale('de');
self::assertEquals('it', $user->getLanguage());
self::assertEquals('de', $user->getLocale());
$user->setPreferenceValue(UserPreference::LOCALE, 'hu');
self::assertEquals('it', $user->getLanguage());
self::assertEquals('hu', $user->getLocale());
}
public function testTeams(): void

View File

@@ -22,10 +22,10 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
*/
class RedirectToLocaleSubscriberTest extends TestCase
{
public function testConstruct()
public function testConstruct(): void
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$sut = new RedirectToLocaleSubscriber($urlGenerator, new LocaleService(['de', 'en']));
$sut = new RedirectToLocaleSubscriber($urlGenerator, new LocaleService(['de' => LocaleService::DEFAULT_SETTINGS, 'en' => LocaleService::DEFAULT_SETTINGS]));
self::assertEquals([KernelEvents::REQUEST => ['onKernelRequest']], RedirectToLocaleSubscriber::getSubscribedEvents());

View File

@@ -28,6 +28,7 @@ class UserPreferenceSubscriberTest extends TestCase
'internal_rate',
'timezone',
'language',
'locale',
'first_weekday',
'skin',
'update_browser_title',

View File

@@ -45,11 +45,7 @@ abstract class AbstractRendererTest extends KernelTestCase
protected function getAbstractRenderer(string $classname)
{
$languages = [
'en' => [
'date' => 'Y.m.d',
'duration' => '%h:%m',
'time' => 'H:i',
]
'en' => LocaleService::DEFAULT_SETTINGS
];
$security = $this->createMock(Security::class);

View File

@@ -44,11 +44,7 @@ abstract class AbstractRendererTest extends KernelTestCase
protected function getAbstractRenderer(string $classname)
{
$languages = [
'en' => [
'date' => 'Y.m.d',
'duration' => '%h:%m',
'time' => 'H:i',
]
'en' => LocaleService::DEFAULT_SETTINGS
];
$user = new User();

View File

@@ -26,9 +26,10 @@ class ProjectHelperTest extends TestCase
{
$config = SystemConfigurationFactory::createStub(['project.choice_pattern' => $format]);
$localeService = new LocaleService(['en_US' => ['date' => 'dd.MM.y']]);
$localeService = new LocaleService(['en_US' => LocaleService::DEFAULT_SETTINGS]);
$translator = $this->createMock(TranslatorInterface::class);
$translator->method('trans')->willReturn('dating');
$helper = new ProjectHelper($config, $localeService, $translator);
$helper->setLocale('en_US');

View File

@@ -66,6 +66,7 @@ trait RendererTestTrait
'date' => 'yy.MM.dd',
'time' => 'H:i',
'rtl' => false,
'translation' => true,
]
];

View File

@@ -38,11 +38,7 @@ class ServiceInvoiceTest extends TestCase
private function getSut(array $paths): ServiceInvoice
{
$languages = [
'en' => [
'date' => 'Y.m.d',
'duration' => '%h:%m',
'time' => 'H:i',
]
'en' => LocaleService::DEFAULT_SETTINGS
];
$formattings = new LocaleService($languages);

View File

@@ -48,27 +48,18 @@ class LocaleFormatExtensionsTest extends TestCase
}
/**
* @param string|array $locale
* @param array|string $dateSettings
* @return LocaleFormatExtensions
* @param array<string, array{'date': string, 'time': string, 'rtl': bool, 'translation': bool}> $languageSettings
*/
protected function getSut($locale, $dateSettings, ?User $user = null)
private function getSut(string $locale, array $languageSettings): LocaleFormatExtensions
{
$language = $locale;
if (\is_array($locale)) {
$language = $dateSettings;
$dateSettings = $locale;
}
$user = new User();
$user->setTimezone('Europe/Vienna');
if ($user === null) {
$user = new User();
$user->setTimezone('Europe/Vienna');
}
$security = $this->createMock(Security::class);
$security->expects($this->any())->method('getUser')->willReturn($user);
$sut = new LocaleFormatExtensions(new LocaleService($dateSettings), $security);
$sut->setLocale($language);
$sut = new LocaleFormatExtensions(new LocaleService($languageSettings), $security);
$sut->setLocale($locale);
return $sut;
}
@@ -143,9 +134,9 @@ class LocaleFormatExtensionsTest extends TestCase
public function testDateShort(string $locale, \DateTime|string|null $date, string $expected): void
{
$sut = $this->getSut($locale, [
'de' => ['date' => 'dd.MM.Y'],
'en' => ['date' => 'Y-MM-dd'],
'ru' => ['date' => 'dd.MM.Y'],
'de' => array_merge(LocaleService::DEFAULT_SETTINGS, ['date' => 'dd.MM.Y']),
'en' => array_merge(LocaleService::DEFAULT_SETTINGS, ['date' => 'Y-MM-dd']),
'ru' => array_merge(LocaleService::DEFAULT_SETTINGS, ['date' => 'dd.MM.Y']),
]);
$this->assertEquals($expected, $sut->dateShort($date));
}
@@ -175,8 +166,8 @@ class LocaleFormatExtensionsTest extends TestCase
public function testDateTime(string $locale, \DateTime|string|null $date, string $expected): void
{
$sut = $this->getSut($locale, [
'de' => ['date' => 'dd.MM.Y', 'time' => 'HH:mm:s'],
'en' => ['date' => 'Y-MM-dd', 'time' => 'h:mm a'],
'de' => array_merge(LocaleService::DEFAULT_SETTINGS, ['date' => 'dd.MM.Y', 'time' => 'HH:mm:s']),
'en' => array_merge(LocaleService::DEFAULT_SETTINGS, ['date' => 'Y-MM-dd', 'time' => 'h:mm a']),
]);
$this->assertEquals($expected, $sut->dateTime($date));
}
@@ -265,7 +256,7 @@ class LocaleFormatExtensionsTest extends TestCase
$time = new \DateTime('2016-06-23');
$time->setTime(17, 53, 23);
$sut = $this->getSut('en', ['en' => ['time' => 'HH:mm']]);
$sut = $this->getSut('en', ['en' => array_merge(LocaleService::DEFAULT_SETTINGS, ['time' => 'HH:mm'])]);
$this->assertEquals('17:53', $sut->time($time));
$this->assertEquals('17:53', $sut->time('2016-06-23 17:53'));
}
@@ -289,10 +280,10 @@ class LocaleFormatExtensionsTest extends TestCase
public function testMoneyWithoutCurrency(): void
{
$sut = $this->getSut($this->localeEn, 'en');
$sut = $this->getSut('en', $this->localeEn);
$this->assertEquals('123.75', $sut->money(123.75));
$sut = $this->getSut($this->localeEn, 'de');
$sut = $this->getSut('de', $this->localeEn);
$this->assertEquals('123.234,76', $sut->money(123234.7554, null, true));
$this->assertEquals('123.234,76', $sut->money(123234.7554, null, false));
$this->assertEquals('123.234,76', $sut->money(123234.7554, 'EUR', false));
@@ -303,7 +294,7 @@ class LocaleFormatExtensionsTest extends TestCase
*/
public function testMoneyNoCurrency($result, $amount, $currency, $locale): void
{
$sut = $this->getSut($this->localeEn, $locale);
$sut = $this->getSut($locale, $this->localeEn);
$this->assertEquals($result, $sut->money($amount, $currency, false));
}
@@ -338,7 +329,7 @@ class LocaleFormatExtensionsTest extends TestCase
*/
public function testMoney(string $result, float|int|null $amount, string $currency, string $locale): void
{
$sut = $this->getSut($this->localeEn, $locale);
$sut = $this->getSut($locale, $this->localeEn);
$this->assertEquals($result, $sut->money($amount, $currency));
}
@@ -370,9 +361,9 @@ class LocaleFormatExtensionsTest extends TestCase
/**
* @dataProvider getAmountData
*/
public function testAmount($result, $amount, $locale): void
public function testAmount(string $result, null|int|float|string $amount, string $locale): void
{
$sut = $this->getSut($this->localeEn, $locale);
$sut = $this->getSut($locale, $this->localeEn);
$this->assertEquals($result, $sut->amount($amount));
}
@@ -391,6 +382,7 @@ class LocaleFormatExtensionsTest extends TestCase
['2.345,009', 2345.009, 'de'],
['13.75', 13.75, 'en'],
['13,75', 13.75, 'de'],
['13,75', '13.75', 'de'],
["13\u{a0}933,49", 13933.49, 'ru'],
['1.234.567,891', 1234567.891234567890000, 'de'],
];
@@ -399,11 +391,11 @@ class LocaleFormatExtensionsTest extends TestCase
/**
* @dataProvider getMoneyData62_1
*/
public function testMoney62_1($result, $amount, $currency, $locale): void
public function testMoney62_1(string $result, null|int|float $amount, string $currency, string $locale): void
{
IntlTestHelper::requireFullIntl($this, '62.1');
$sut = $this->getSut($this->localeEn, $locale);
$sut = $this->getSut($locale, $this->localeEn);
$this->assertEquals($result, $sut->money($amount, $currency));
}
@@ -432,11 +424,11 @@ class LocaleFormatExtensionsTest extends TestCase
$this->assertEquals('2.62', $sut->duration($record, true));
// test extended format
$sut = $this->getSut($this->localeFake, 'XX');
$sut = $this->getSut('XX', $this->localeFake);
$this->assertEquals('2:37', $sut->duration($record->getDuration()));
// test negative duration
$sut = $this->getSut($this->localeEn, 'en');
$sut = $this->getSut('en', $this->localeEn);
$this->assertEquals('0:00', $sut->duration(0));
$this->assertEquals('0:00', $sut->duration(-1));
$this->assertEquals('0:00', $sut->duration(-59));
@@ -444,10 +436,10 @@ class LocaleFormatExtensionsTest extends TestCase
$this->assertEquals('-1:36', $sut->duration(-5786));
// test zero duration
$sut = $this->getSut($this->localeEn, 'en');
$sut = $this->getSut('en', $this->localeEn);
$this->assertEquals('0:00', $sut->duration(0));
$sut = $this->getSut($this->localeEn, 'en');
$sut = $this->getSut('en', $this->localeEn);
$this->assertEquals('0:00', $sut->duration(null));
$this->assertEquals('0.00', $sut->duration(null, true));
@@ -499,31 +491,31 @@ class LocaleFormatExtensionsTest extends TestCase
$this->assertEquals('2.62', $sut->durationDecimal($record));
// test extended format
$sut = $this->getSut($this->localeDe, 'de');
$sut = $this->getSut('de', $this->localeDe);
$this->assertEquals('2,62', $sut->durationDecimal($record->getDuration()));
// test negative duration
$sut = $this->getSut($this->localeEn, 'en');
$sut = $this->getSut('en', $this->localeEn);
$this->assertEquals('-0.00', $sut->durationDecimal(-1));
// test negative duration
$sut = $this->getSut($this->localeEn, 'en');
$sut = $this->getSut('en', $this->localeEn);
$this->assertEquals('-0.01', $sut->durationDecimal(-40));
$this->assertEquals('-0.01', $sut->durationDecimal(-50));
// test negative duration - with rounding issue
$sut = $this->getSut($this->localeEn, 'en');
$sut = $this->getSut('en', $this->localeEn);
$this->assertEquals('-0.02', $sut->durationDecimal(-60));
// test zero duration
$sut = $this->getSut($this->localeEn, 'en');
$sut = $this->getSut('en', $this->localeEn);
$this->assertEquals('0.00', $sut->durationDecimal(-0));
$this->assertEquals('0.00', $sut->durationDecimal(0));
$sut = $this->getSut($this->localeEn, 'en');
$sut = $this->getSut('en', $this->localeEn);
$this->assertEquals('0.00', $sut->durationDecimal(-0));
$sut = $this->getSut($this->localeEn, 'en');
$sut = $this->getSut('en', $this->localeEn);
$this->assertEquals('0.00', $sut->durationDecimal(null));
}

View File

@@ -1567,51 +1567,11 @@ parameters:
count: 1
path: Configuration/LdapConfigurationTest.php
-
message: "#^Method App\\\\Tests\\\\Configuration\\\\LocaleServiceTest\\:\\:getDefaultSettings\\(\\) has no return type specified\\.$#"
count: 1
path: Configuration/LocaleServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Configuration\\\\LocaleServiceTest\\:\\:getSut\\(\\) has no return type specified\\.$#"
count: 1
path: Configuration/LocaleServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Configuration\\\\LocaleServiceTest\\:\\:getSut\\(\\) has parameter \\$settings with no value type specified in iterable type array\\.$#"
count: 1
path: Configuration/LocaleServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Configuration\\\\LocaleServiceTest\\:\\:testGetAllLocales\\(\\) has no return type specified\\.$#"
count: 1
path: Configuration/LocaleServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Configuration\\\\LocaleServiceTest\\:\\:testGetDateFormat\\(\\) has no return type specified\\.$#"
count: 1
path: Configuration/LocaleServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Configuration\\\\LocaleServiceTest\\:\\:testGetDateTimeFormat\\(\\) has no return type specified\\.$#"
count: 1
path: Configuration/LocaleServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Configuration\\\\LocaleServiceTest\\:\\:testGetDurationFormat\\(\\) has no return type specified\\.$#"
count: 1
path: Configuration/LocaleServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Configuration\\\\LocaleServiceTest\\:\\:testGetTimeFormat\\(\\) has no return type specified\\.$#"
count: 1
path: Configuration/LocaleServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Configuration\\\\LocaleServiceTest\\:\\:testInvalidLocaleWithGivenLocale\\(\\) has no return type specified\\.$#"
count: 1
path: Configuration/LocaleServiceTest.php
-
message: "#^Method App\\\\Tests\\\\Configuration\\\\MailConfigurationTest\\:\\:testGetFromAddress\\(\\) has no return type specified\\.$#"
count: 1
@@ -4527,11 +4487,6 @@ parameters:
count: 1
path: EventSubscriber/ProfileSubscriberTest.php
-
message: "#^Method App\\\\Tests\\\\EventSubscriber\\\\RedirectToLocaleSubscriberTest\\:\\:testConstruct\\(\\) has no return type specified\\.$#"
count: 1
path: EventSubscriber/RedirectToLocaleSubscriberTest.php
-
message: "#^Method App\\\\Tests\\\\EventSubscriber\\\\UserDetailsSubscriberTest\\:\\:testGetSubscribedEvents\\(\\) has no return type specified\\.$#"
count: 1
@@ -8157,56 +8112,11 @@ parameters:
count: 1
path: Twig/LocaleFormatExtensionsTest.php
-
message: "#^Method App\\\\Tests\\\\Twig\\\\LocaleFormatExtensionsTest\\:\\:getSut\\(\\) has parameter \\$dateSettings with no value type specified in iterable type array\\.$#"
count: 1
path: Twig/LocaleFormatExtensionsTest.php
-
message: "#^Method App\\\\Tests\\\\Twig\\\\LocaleFormatExtensionsTest\\:\\:getSut\\(\\) has parameter \\$locale with no value type specified in iterable type array\\.$#"
count: 1
path: Twig/LocaleFormatExtensionsTest.php
-
message: "#^Method App\\\\Tests\\\\Twig\\\\LocaleFormatExtensionsTest\\:\\:getTimesheet\\(\\) has parameter \\$seconds with no type specified\\.$#"
count: 1
path: Twig/LocaleFormatExtensionsTest.php
-
message: "#^Method App\\\\Tests\\\\Twig\\\\LocaleFormatExtensionsTest\\:\\:testAmount\\(\\) has parameter \\$amount with no type specified\\.$#"
count: 1
path: Twig/LocaleFormatExtensionsTest.php
-
message: "#^Method App\\\\Tests\\\\Twig\\\\LocaleFormatExtensionsTest\\:\\:testAmount\\(\\) has parameter \\$locale with no type specified\\.$#"
count: 1
path: Twig/LocaleFormatExtensionsTest.php
-
message: "#^Method App\\\\Tests\\\\Twig\\\\LocaleFormatExtensionsTest\\:\\:testAmount\\(\\) has parameter \\$result with no type specified\\.$#"
count: 1
path: Twig/LocaleFormatExtensionsTest.php
-
message: "#^Method App\\\\Tests\\\\Twig\\\\LocaleFormatExtensionsTest\\:\\:testMoney62_1\\(\\) has parameter \\$amount with no type specified\\.$#"
count: 1
path: Twig/LocaleFormatExtensionsTest.php
-
message: "#^Method App\\\\Tests\\\\Twig\\\\LocaleFormatExtensionsTest\\:\\:testMoney62_1\\(\\) has parameter \\$currency with no type specified\\.$#"
count: 1
path: Twig/LocaleFormatExtensionsTest.php
-
message: "#^Method App\\\\Tests\\\\Twig\\\\LocaleFormatExtensionsTest\\:\\:testMoney62_1\\(\\) has parameter \\$locale with no type specified\\.$#"
count: 1
path: Twig/LocaleFormatExtensionsTest.php
-
message: "#^Method App\\\\Tests\\\\Twig\\\\LocaleFormatExtensionsTest\\:\\:testMoney62_1\\(\\) has parameter \\$result with no type specified\\.$#"
count: 1
path: Twig/LocaleFormatExtensionsTest.php
-
message: "#^Method App\\\\Tests\\\\Twig\\\\LocaleFormatExtensionsTest\\:\\:testMoneyNoCurrency\\(\\) has parameter \\$amount with no type specified\\.$#"
count: 1
@@ -8232,16 +8142,6 @@ parameters:
count: 5
path: Twig/LocaleFormatExtensionsTest.php
-
message: "#^Parameter \\#1 \\$languageSettings of class App\\\\Configuration\\\\LocaleService constructor expects array, array\\|string given\\.$#"
count: 1
path: Twig/LocaleFormatExtensionsTest.php
-
message: "#^Parameter \\#1 \\$locale of method App\\\\Twig\\\\LocaleFormatExtensions\\:\\:setLocale\\(\\) expects string, array\\|string given\\.$#"
count: 1
path: Twig/LocaleFormatExtensionsTest.php
-
message: "#^Parameter \\#1 \\$timezoneId of function date_default_timezone_set expects string, string\\|null given\\.$#"
count: 1

View File

@@ -1554,11 +1554,15 @@
</trans-unit>
<trans-unit id="i_XOmN4" resname="help_locales">
<source>help_locales</source>
<target>Unterstützte Sprachen mit Formatierungsregeln</target>
<target>Hier finden Sie Beispiele für die Formatierungsregeln der unterstützten Sprachen</target>
</trans-unit>
<trans-unit id="d9m85KE" resname="locale">
<source>locale</source>
<target state="translated">Format für Zeit, Datum und Geld</target>
</trans-unit>
<trans-unit id="JmkKf48" resname="user.language.help">
<source>user.language.help</source>
<target state="translated">Diese Einstellung ist neben der Übersetzung der Oberfläche ebenso zuständig für die Zeit-, Datums-, und Währungsformate.</target>
<target state="translated">Diese Einstellung ist für die Übersetzung der Software zuständig</target>
</trans-unit>
<trans-unit id="ulgdXdi" resname="hour_24">
<source>hour_24</source>

View File

@@ -1554,11 +1554,15 @@
</trans-unit>
<trans-unit id="i_XOmN4" resname="help_locales">
<source>help_locales</source>
<target>Supported languages with formatting rules</target>
<target>Here are examples of formatting rules for the supported languages</target>
</trans-unit>
<trans-unit id="d9m85KE" resname="locale">
<source>locale</source>
<target state="translated">Format for time, date and currency</target>
</trans-unit>
<trans-unit id="JmkKf48" resname="user.language.help">
<source>user.language.help</source>
<target>In addition to translating the interface, this setting is also responsible for the time, date and currency formats.</target>
<target>This setting is responsible for the translation of the software</target>
</trans-unit>
<trans-unit id="ulgdXdi" resname="hour_24">
<source>hour_24</source>