mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2025-12-16 16:09:31 +00:00
Implemented the ability to set user-defined synonyms/labels for internal element types
* 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. * Anpassung aus Analyse * 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. * Ermögliche Rückgabe aller möglichen Sprachoptionen in Verbindung mit den vom Nutzer freigeschalteten. * Removed unnecessary service definition The tag is applied via autoconfiguration * Use default translations for the NotBlank constraint * Started refactoring ElementTypeNameGenerator * Made ElementTypeNameGenerator class readonly * Modified form to work properly with new datastructure * Made the form more beautiful and space saving * Made synonym form even more space saving * Allow to define overrides for any element label there is * Use defined synonyms in ElementTypeNameGenerator * Use ElementTypeNameGenerator where possible * Register synonyms for element types as global translation parameters * Revert changes done to permission layout * Use new synonym system for admin page titles * Removed now unnecessary services * Reworked settings name and translation * Renamed all files to Synonyms * Removed unnecessary translations * Removed unnecessary translations * Fixed duplicate check * Renamed synoynms translations * Use our synonyms for permission translations * Fixed phpstan issue * Added tests --------- Co-authored-by: Marcel Diegelmann <marcel.diegelmann@gmail.com> Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
This commit is contained in:
parent
5e3bd26e27
commit
54f318ecac
43 changed files with 1504 additions and 335 deletions
|
|
@ -21,12 +21,11 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Form\Type;
|
||||
namespace App\Form\Settings;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\LanguageType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
|
||||
use Symfony\Component\Intl\Languages;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
142
src/Form/Settings/TypeSynonymRowType.php
Normal file
142
src/Form/Settings/TypeSynonymRowType.php
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<?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\Settings;
|
||||
|
||||
use App\Services\ElementTypes;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
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\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* A single translation row: data source + language + translations (singular/plural).
|
||||
*/
|
||||
class TypeSynonymRowType extends AbstractType
|
||||
{
|
||||
|
||||
private const PREFERRED_TYPES = [
|
||||
ElementTypes::CATEGORY,
|
||||
ElementTypes::STORAGE_LOCATION,
|
||||
ElementTypes::FOOTPRINT,
|
||||
ElementTypes::MANUFACTURER,
|
||||
ElementTypes::SUPPLIER,
|
||||
ElementTypes::PROJECT,
|
||||
];
|
||||
|
||||
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', EnumType::class, [
|
||||
'class' => ElementTypes::class,
|
||||
'label' => false,
|
||||
'required' => true,
|
||||
'constraints' => [
|
||||
new Assert\NotBlank(),
|
||||
],
|
||||
'row_attr' => ['class' => 'mb-0'],
|
||||
'attr' => ['class' => 'form-select-sm'],
|
||||
'preferred_choices' => self::PREFERRED_TYPES
|
||||
])
|
||||
->add('locale', LocaleType::class, [
|
||||
'label' => false,
|
||||
'required' => true,
|
||||
// Restrict to languages configured in the language menu: disable ChoiceLoader and provide explicit choices
|
||||
'choice_loader' => null,
|
||||
'choices' => $this->buildLocaleChoices(true),
|
||||
'preferred_choices' => $this->getPreferredLocales(),
|
||||
'constraints' => [
|
||||
new Assert\NotBlank(),
|
||||
],
|
||||
'row_attr' => ['class' => 'mb-0'],
|
||||
'attr' => ['class' => 'form-select-sm']
|
||||
])
|
||||
->add('translation_singular', TextType::class, [
|
||||
'label' => false,
|
||||
'required' => true,
|
||||
'empty_data' => '',
|
||||
'constraints' => [
|
||||
new Assert\NotBlank(),
|
||||
],
|
||||
'row_attr' => ['class' => 'mb-0'],
|
||||
'attr' => ['class' => 'form-select-sm']
|
||||
])
|
||||
->add('translation_plural', TextType::class, [
|
||||
'label' => false,
|
||||
'required' => true,
|
||||
'empty_data' => '',
|
||||
'constraints' => [
|
||||
new Assert\NotBlank(),
|
||||
],
|
||||
'row_attr' => ['class' => 'mb-0'],
|
||||
'attr' => ['class' => 'form-select-sm']
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns only locales configured in the language menu (settings) or falls back to the parameter.
|
||||
* Format: ['German (DE)' => 'de', ...]
|
||||
*/
|
||||
private function buildLocaleChoices(bool $returnPossible = false): array
|
||||
{
|
||||
$locales = $this->getPreferredLocales();
|
||||
|
||||
if ($returnPossible) {
|
||||
$locales = $this->getPossibleLocales();
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
private function getPossibleLocales(): array
|
||||
{
|
||||
return array_values($this->preferredLanguagesParam);
|
||||
}
|
||||
}
|
||||
223
src/Form/Settings/TypeSynonymsCollectionType.php
Normal file
223
src/Form/Settings/TypeSynonymsCollectionType.php
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\Settings;
|
||||
|
||||
use App\Services\ElementTypes;
|
||||
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\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 TypeSynonymsCollectionType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly TranslatorInterface $translator)
|
||||
{
|
||||
}
|
||||
|
||||
private function flattenStructure(array $modelValue): array
|
||||
{
|
||||
//If the model is already flattened, return as is
|
||||
if (array_is_list($modelValue)) {
|
||||
return $modelValue;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($modelValue as $dataSource => $locales) {
|
||||
if (!is_array($locales)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($locales as $locale => $translations) {
|
||||
if (!is_array($translations)) {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
//Convert string to enum value
|
||||
'dataSource' => ElementTypes::from($dataSource),
|
||||
'locale' => $locale,
|
||||
'translation_singular' => $translations['singular'] ?? '',
|
||||
'translation_plural' => $translations['plural'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
|
||||
//Flatten the structure
|
||||
$data = $event->getData();
|
||||
$event->setData($this->flattenStructure($data));
|
||||
});
|
||||
|
||||
$builder->addModelTransformer(new CallbackTransformer(
|
||||
// Model -> View
|
||||
$this->flattenStructure(...),
|
||||
// View -> Model (keep list; let existing behavior unchanged)
|
||||
function (array $viewValue) {
|
||||
//Turn our flat list back into the structured array
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($viewValue as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$dataSource = $row['dataSource'] ?? null;
|
||||
$locale = $row['locale'] ?? null;
|
||||
$translation_singular = $row['translation_singular'] ?? null;
|
||||
$translation_plural = $row['translation_plural'] ?? null;
|
||||
|
||||
if ($dataSource === null ||
|
||||
!is_string($locale) || $locale === ''
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[$dataSource->value][$locale] = [
|
||||
'singular' => is_string($translation_singular) ? $translation_singular : '',
|
||||
'plural' => is_string($translation_plural) ? $translation_plural : '',
|
||||
];
|
||||
}
|
||||
|
||||
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 ($ds !== null && is_string($loc) && $loc !== '') {
|
||||
$key = $ds->value . '|' . $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.synonyms.type_synonyms.collection_type.duplicate',
|
||||
[], 'validators'
|
||||
))
|
||||
);
|
||||
}
|
||||
if ($child->has('locale')) {
|
||||
$child->get('locale')->addError(
|
||||
new FormError($this->translator->trans(
|
||||
'settings.synonyms.type_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 = $a['dataSource']->value ?? '';
|
||||
$bDs = $b['dataSource']->value ?? '';
|
||||
|
||||
$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
|
||||
{
|
||||
|
||||
// Defaults for the collection and entry type
|
||||
$resolver->setDefaults([
|
||||
'entry_type' => TypeSynonymRowType::class,
|
||||
'allow_add' => true,
|
||||
'allow_delete' => true,
|
||||
'by_reference' => false,
|
||||
'required' => false,
|
||||
'prototype' => true,
|
||||
'empty_data' => [],
|
||||
'entry_options' => ['label' => false],
|
||||
]);
|
||||
}
|
||||
|
||||
public function getParent(): ?string
|
||||
{
|
||||
return CollectionType::class;
|
||||
}
|
||||
|
||||
public function getBlockPrefix(): string
|
||||
{
|
||||
return 'type_synonyms_collection';
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
|||
|
||||
/**
|
||||
* A locale select field that uses the preferred languages from the configuration.
|
||||
|
||||
*/
|
||||
class LocaleSelectType extends AbstractType
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue