Entferne alten JSON-basierten Datenquellen-Synonym-Handler

Die Verwaltung der Datenquellen-Synonyme wurde überarbeitet, um ein flexibleres und strukturiertes Konzept zu ermöglichen. Der bestehende JSON-basierte Ansatz wurde durch eine neue Service-basierte Architektur ersetzt, die eine bessere Handhabung und Erweiterbarkeit erlaubt.
This commit is contained in:
Marcel Diegelmann 2025-11-05 12:27:40 +01:00
parent d80ec94227
commit a8b3dce899
30 changed files with 802 additions and 277 deletions

View file

@ -64,7 +64,7 @@ class SettingsController extends AbstractController
$this->settingsManager->save($settings);
//It might be possible, that the tree settings have changed, so clear the cache
$cache->invalidateTags(['tree_treeview', 'sidebar_tree_update']);
$cache->invalidateTags(['tree_tools', 'tree_treeview', 'sidebar_tree_update']);
$this->addFlash('success', t('settings.flash.saved'));
}

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' => array_column($settings->system->localization->preferredLanguages, 'value'),
'enabled_locales' => $this->getParameter('partdb.locale_menu'),
'demo_mode' => $this->getParameter('partdb.demo_mode'),
'use_gravatar' => $settings->system->privacy->useGravatar,
'gdpr_compliance' => $this->getParameter('partdb.gdpr_compliance'),

View file

@ -1,103 +0,0 @@
<?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
*/
public static function isValidJson(string $json): bool
{
json_decode($json);
return json_last_error() === JSON_ERROR_NONE;
}
}

View file

@ -0,0 +1,131 @@
<?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\Form\Type;
use App\Settings\SystemSettings\LocalizationSettings;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Intl\Locales;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
/**
* A single translation row: data source + language + translations (singular/plural).
*/
class DataSourceSynonymRowType extends AbstractType
{
public function __construct(
private readonly LocalizationSettings $localizationSettings,
#[Autowire(param: 'partdb.locale_menu')] private readonly array $preferredLanguagesParam,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('dataSource', ChoiceType::class, [
'label' => 'settings.behavior.data_source_synonyms.row_type.form.datasource',
'choices' => $this->buildDataSourceChoices($options['data_sources']),
'required' => true,
'constraints' => [
new Assert\NotBlank(message: 'settings.system.data_source_synonyms.row_type.value_not_blank'),
],
])
->add('locale', LocaleType::class, [
'label' => 'settings.behavior.data_source_synonyms.row_type.form.locale',
'required' => true,
// Restrict to languages configured in the language menu: disable ChoiceLoader and provide explicit choices
'choice_loader' => null,
'choices' => $this->buildLocaleChoices(),
'preferred_choices' => $this->getPreferredLocales(),
'constraints' => [
new Assert\NotBlank(message: 'settings.system.data_source_synonyms.row_type.value_not_blank'),
],
])
->add('translation_singular', TextType::class, [
'label' => 'settings.behavior.data_source_synonyms.row_type.form.translation_singular',
'required' => true,
'empty_data' => '',
'constraints' => [
new Assert\NotBlank(message: 'settings.system.data_source_synonyms.row_type.value_not_blank'),
],
])
->add('translation_plural', TextType::class, [
'label' => 'settings.behavior.data_source_synonyms.row_type.form.translation_plural',
'required' => true,
'empty_data' => '',
'constraints' => [
new Assert\NotBlank(message: 'settings.system.data_source_synonyms.row_type.value_not_blank'),
],
]);
}
private function buildDataSourceChoices(array $dataSources): array
{
$choices = [];
foreach ($dataSources as $key => $label) {
$choices[(string)$label] = (string)$key;
}
return $choices;
}
/**
* Returns only locales configured in the language menu (settings) or falls back to the parameter.
* Format: ['German (DE)' => 'de', ...]
*/
private function buildLocaleChoices(): array
{
$locales = $this->getPreferredLocales();
$choices = [];
foreach ($locales as $code) {
$label = Locales::getName($code);
$choices[$label . ' (' . strtoupper($code) . ')'] = $code;
}
return $choices;
}
/**
* Source of allowed locales:
* 1) LocalizationSettings->languageMenuEntries (if set)
* 2) Fallback: parameter partdb.locale_menu
*/
private function getPreferredLocales(): array
{
$fromSettings = $this->localizationSettings->languageMenuEntries ?? [];
return !empty($fromSettings) ? array_values($fromSettings) : array_values($this->preferredLanguagesParam);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setRequired('data_sources');
$resolver->setAllowedTypes('data_sources', 'array');
$resolver->setDefaults([
'error_translation_domain' => 'validators',
]);
}
}

