Implementiere bevorzugte Sprachauswahl und Datenquellen-Synonyme

Die Spracheinstellungen/System-Settings wurden um die Möglichkeit ergänzt, bevorzugte Sprachen für die Dropdown-Menüs festzulegen. Zudem wurde ein Datenquellen-Synonymsystem implementiert, um benutzerfreundlichere Bezeichnungen anzuzeigen und zu personalisieren.
This commit is contained in:
Marcel Diegelmann 2025-10-15 12:33:05 +02:00
parent e53b72a8d1
commit 68e7ffa452
34 changed files with 648 additions and 44 deletions

View file

@ -61,7 +61,7 @@ class ToolsController extends AbstractController
'default_timezone' => $settings->system->localization->timezone,
'default_currency' => $settings->system->localization->baseCurrency,
'default_theme' => $settings->system->customization->theme,
'enabled_locales' => $this->getParameter('partdb.locale_menu'),
'enabled_locales' => array_column($settings->system->localization->preferredLanguages, 'value'),
'demo_mode' => $this->getParameter('partdb.demo_mode'),
'use_gravatar' => $settings->system->privacy->useGravatar,
'gdpr_compliance' => $this->getParameter('partdb.gdpr_compliance'),

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Form\Type;
use App\Settings\BehaviorSettings\DataSourceSynonymsSettings;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
/**
* A form type that generates multiple JSON input fields for different data sources.
*/
class DataSourceJsonType extends AbstractType
{
public function __construct(private DataSourceSynonymsSettings $settings)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$dataSources = $options['data_sources'];
$defaultValues = $options['default_values'];
$existingData = $options['data'] ?? [];
if ($existingData === []) {
$existingData = $this->settings->dataSourceSynonyms;
}
foreach ($dataSources as $key => $label) {
$initialData = $existingData[$key] ?? $defaultValues[$key] ?? '{}';
$builder->add($key, TextareaType::class, [
'label' => $label,
'required' => false,
'data' => $initialData,
'attr' => [
'rows' => 3,
'style' => 'font-family: monospace;',
'placeholder' => sprintf('%s translations in JSON format', ucfirst($key)),
],
'constraints' => [
new Assert\Callback(function ($value, $context) {
if ($value && !static::isValidJson($value)) {
$context->buildViolation('The field must contain valid JSON.')->addViolation();
}
}),
],
]);
}
$builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event) use ($defaultValues) {
$data = $event->getData();
if (!$data) {
$event->setData($defaultValues);
return;
}
foreach ($defaultValues as $key => $defaultValue) {
if (empty($data[$key])) {
$data[$key] = $defaultValue;
} else {
$decodedValue = json_decode($data[$key], true);
if (json_last_error() === JSON_ERROR_NONE) {
$data[$key] = json_encode($decodedValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}
}
}
$event->setData($data);
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_sources' => [],
'default_values' => [],
]);
$resolver->setAllowedTypes('data_sources', 'array');
$resolver->setAllowedTypes('default_values', 'array');
}
/**
* Validates if a string is a valid JSON format.
*
* @param string $json
* @return bool
*/
private static function isValidJson(string $json): bool
{
json_decode($json);
return json_last_error() === JSON_ERROR_NONE;
}
}

View file

@ -23,7 +23,7 @@ declare(strict_types=1);
namespace App\Form\Type;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use App\Settings\SystemSettings\LocalizationSettings;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
use Symfony\Component\OptionsResolver\OptionsResolver;
@ -35,7 +35,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class LocaleSelectType extends AbstractType
{
public function __construct(#[Autowire(param: 'partdb.locale_menu')] private readonly array $preferred_languages)
public function __construct(private LocalizationSettings $localizationSetting)
{
}
@ -47,7 +47,7 @@ class LocaleSelectType extends AbstractType
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'preferred_choices' => $this->preferred_languages,
'preferred_choices' => array_column($this->localizationSetting->preferredLanguages, 'value'),
]);
}
}

