Split user language (UI translation) from locale (formatted values) (#4595)
This commit is contained in:
@@ -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()) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
47
src/Form/Type/UserLocaleType.php
Normal file
47
src/Form/Type/UserLocaleType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user