View file

@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Intl\Locales;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Flat collection of translation rows.
* View data: list [{dataSource, locale, translation_singular, translation_plural}, ...]
* Model data: same structure (list). Optionally expands a nested map to a list.
*/
class DataSourceSynonymsCollectionType extends AbstractType
{
public function __construct(private readonly TranslatorInterface $translator)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer(new CallbackTransformer(
// Model -> View
function ($modelValue) {
if (!is_array($modelValue)) {
return [[
'dataSource' => null,
'locale' => null,
'translation_singular' => null,
'translation_plural' => null,
]];
}
return $modelValue === [] ? [[
'dataSource' => null,
'locale' => null,
'translation_singular' => null,
'translation_plural' => null,
]] : $modelValue;
},
// View -> Model (keep list; let existing behavior unchanged)
function ($viewValue) {
if (!is_array($viewValue)) {
return [];
}
$out = [];
foreach ($viewValue as $row) {
if (is_array($row)) {
$out[] = $row;
}
}
return $out;
}
));
// Validation and normalization (duplicates + sorting) during SUBMIT
$builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void {
$form = $event->getForm();
$rows = $event->getData();
if (!is_array($rows)) {
return;
}
// Duplicate check: (dataSource, locale) must be unique
$seen = [];
$hasDuplicate = false;
foreach ($rows as $idx => $row) {
if (!is_array($row)) {
continue;
}
$ds = $row['dataSource'] ?? null;
$loc = $row['locale'] ?? null;
if (is_string($ds) && $ds !== '' && is_string($loc) && $loc !== '') {
$key = $ds . '|' . $loc;
if (isset($seen[$key])) {
$hasDuplicate = true;
if ($form->has((string)$idx)) {
$child = $form->get((string)$idx);
if ($child->has('dataSource')) {
$child->get('dataSource')->addError(
new FormError($this->translator->trans(
'settings.system.data_source_synonyms.collection_type.duplicate',
[], 'validators'
))
);
}
if ($child->has('locale')) {
$child->get('locale')->addError(
new FormError($this->translator->trans(
'settings.system.data_source_synonyms.collection_type.duplicate',
[], 'validators'
))
);
}
}
} else {
$seen[$key] = true;
}
}
}
if ($hasDuplicate) {
return;
}
// Overall sort: first by dataSource key, then by localized language name
$sortable = $rows;
usort($sortable, static function ($a, $b) {
$aDs = (string)($a['dataSource'] ?? '');
$bDs = (string)($b['dataSource'] ?? '');
$cmpDs = strcasecmp($aDs, $bDs);
if ($cmpDs !== 0) {
return $cmpDs;
}
$aLoc = (string)($a['locale'] ?? '');
$bLoc = (string)($b['locale'] ?? '');
$aName = Locales::getName($aLoc);
$bName = Locales::getName($bLoc);
return strcasecmp($aName, $bName);
});
$event->setData($sortable);
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setRequired(['data_sources']);
$resolver->setAllowedTypes('data_sources', 'array');
// Defaults for the collection and entry type
$resolver->setDefaults([
'entry_type' => DataSourceSynonymRowType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'required' => false,
'prototype' => true,
'empty_data' => [],
'entry_options' => ['label' => false],
'error_translation_domain' => 'validators',
]);
// Pass data_sources automatically to each row (DataSourceSynonymRowType)
$resolver->setNormalizer('entry_options', function (Options $options, $value) {
$value = is_array($value) ? $value : [];
return $value + ['data_sources' => $options['data_sources']];
});
}
public function getParent(): ?string
{
return CollectionType::class;
}
public function getBlockPrefix(): string
{
return 'datasource_synonyms_collection';
}
}

