diff --git a/assets/controllers/elements/datasource_synonyms_collection_controller.js b/assets/controllers/elements/datasource_synonyms_collection_controller.js new file mode 100644 index 00000000..6b2f4811 --- /dev/null +++ b/assets/controllers/elements/datasource_synonyms_collection_controller.js @@ -0,0 +1,68 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2022 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 . + */ + +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['items']; + static values = { + prototype: String, + prototypeName: { type: String, default: '__name__' }, + index: { type: Number, default: 0 }, + }; + + connect() { + if (!this.hasIndexValue || Number.isNaN(this.indexValue)) { + this.indexValue = this.itemsTarget?.children.length || 0; + } + } + + add(event) { + event.preventDefault(); + + const encodedProto = this.prototypeValue || ''; + const placeholder = this.prototypeNameValue || '__name__'; + if (!encodedProto || !this.itemsTarget) return; + + const protoHtml = this._decodeHtmlAttribute(encodedProto); + + const idx = this.indexValue; + const html = protoHtml.replace(new RegExp(placeholder, 'g'), String(idx)); + + const wrapper = document.createElement('div'); + wrapper.innerHTML = html; + const newItem = wrapper.firstElementChild; + if (newItem) { + this.itemsTarget.appendChild(newItem); + this.indexValue = idx + 1; + } + } + + remove(event) { + event.preventDefault(); + const row = event.currentTarget.closest('.tc-item'); + if (row) row.remove(); + } + + _decodeHtmlAttribute(str) { + const tmp = document.createElement('textarea'); + tmp.innerHTML = str; + return tmp.value || tmp.textContent || ''; + } +} diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 78956026..b9fdf5bd 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,13 +1,13 @@ twig: default_path: '%kernel.project_dir%/templates' - form_themes: ['bootstrap_5_horizontal_layout.html.twig', 'form/extended_bootstrap_layout.html.twig', 'form/permission_layout.html.twig', 'form/filter_types_layout.html.twig'] + form_themes: ['bootstrap_5_horizontal_layout.html.twig', 'form/extended_bootstrap_layout.html.twig', 'form/permission_layout.html.twig', 'form/filter_types_layout.html.twig', 'form/datasource_synonyms_collection.html.twig'] paths: '%kernel.project_dir%/assets/css': css globals: allow_email_pw_reset: '%partdb.users.email_pw_reset%' - location_settings: '@App\Settings\SystemSettings\LocalizationSettings' + locale_menu: '%partdb.locale_menu%' attachment_manager: '@App\Services\Attachments\AttachmentManager' label_profile_dropdown_helper: '@App\Services\LabelSystem\LabelProfileDropdownHelper' error_page_admin_email: '%partdb.error_pages.admin_email%' diff --git a/docs/configuration.md b/docs/configuration.md index 42cc546a..4bb46d08 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -272,6 +272,8 @@ command `bin/console cache:clear`. The following options are available: +* `partdb.locale_menu`: The codes of the languages, which should be shown in the language chooser menu (the one with the + user icon in the navbar). The first language in the list will be the default language. * `partdb.gdpr_compliance`: When set to true (default value), IP addresses which are saved in the database will be anonymized, by removing the last byte of the IP. This is required by the GDPR (General Data Protection Regulation) in the EU. diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php index 3479cf84..f412e469 100644 --- a/src/Controller/SettingsController.php +++ b/src/Controller/SettingsController.php @@ -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')); } diff --git a/src/Controller/ToolsController.php b/src/Controller/ToolsController.php index 5d353615..d78aff62 100644 --- a/src/Controller/ToolsController.php +++ b/src/Controller/ToolsController.php @@ -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'), diff --git a/src/Form/Type/DataSourceJsonType.php b/src/Form/Type/DataSourceJsonType.php deleted file mode 100644 index 6d11058a..00000000 --- a/src/Form/Type/DataSourceJsonType.php +++ /dev/null @@ -1,103 +0,0 @@ -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; - } -} diff --git a/src/Form/Type/DataSourceSynonymRowType.php b/src/Form/Type/DataSourceSynonymRowType.php new file mode 100644 index 00000000..eeed32cd --- /dev/null +++ b/src/Form/Type/DataSourceSynonymRowType.php @@ -0,0 +1,131 @@ +. + */ + +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', + ]); + } +} diff --git a/src/Form/Type/DataSourceSynonymsCollectionType.php b/src/Form/Type/DataSourceSynonymsCollectionType.php new file mode 100644 index 00000000..3853d56a --- /dev/null +++ b/src/Form/Type/DataSourceSynonymsCollectionType.php @@ -0,0 +1,180 @@ +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'; + } +} diff --git a/src/Form/Type/LocaleSelectType.php b/src/Form/Type/LocaleSelectType.php index b87932d1..6dc6f9fc 100644 --- a/src/Form/Type/LocaleSelectType.php +++ b/src/Form/Type/LocaleSelectType.php @@ -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, ]); } } diff --git a/src/Services/Misc/DataSourceSynonymResolver.php b/src/Services/Misc/DataSourceSynonymResolver.php new file mode 100644 index 00000000..8e164670 --- /dev/null +++ b/src/Services/Misc/DataSourceSynonymResolver.php @@ -0,0 +1,71 @@ +. + */ + +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'] ?? ''), + ]; + } +} diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php index 9a22ad4f..fa22bd0a 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -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; - } } diff --git a/src/Services/Trees/TreeViewGenerator.php b/src/Services/Trees/TreeViewGenerator.php index 4b30cb18..1cd1730f 100644 --- a/src/Services/Trees/TreeViewGenerator.php +++ b/src/Services/Trees/TreeViewGenerator.php @@ -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; - } } diff --git a/src/Settings/BehaviorSettings/DataSourceSynonymsSettings.php b/src/Settings/BehaviorSettings/DataSourceSynonymsSettings.php index 74b9a2a1..c10792d6 100644 --- a/src/Settings/BehaviorSettings/DataSourceSynonymsSettings.php +++ b/src/Settings/BehaviorSettings/DataSourceSynonymsSettings.php @@ -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> The data source synonyms parsed from JSON to array. + * @return array> */ 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; } - } diff --git a/src/Settings/SystemSettings/PreferredLocales.php b/src/Settings/SystemSettings/PreferredLocales.php deleted file mode 100644 index 1fe38a54..00000000 --- a/src/Settings/SystemSettings/PreferredLocales.php +++ /dev/null @@ -1,37 +0,0 @@ -. - */ - -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'; -} diff --git a/src/Twig/DataSourceNameExtension.php b/src/Twig/DataSourceNameExtension.php index d0d8b4b5..693b3a36 100644 --- a/src/Twig/DataSourceNameExtension.php +++ b/src/Twig/DataSourceNameExtension.php @@ -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; } } diff --git a/templates/admin/category_admin.html.twig b/templates/admin/category_admin.html.twig index 82089a28..7478de11 100644 --- a/templates/admin/category_admin.html.twig +++ b/templates/admin/category_admin.html.twig @@ -1,9 +1,8 @@ {% extends "admin/base_admin.html.twig" %} {% block card_title %} - {% set dataSourceName = get_data_source_name('category', 'category.labelp') %} - {% set translatedSource = 'category.labelp'|trans %} - {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %} + + {{ data_source_name_with_hint('category', 'category.label') }} {% endblock %} {% block additional_pills %} @@ -63,4 +62,4 @@ {{ form_row(form.eda_info.kicad_symbol) }} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/footprint_admin.html.twig b/templates/admin/footprint_admin.html.twig index a6acbe84..e76fa52f 100644 --- a/templates/admin/footprint_admin.html.twig +++ b/templates/admin/footprint_admin.html.twig @@ -1,9 +1,8 @@ {% extends "admin/base_admin.html.twig" %} {% block card_title %} - {% set dataSourceName = get_data_source_name('footprint', 'footprint.labelp') %} - {% set translatedSource = 'footprint.labelp'|trans %} - {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %} + + {{ data_source_name_with_hint('footprint', 'footprint.labelp') }} {% endblock %} {% block master_picture_block %} @@ -36,4 +35,4 @@ {{ form_row(form.eda_info.kicad_footprint) }} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/manufacturer_admin.html.twig b/templates/admin/manufacturer_admin.html.twig index 3ce9a124..3289fb08 100644 --- a/templates/admin/manufacturer_admin.html.twig +++ b/templates/admin/manufacturer_admin.html.twig @@ -1,9 +1,8 @@ {% extends "admin/base_company_admin.html.twig" %} {% block card_title %} - {% set dataSourceName = get_data_source_name('manufacturer', 'manufacturer.caption') %} - {% set translatedSource = 'manufacturer.caption'|trans %} - {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %} + + {{ data_source_name_with_hint('manufacturer', 'manufacturer.caption') }} {% endblock %} {% block edit_title %} @@ -12,4 +11,4 @@ {% block new_title %} {% trans %}manufacturer.new{% endtrans %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/project_admin.html.twig b/templates/admin/project_admin.html.twig index 8066d545..044f50a5 100644 --- a/templates/admin/project_admin.html.twig +++ b/templates/admin/project_admin.html.twig @@ -3,9 +3,8 @@ {# @var entity App\Entity\ProjectSystem\Project #} {% block card_title %} - {% set dataSourceName = get_data_source_name('project', 'project.caption') %} - {% set translatedSource = 'project.caption'|trans %} - {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %} + + {{ data_source_name_with_hint('project', 'project.labelp') }} {% endblock %} {% block edit_title %} @@ -61,4 +60,4 @@ {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/storelocation_admin.html.twig b/templates/admin/storelocation_admin.html.twig index 1e60eeea..954c0322 100644 --- a/templates/admin/storelocation_admin.html.twig +++ b/templates/admin/storelocation_admin.html.twig @@ -2,9 +2,8 @@ {% import "label_system/dropdown_macro.html.twig" as dropdown %} {% block card_title %} - {% set dataSourceName = get_data_source_name('storagelocation', 'storelocation.labelp') %} - {% set translatedSource = 'storelocation.labelp'|trans %} - {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %} + + {{ data_source_name_with_hint('storagelocation', 'storelocation.labelp') }} {% endblock %} {% block additional_controls %} @@ -40,4 +39,4 @@ {% block new_title %} {% trans %}storelocation.new{% endtrans %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/supplier_admin.html.twig b/templates/admin/supplier_admin.html.twig index b5cf7b23..7b0813d4 100644 --- a/templates/admin/supplier_admin.html.twig +++ b/templates/admin/supplier_admin.html.twig @@ -1,9 +1,8 @@ {% extends "admin/base_company_admin.html.twig" %} {% block card_title %} - {% set dataSourceName = get_data_source_name('supplier', 'supplier.caption') %} - {% set translatedSource = 'supplier.caption'|trans %} - {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %} + + {{ data_source_name_with_hint('supplier', 'supplier.labelp') }} {% endblock %} {% block additional_panes %} @@ -21,4 +20,4 @@ {% block new_title %} {% trans %}supplier.new{% endtrans %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/components/tree_macros.html.twig b/templates/components/tree_macros.html.twig index e82cd3b4..673b8108 100644 --- a/templates/components/tree_macros.html.twig +++ b/templates/components/tree_macros.html.twig @@ -20,9 +20,14 @@ {% for source in data_sources %} {% if source[3] %} {# show_condition #} -
  • +
  • + +
  • {% endif %} {% endfor %} {% endmacro %} diff --git a/templates/form/datasource_synonyms_collection.html.twig b/templates/form/datasource_synonyms_collection.html.twig new file mode 100644 index 00000000..8b759791 --- /dev/null +++ b/templates/form/datasource_synonyms_collection.html.twig @@ -0,0 +1,48 @@ +{% block datasource_synonyms_collection_widget %} + {% set _attrs = attr|default({}) %} + {% set _attrs = _attrs|merge({ + class: (_attrs.class|default('') ~ ' datasource-synonyms-collection-widget')|trim + }) %} + + {% set has_proto = prototype is defined %} + {% if has_proto %} + {% set __proto %} +
    + {{ form_widget(prototype) }} +
    + +
    +
    + {% endset %} + {% set _proto_html = __proto|e('html_attr') %} + {% set _proto_name = form.vars.prototype_name|default('__name__') %} + {% set _index = form|length %} + {% endif %} + +
    +
    + {% for child in form %} +
    + {{ form_widget(child) }} +
    + +
    +
    + {% endfor %} +
    + +
    +{% endblock %} diff --git a/templates/form/permission_layout.html.twig b/templates/form/permission_layout.html.twig index 07ae6c49..aefd9edd 100644 --- a/templates/form/permission_layout.html.twig +++ b/templates/form/permission_layout.html.twig @@ -21,7 +21,7 @@ {% set dataSource = 'project' %} {% endif %} - {% set dataSourceName = get_data_source_name(dataSource, form.vars.label) %} + {% set dataSourceName = get_data_source_name_plural(dataSource, form.vars.label) %} {% set translatedSource = form.vars.label|trans %} {% if dataSourceName != translatedSource %} {{ translatedSource }} diff --git a/translations/messages.cs.xlf b/translations/messages.cs.xlf index 8757d6c7..cefc9bb3 100644 --- a/translations/messages.cs.xlf +++ b/translations/messages.cs.xlf @@ -12828,7 +12828,7 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz settings.system.data_source_synonyms.configuration.help - Definujte vlastní synonyma pro dané zdroje dat. Očekává se formát JSON s vašimi preferovanými jazykovými ISO kódy. Příklad: %format%. + Definujte vlastní synonyma pro zadané zdroje dat. Volně přidávat zdroj dat, jazyk a překlady; Jazyky, které se nepoužívají, zůstávají prázdné. @@ -12867,6 +12867,42 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz Projekt + + + settings.behavior.data_source_synonyms.collection.add_entry + Přidat položku + + + + + settings.behavior.data_source_synonyms.collection.remove_entry + Odebrat položku + + + + + settings.behavior.data_source_synonyms.row_type.form.datasource + Zdroj dat + + + + + settings.behavior.data_source_synonyms.row_type.form.locale + Místní nastavení + + + + + settings.behavior.data_source_synonyms.row_type.form.translation_singular + Překlad (jednotné číslo) + + + + + settings.behavior.data_source_synonyms.row_type.form.translation_plural + Překlad (množné číslo) + + settings.system.privacy diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 3fb6d28b..646281a5 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -12896,7 +12896,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön settings.system.data_source_synonyms.configuration.help - Definieren Sie Ihre eigenen Synonyme für die angegebenen Datenquellen. Erwartet wird ein JSON-Format mit Ihren bevorzugten Sprache-ISO-Codes. Beispiel: %format%. + Definieren Sie Ihre eigenen Synonyme für die angegebenen Datenquellen. Datenquelle, Sprache und Übersetzungen frei hinzufügen; Nicht verwendete Sprachen bleiben leer. @@ -12935,6 +12935,42 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön Projekt + + + settings.behavior.data_source_synonyms.collection.add_entry + Eintrag hinzufügen + + + + + settings.behavior.data_source_synonyms.collection.remove_entry + Eintrag entfernen + + + + + settings.behavior.data_source_synonyms.row_type.form.datasource + Datenquelle + + + + + settings.behavior.data_source_synonyms.row_type.form.locale + Sprache + + + + + settings.behavior.data_source_synonyms.row_type.form.translation_singular + Übersetzung (Singular) + + + + + settings.behavior.data_source_synonyms.row_type.form.translation_plural + Übersetzung (Plural) + + settings.system.privacy diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index ad772b12..5d2c404e 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12897,7 +12897,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.system.data_source_synonyms.configuration.help - Define your own synonyms for the given data sources. Expected in JSON-format with your preferred language iso-codes. Example: %format%. + Define your own synonyms for the given data sources. Add data source, language and translations freely; unused languages remain empty. @@ -12936,6 +12936,42 @@ Please note, that you can not impersonate a disabled user. If you try you will g Project + + + settings.behavior.data_source_synonyms.collection.add_entry + Add entry + + + + + settings.behavior.data_source_synonyms.collection.remove_entry + Remove entry + + + + + settings.behavior.data_source_synonyms.row_type.form.datasource + Data source + + + + + settings.behavior.data_source_synonyms.row_type.form.locale + Locale + + + + + settings.behavior.data_source_synonyms.row_type.form.translation_singular + Translation singular + + + + + settings.behavior.data_source_synonyms.row_type.form.translation_plural + Translation plural + + settings.system.privacy diff --git a/translations/validators.cs.xlf b/translations/validators.cs.xlf index c298266a..5f1f9a54 100644 --- a/translations/validators.cs.xlf +++ b/translations/validators.cs.xlf @@ -365,5 +365,17 @@ Neplatný kód. Zkontrolujte, zda je vaše ověřovací aplikace správně nastavena a zda je čas správně nastaven jak na serveru, tak na ověřovacím zařízení. + + + settings.system.data_source_synonyms.row_type.value_not_blank + Tato hodnota nemůže být prázdná. + + + + + settings.system.data_source_synonyms.collection_type.duplicate + Dvojitá kombinace zdroje dat a jazyka. + + diff --git a/translations/validators.de.xlf b/translations/validators.de.xlf index 5cccd388..5d226d6a 100644 --- a/translations/validators.de.xlf +++ b/translations/validators.de.xlf @@ -365,5 +365,17 @@ Ungültiger Code. Überprüfen Sie, ob die Authenticator App korrekt eingerichtet ist und ob der Server und das Gerät beide die korrekte Uhrzeit eingestellt haben. + + + settings.system.data_source_synonyms.row_type.value_not_blank + Dieser Wert darf nicht leer sein. + + + + + settings.system.data_source_synonyms.collection_type.duplicate + Doppelte Kombination aus Datenquelle und Sprache. + + diff --git a/translations/validators.en.xlf b/translations/validators.en.xlf index 6ad14460..e0f4be15 100644 --- a/translations/validators.en.xlf +++ b/translations/validators.en.xlf @@ -365,5 +365,17 @@ Invalid code. Check that your authenticator app is set up correctly and that both the server and authentication device has the time set correctly. + + + settings.system.data_source_synonyms.row_type.value_not_blank + This value should not be blank. + + + + + settings.system.data_source_synonyms.collection_type.duplicate + Duplicate combination of data source and locale. + +