View file

@ -38,6 +38,7 @@ use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use App\Helpers\Trees\TreeViewNode;
use App\Services\Cache\UserCacheKeyGenerator;
use App\Settings\BehaviorSettings\DataSourceSynonymsSettings;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Cache\ItemInterface;
@ -50,8 +51,14 @@ use Symfony\Contracts\Translation\TranslatorInterface;
*/
class ToolsTreeBuilder
{
public function __construct(protected TranslatorInterface $translator, protected UrlGeneratorInterface $urlGenerator, protected TagAwareCacheInterface $cache, protected UserCacheKeyGenerator $keyGenerator, protected Security $security)
{
public function __construct(
protected TranslatorInterface $translator,
protected UrlGeneratorInterface $urlGenerator,
protected TagAwareCacheInterface $cache,
protected UserCacheKeyGenerator $keyGenerator,
protected Security $security,
protected DataSourceSynonymsSettings $dataSourceSynonymsSettings,
) {
}
/**
@ -139,7 +146,7 @@ class ToolsTreeBuilder
$this->translator->trans('info_providers.search.title'),
$this->urlGenerator->generate('info_providers_search')
))->setIcon('fa-treeview fa-fw fa-solid fa-cloud-arrow-down');
$nodes[] = (new TreeViewNode(
$this->translator->trans('info_providers.bulk_import.manage_jobs'),
$this->urlGenerator->generate('bulk_info_provider_manage')
@ -166,37 +173,37 @@ class ToolsTreeBuilder
}
if ($this->security->isGranted('read', new Category())) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.edit.categories'),
$this->getTranslatedDataSourceOrSynonym('category', 'tree.tools.edit.categories', $this->translator->getLocale()),
$this->urlGenerator->generate('category_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-tags');
}
if ($this->security->isGranted('read', new Project())) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.edit.projects'),
$this->getTranslatedDataSourceOrSynonym('project', 'tree.tools.edit.projects', $this->translator->getLocale()),
$this->urlGenerator->generate('project_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-archive');
}
if ($this->security->isGranted('read', new Supplier())) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.edit.suppliers'),
$this->getTranslatedDataSourceOrSynonym('supplier', 'tree.tools.edit.suppliers', $this->translator->getLocale()),
$this->urlGenerator->generate('supplier_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-truck');
}
if ($this->security->isGranted('read', new Manufacturer())) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.edit.manufacturer'),
$this->getTranslatedDataSourceOrSynonym('manufacturer', 'tree.tools.edit.manufacturer', $this->translator->getLocale()),
$this->urlGenerator->generate('manufacturer_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-industry');
}
if ($this->security->isGranted('read', new StorageLocation())) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.edit.storelocation'),
$this->getTranslatedDataSourceOrSynonym('storagelocation', 'tree.tools.edit.storelocation', $this->translator->getLocale()),
$this->urlGenerator->generate('store_location_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-cube');
}
if ($this->security->isGranted('read', new Footprint())) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.edit.footprint'),
$this->getTranslatedDataSourceOrSynonym('footprint', 'tree.tools.edit.footprint', $this->translator->getLocale()),
$this->urlGenerator->generate('footprint_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-microchip');
}
@ -310,4 +317,24 @@ class ToolsTreeBuilder
return $nodes;
}
protected function getTranslatedDataSourceOrSynonym(string $dataSource, string $translationKey, string $locale): string
{
$currentTranslation = $this->translator->trans($translationKey);
$synonyms = $this->dataSourceSynonymsSettings->getSynonymsAsArray();
// Call alternatives from DataSourcesynonyms (if available)
if (!empty($synonyms[$dataSource][$locale])) {
$alternativeTranslation = $synonyms[$dataSource][$locale];
// Use alternative translation when it deviates from the standard translation
if ($alternativeTranslation !== $currentTranslation) {
return $alternativeTranslation;
}
}
// Otherwise return the standard translation
return $currentTranslation;
}
}

View file

@ -38,6 +38,7 @@ use App\Repository\StructuralDBElementRepository;
use App\Services\Cache\ElementCacheTagGenerator;
use App\Services\Cache\UserCacheKeyGenerator;
use App\Services\EntityURLGenerator;
use App\Settings\BehaviorSettings\DataSourceSynonymsSettings;
use App\Settings\BehaviorSettings\SidebarSettings;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@ -67,6 +68,7 @@ class TreeViewGenerator
protected TranslatorInterface $translator,
private readonly UrlGeneratorInterface $router,
private readonly SidebarSettings $sidebarSettings,
protected DataSourceSynonymsSettings $dataSourceSynonymsSettings,
) {
$this->rootNodeEnabled = $this->sidebarSettings->rootNodeEnabled;
$this->rootNodeExpandedByDefault = $this->sidebarSettings->rootNodeExpanded;
@ -212,13 +214,15 @@ class TreeViewGenerator
protected function entityClassToRootNodeString(string $class): string
{
$locale = $this->translator->getLocale();
return match ($class) {
Category::class => $this->translator->trans('category.labelp'),
StorageLocation::class => $this->translator->trans('storelocation.labelp'),
Footprint::class => $this->translator->trans('footprint.labelp'),
Manufacturer::class => $this->translator->trans('manufacturer.labelp'),
Supplier::class => $this->translator->trans('supplier.labelp'),
Project::class => $this->translator->trans('project.labelp'),
Category::class => $this->getTranslatedOrSynonym('category', $locale),
StorageLocation::class => $this->getTranslatedOrSynonym('storelocation', $locale),
Footprint::class => $this->getTranslatedOrSynonym('footprint', $locale),
Manufacturer::class => $this->getTranslatedOrSynonym('manufacturer', $locale),
Supplier::class => $this->getTranslatedOrSynonym('supplier', $locale),
Project::class => $this->getTranslatedOrSynonym('project', $locale),
default => $this->translator->trans('tree.root_node.text'),
};
}
@ -274,4 +278,24 @@ class TreeViewGenerator
return $repo->getGenericNodeTree($parent); //@phpstan-ignore-line
});
}
protected function getTranslatedOrSynonym(string $key, string $locale): string
{
$currentTranslation = $this->translator->trans($key . '.labelp');
$synonyms = $this->dataSourceSynonymsSettings->getSynonymsAsArray();
// Call alternatives from DataSourcesynonyms (if available)
if (!empty($synonyms[$key][$locale])) {
$alternativeTranslation = $synonyms[$key][$locale];
// Use alternative translation when it deviates from the standard translation
if ($alternativeTranslation !== $currentTranslation) {
return $alternativeTranslation;
}
}
// Otherwise return the standard translation
return $currentTranslation;
}
}

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Settings\BehaviorSettings;
use App\Form\Type\DataSourceJsonType;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\ParameterTypes\ArrayType;
use Jbtronics\SettingsBundle\ParameterTypes\StringType;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Translation\TranslatableMessage as TM;
#[Settings(label: new TM("settings.system.data_source_synonyms"))]
#[SettingsIcon("fa-language")]
class DataSourceSynonymsSettings
{
use SettingsTrait;
#[SettingsParameter(ArrayType::class,
label: new TM("settings.system.data_source_synonyms.configuration"),
description: new TM("settings.system.data_source_synonyms.configuration.help", ['%format%' => '{"en":"", "de":""}']),
options: ['type' => StringType::class],
formType: DataSourceJsonType::class,
formOptions: [
'required' => false,
'data_sources' => [
'category' => new TM("settings.behavior.data_source_synonyms.category"),
'storagelocation' => new TM("settings.behavior.data_source_synonyms.storagelocation"),
'footprint' => new TM("settings.behavior.data_source_synonyms.footprint"),
'manufacturer' => new TM("settings.behavior.data_source_synonyms.manufacturer"),
'supplier' => new TM("settings.behavior.data_source_synonyms.supplier"),
'project' => new TM("settings.behavior.data_source_synonyms.project"),
],
'default_values' => [
'category' => '{"en":"Categories", "de":"Kategorien"}',
'storagelocation' => '{"en":"Storage locations", "de":"Lagerorte"}',
'footprint' => '{"en":"Footprints", "de":"Footprints"}',
'manufacturer' => '{"en":"Manufacturers", "de":"Hersteller"}',
'supplier' => '{"en":"Suppliers", "de":"Lieferanten"}',
'project' => '{"en":"Projects", "de":"Projekte"}',
],
],
)]
#[Assert\Type('array')]
public array $dataSourceSynonyms = [
'category' => '{"en":"Categories", "de":"Kategorien"}',
'storagelocation' => '{"en":"Storage locations", "de":"Lagerorte"}',
'footprint' => '{"en":"Footprints", "de":"Footprints"}',
'manufacturer' => '{"en":"Manufacturers", "de":"Hersteller"}',
'supplier' => '{"en":"Suppliers", "de":"Lieferanten"}',
'project' => '{"en":"Projects", "de":"Projekte"}',
];
/**
* Get the synonyms data as a structured array.
*
* @return array<string, array<string, string>> The data source synonyms parsed from JSON to array.
*/
public function getSynonymsAsArray(): array
{
$result = [];
foreach ($this->dataSourceSynonyms as $key => $jsonString) {
$result[$key] = json_decode($jsonString, true, 512, JSON_THROW_ON_ERROR) ?? [];
}
return $result;
}
}

View file

@ -0,0 +1,37 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Settings\SystemSettings;
enum PreferredLocales: string
{
case EN = 'en';
case DE = 'de';
case IT = 'it';
case FR = 'fr';
case RU = 'ru';
case JA = 'ja';
case CS = 'cs';
case DA = 'da';
case ZH = 'zh';
case PL = 'pl';
}

View file

@ -24,6 +24,7 @@ declare(strict_types=1);
namespace App\Settings\SystemSettings;
use Jbtronics\SettingsBundle\Settings\EmbeddedSettings;
use App\Settings\BehaviorSettings\DataSourceSynonymsSettings;
use Jbtronics\SettingsBundle\Settings\Settings;
use Symfony\Component\Translation\TranslatableMessage as TM;
@ -33,6 +34,9 @@ class SystemSettings
#[EmbeddedSettings()]
public ?LocalizationSettings $localization = null;
#[EmbeddedSettings]
public ?DataSourceSynonymsSettings $dataSourceSynonyms = null;
#[EmbeddedSettings()]
public ?CustomizationSettings $customization = null;

View file

@ -0,0 +1,43 @@
<?php
namespace App\Twig;
use App\Settings\BehaviorSettings\DataSourceSynonymsSettings;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class DataSourceNameExtension extends AbstractExtension
{
private TranslatorInterface $translator;
private array $dataSourceSynonyms;
public function __construct(TranslatorInterface $translator, DataSourceSynonymsSettings $dataSourceSynonymsSettings)
{
$this->translator = $translator;
$this->dataSourceSynonyms = $dataSourceSynonymsSettings->getSynonymsAsArray();
}
public function getFunctions(): array
{
return [
new TwigFunction('get_data_source_name', [$this, 'getDataSourceName']),
];
}
/**
* Based on the locale and data source names, gives the right synonym value back or the default translator value.
*/
public function getDataSourceName(string $dataSourceName, string $defaultKey): string
{
$locale = $this->translator->getLocale();
// Use alternative dataSource synonym (if available)
if (isset($this->dataSourceSynonyms[$dataSourceName][$locale])) {
return $this->dataSourceSynonyms[$dataSourceName][$locale];
}
// Otherwise return the standard translation
return $this->translator->trans($defaultKey);
}
}