View file

@ -23,19 +23,18 @@ declare(strict_types=1);
namespace App\Form\Type;
use App\Settings\SystemSettings\LocalizationSettings;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* A locale select field that uses the preferred languages from the configuration.
*/
class LocaleSelectType extends AbstractType
{
public function __construct(private LocalizationSettings $localizationSetting)
public function __construct(#[Autowire(param: 'partdb.locale_menu')] private readonly array $preferred_languages)
{
}
@ -47,7 +46,7 @@ class LocaleSelectType extends AbstractType
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'preferred_choices' => array_column($this->localizationSetting->preferredLanguages, 'value'),
'preferred_choices' => $this->preferred_languages,
]);
}
}

View file

@ -0,0 +1,71 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 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\Services\Misc;
use App\Settings\BehaviorSettings\DataSourceSynonymsSettings;
use Symfony\Contracts\Translation\TranslatorInterface;
readonly class DataSourceSynonymResolver
{
public function __construct(
private TranslatorInterface $translator,
private DataSourceSynonymsSettings $synonymsSettings,
) {
}
public function displayNamePlural(string $dataSource, string $defaultKey, ?string $locale = null): string
{
$locale ??= $this->translator->getLocale();
$syn = $this->synonyms($dataSource, $locale);
if ($syn['plural'] !== '') {
return $syn['plural'];
}
return $this->translator->trans($defaultKey, locale: $locale);
}
public function displayNameSingular(string $dataSource, string $defaultKey, ?string $locale = null): string
{
$locale ??= $this->translator->getLocale();
$syn = $this->synonyms($dataSource, $locale);
if ($syn['singular'] !== '') {
return $syn['singular'];
}
return $this->translator->trans($defaultKey, locale: $locale);
}
private function synonyms(string $dataSource, string $locale): array
{
$all = $this->synonymsSettings->getSynonymsAsArray();
$row = $all[$dataSource][$locale] ?? ['singular' => '', 'plural' => ''];
return [
'singular' => (string)($row['singular'] ?? ''),
'plural' => (string)($row['plural'] ?? ''),
];
}
}

View file

@ -38,7 +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 App\Services\Misc\DataSourceSynonymResolver;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Cache\ItemInterface;
@ -57,7 +57,7 @@ class ToolsTreeBuilder
protected TagAwareCacheInterface $cache,
protected UserCacheKeyGenerator $keyGenerator,
protected Security $security,
protected DataSourceSynonymsSettings $dataSourceSynonymsSettings,
protected readonly DataSourceSynonymResolver $synonymResolver,
) {
}
@ -173,37 +173,60 @@ class ToolsTreeBuilder
}
if ($this->security->isGranted('read', new Category())) {
$nodes[] = (new TreeViewNode(
$this->getTranslatedDataSourceOrSynonym('category', 'tree.tools.edit.categories', $this->translator->getLocale()),
$this->synonymResolver->displayNamePlural(
'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->getTranslatedDataSourceOrSynonym('project', 'tree.tools.edit.projects', $this->translator->getLocale()),
$this->synonymResolver->displayNamePlural(
'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->getTranslatedDataSourceOrSynonym('supplier', 'tree.tools.edit.suppliers', $this->translator->getLocale()),
$this->synonymResolver->displayNamePlural(
'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->getTranslatedDataSourceOrSynonym('manufacturer', 'tree.tools.edit.manufacturer', $this->translator->getLocale()),
$this->synonymResolver->displayNamePlural(
'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->getTranslatedDataSourceOrSynonym('storagelocation', 'tree.tools.edit.storelocation', $this->translator->getLocale()),
$this->synonymResolver->displayNamePlural(
'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->getTranslatedDataSourceOrSynonym('footprint', 'tree.tools.edit.footprint', $this->translator->getLocale()),
$this->synonymResolver->displayNamePlural(
'footprint',
'tree.tools.edit.footprint',
$this->translator->getLocale()
),
$this->urlGenerator->generate('footprint_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-microchip');
}
@ -317,24 +340,4 @@ 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

@ -34,11 +34,10 @@ use App\Entity\ProjectSystem\Project;
use App\Helpers\Trees\TreeViewNode;
use App\Helpers\Trees\TreeViewNodeIterator;
use App\Repository\NamedDBElementRepository;
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\Services\Misc\DataSourceSynonymResolver;
use App\Settings\BehaviorSettings\SidebarSettings;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@ -68,7 +67,7 @@ class TreeViewGenerator
protected TranslatorInterface $translator,
private readonly UrlGeneratorInterface $router,
private readonly SidebarSettings $sidebarSettings,
protected DataSourceSynonymsSettings $dataSourceSynonymsSettings,
protected readonly DataSourceSynonymResolver $synonymResolver
) {
$this->rootNodeEnabled = $this->sidebarSettings->rootNodeEnabled;
$this->rootNodeExpandedByDefault = $this->sidebarSettings->rootNodeExpanded;
@ -217,12 +216,12 @@ class TreeViewGenerator
$locale = $this->translator->getLocale();
return match ($class) {
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),
Category::class => $this->synonymResolver->displayNamePlural('category', $locale),
StorageLocation::class => $this->synonymResolver->displayNamePlural('storelocation', $locale),
Footprint::class => $this->synonymResolver->displayNamePlural('footprint', $locale),
Manufacturer::class => $this->synonymResolver->displayNamePlural('manufacturer', $locale),
Supplier::class => $this->synonymResolver->displayNamePlural('supplier', $locale),
Project::class => $this->synonymResolver->displayNamePlural('project', $locale),
default => $this->translator->trans('tree.root_node.text'),
};
}
@ -278,24 +277,4 @@ 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

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Settings\BehaviorSettings;
use App\Form\Type\DataSourceJsonType;
use App\Form\Type\DataSourceSynonymsCollectionType;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\ParameterTypes\ArrayType;
use Jbtronics\SettingsBundle\ParameterTypes\StringType;
@ -20,11 +20,12 @@ class DataSourceSynonymsSettings
{
use SettingsTrait;
#[SettingsParameter(ArrayType::class,
#[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,
description: new TM("settings.system.data_source_synonyms.configuration.help"),
options: ['type' => ArrayType::class, 'options' => ['type' => StringType::class]],
formType: DataSourceSynonymsCollectionType::class,
formOptions: [
'required' => false,
'data_sources' => [
@ -35,39 +36,55 @@ class DataSourceSynonymsSettings
'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')]
#[Assert\All([new 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"}',
// flat list of rows, e.g.:
// ['dataSource' => 'category', 'locale' => 'en', 'translation_singular' => 'Category', 'translation_plural' => 'Categories'],
];
/**
* Get the synonyms data as a structured array.
* Normalize to map form:
* [dataSource => [locale => ['singular' => string, 'plural' => string]]]
* No preference/merging is applied; both values are returned as provided (missing ones as empty strings).
*
* @return array<string, array<string, string>> The data source synonyms parsed from JSON to array.
* @return array<string, array<string, array{singular: string, plural: string}>>
*/
public function getSynonymsAsArray(): array
{
$result = [];
foreach ($this->dataSourceSynonyms as $key => $jsonString) {
$result[$key] = json_decode($jsonString, true, 512, JSON_THROW_ON_ERROR) ?? [];
foreach ($this->dataSourceSynonyms as $row) {
if (!is_array($row)) {
continue;
}
$ds = $row['dataSource'] ?? null;
$loc = $row['locale'] ?? null;
if (!is_string($ds) || $ds === '' || !is_string($loc) || $loc === '') {
continue;
}
// Read both fields independently; do not prefer one over the other.
$singular = isset($row['translation_singular']) && is_string($row['translation_singular'])
? $row['translation_singular'] : '';
$plural = isset($row['translation_plural']) && is_string($row['translation_plural'])
? $row['translation_plural'] : '';
// For legacy data (optional): if only "text" exists and both fields are empty, keep it as given in both slots or leave empty?
// Requirement says: no preference, just return values. We therefore do NOT map legacy automatically.
// If you want to expose legacy "text" as well, handle it outside or migrate data beforehand.
$result[$ds] ??= [];
$result[$ds][$loc] = [
'singular' => $singular,
'plural' => $plural,
];
}
return $result;
}
}

View file

@ -1,37 +0,0 @@
<?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

@ -2,42 +2,66 @@
namespace App\Twig;
use App\Settings\BehaviorSettings\DataSourceSynonymsSettings;
use App\Services\Misc\DataSourceSynonymResolver;
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 __construct(
private readonly TranslatorInterface $translator,
private readonly DataSourceSynonymResolver $resolver,
) {
}
public function getFunctions(): array
{
return [
new TwigFunction('get_data_source_name', [$this, 'getDataSourceName']),
new TwigFunction('get_data_source_name_singular', [$this, 'getDataSourceNameSingular']),
new TwigFunction('get_data_source_name_plural', [$this, 'getDataSourceNamePlural']),
new TwigFunction('data_source_name_with_hint', [$this, 'getDataSourceNameWithHint']),
];
}
/**
* Based on the locale and data source names, gives the right synonym value back or the default translator value.
* Returns the singular synonym for the given data source in current locale,
* or the translated fallback key if no synonym provided.
*/
public function getDataSourceName(string $dataSourceName, string $defaultKey): string
public function getDataSourceNameSingular(string $dataSourceName, string $defaultKeySingular): string
{
$locale = $this->translator->getLocale();
return $this->resolver->displayNameSingular($dataSourceName, $defaultKeySingular, $this->translator->getLocale());
}
// Use alternative dataSource synonym (if available)
if (isset($this->dataSourceSynonyms[$dataSourceName][$locale])) {
return $this->dataSourceSynonyms[$dataSourceName][$locale];
/**
* Returns the plural synonym for the given data source in current locale,
* or the translated fallback key if no synonym provided.
*/
public function getDataSourceNamePlural(string $dataSourceName, string $defaultKeyPlural): string
{
return $this->resolver->displayNamePlural($dataSourceName, $defaultKeyPlural, $this->translator->getLocale());
}
/**
* Like data_source_name, only with a note if a synonym was set (uses translation key 'datasource.synonym').
*/
public function getDataSourceNameWithHint(string $dataSourceName, string $defaultKey, string $type = 'singular'): string
{
$type = $type === 'singular' ? 'singular' : 'plural';
$resolved = $type === 'singular'
? $this->resolver->displayNameSingular($dataSourceName, $defaultKey, $this->translator->getLocale())
: $this->resolver->displayNamePlural($dataSourceName, $defaultKey, $this->translator->getLocale());
$fallback = $this->translator->trans($defaultKey);
if ($resolved !== $fallback) {
return $this->translator->trans('datasource.synonym', [
'%name%' => $fallback,
'%synonym%' => $resolved,
]);
}
// Otherwise return the standard translation
return $this->translator->trans($defaultKey);
return $fallback;
}
}