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

@ -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 <https://www.gnu.org/licenses/>.
*/
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 || '';
}
}

View file

@ -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%'

View file

@ -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.

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;
}
}

View file

@ -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 %}
<i class="fas fa-tags fa-fw"></i> {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %}
<i class="fas fa-tags fa-fw"></i>
{{ data_source_name_with_hint('category', 'category.label') }}
{% endblock %}
{% block additional_pills %}
@ -63,4 +62,4 @@
</div>
{{ form_row(form.eda_info.kicad_symbol) }}
</div>
{% endblock %}
{% endblock %}

View file

@ -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 %}
<i class="fas fa-microchip fa-fw"></i> {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %}
<i class="fas fa-microchip fa-fw"></i>
{{ data_source_name_with_hint('footprint', 'footprint.labelp') }}
{% endblock %}
{% block master_picture_block %}
@ -36,4 +35,4 @@
</div>
{{ form_row(form.eda_info.kicad_footprint) }}
</div>
{% endblock %}
{% endblock %}

View file

@ -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 %}
<i class="fas fa-industry fa-fw"></i> {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %}
<i class="fas fa-industry fa-fw"></i>
{{ data_source_name_with_hint('manufacturer', 'manufacturer.caption') }}
{% endblock %}
{% block edit_title %}
@ -12,4 +11,4 @@
{% block new_title %}
{% trans %}manufacturer.new{% endtrans %}
{% endblock %}
{% endblock %}

View file

@ -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 %}
<i class="fas fa-archive fa-fw"></i> {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %}
<i class="fas fa-archive fa-fw"></i>
{{ data_source_name_with_hint('project', 'project.labelp') }}
{% endblock %}
{% block edit_title %}
@ -61,4 +60,4 @@
</a>
{% endif %}
</div>
{% endblock %}
{% endblock %}

View file

@ -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 %}
<i class="fas fa-cube fa-fw"></i> {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %}
<i class="fas fa-cube fa-fw"></i>
{{ data_source_name_with_hint('storagelocation', 'storelocation.labelp') }}
{% endblock %}
{% block additional_controls %}
@ -40,4 +39,4 @@
{% block new_title %}
{% trans %}storelocation.new{% endtrans %}
{% endblock %}
{% endblock %}

View file

@ -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 %}
<i class="fas fa-truck fa-fw"></i> {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %}
<i class="fas fa-truck fa-fw"></i>
{{ data_source_name_with_hint('supplier', 'supplier.labelp') }}
{% endblock %}
{% block additional_panes %}
@ -21,4 +20,4 @@
{% block new_title %}
{% trans %}supplier.new{% endtrans %}
{% endblock %}
{% endblock %}

View file

@ -20,9 +20,14 @@
{% for source in data_sources %}
{% if source[3] %} {# show_condition #}
<li><button class="tree-btns dropdown-item" data-mode="{{ source[0] }}" data-url="{{ source[1] }}" data-text="{{ get_data_source_name(source[4], source[2]) }}"
{{ stimulus_action('elements/sidebar_tree', 'changeDataSource') }}
>{{ get_data_source_name(source[4], source[2]) }}</button></li>
<li>
<button class="tree-btns dropdown-item"
data-mode="{{ source[0] }}"
data-url="{{ source[1] }}"
data-text="{{ get_data_source_name_plural(source[4], source[2]) }}"
{{ stimulus_action('elements/sidebar_tree', 'changeDataSource') }}
>{{ get_data_source_name_plural(source[4], source[2]) }}</button>
</li>
{% endif %}
{% endfor %}
{% endmacro %}

View file

@ -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 %}
<div class="tc-item mb-2 border rounded p-2">
{{ form_widget(prototype) }}
<div class="mt-2">
<button type="button" class="btn btn-outline-danger btn-sm tc-remove" data-action="elements--datasource-synonyms-collection#remove">
<i class="fa fa-trash"></i> {{ 'settings.behavior.data_source_synonyms.collection.remove_entry'|trans }}
</button>
</div>
</div>
{% endset %}
{% set _proto_html = __proto|e('html_attr') %}
{% set _proto_name = form.vars.prototype_name|default('__name__') %}
{% set _index = form|length %}
{% endif %}
<div
{{ stimulus_controller('elements/datasource_synonyms_collection', {
prototype: has_proto ? _proto_html : '',
prototypeName: has_proto ? _proto_name : '__name__',
index: has_proto ? _index : (form|length)
}) }}
{{ block('widget_container_attributes')|raw }}{% for k,v in _attrs %} {{ k }}="{{ v }}"{% endfor %}
>
<div class="tc-items" data-elements--datasource-synonyms-collection-target="items">
{% for child in form %}
<div class="tc-item mb-2 border rounded p-2">
{{ form_widget(child) }}
<div class="mt-2">
<button type="button" class="btn btn-outline-danger btn-sm tc-remove" data-action="elements--datasource-synonyms-collection#remove">
<i class="fa fa-trash"></i> {{ 'settings.behavior.data_source_synonyms.collection.remove_entry'|trans }}
</button>
</div>
</div>
{% endfor %}
</div>
<button type="button" class="btn btn-outline-primary btn-sm mt-2 tc-add" data-action="elements--datasource-synonyms-collection#add">
<i class="fa fa-plus"></i> {{ 'settings.behavior.data_source_synonyms.collection.add_entry'|trans }}
</button>
</div>
{% endblock %}

View file

@ -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 }}

View file

@ -12828,7 +12828,7 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz
<unit id="lEcz6N1" name="settings.system.data_source_synonyms.configuration.help">
<segment state="translated">
<source>settings.system.data_source_synonyms.configuration.help</source>
<target>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%.</target>
<target>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é.</target>
</segment>
</unit>
<unit id="kd8nBt4" name="settings.behavior.data_source_synonyms.category">
@ -12867,6 +12867,42 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz
<target>Projekt</target>
</segment>
</unit>
<unit id="dGuR4cl" name="settings.behavior.data_source_synonyms.collection.add_entry">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.collection.add_entry</source>
<target>Přidat položku</target>
</segment>
</unit>
<unit id="nctZn5c" name="settings.behavior.data_source_synonyms.collection.remove_entry">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.collection.remove_entry</source>
<target>Odebrat položku</target>
</segment>
</unit>
<unit id="kdoci8c" name="settings.behavior.data_source_synonyms.row_type.form.datasource">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.row_type.form.datasource</source>
<target>Zdroj dat</target>
</segment>
</unit>
<unit id="cjcUeb3" name="settings.behavior.data_source_synonyms.row_type.form.locale">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.row_type.form.locale</source>
<target>Místní nastavení</target>
</segment>
</unit>
<unit id="jcueBn3" name="settings.behavior.data_source_synonyms.row_type.form.translation_singular">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.row_type.form.translation_singular</source>
<target>Překlad (jednotné číslo)</target>
</segment>
</unit>
<unit id="ncud7Rc" name="settings.behavior.data_source_synonyms.row_type.form.translation_plural">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.row_type.form.translation_plural</source>
<target>Překlad (množné číslo)</target>
</segment>
</unit>
<unit id="cvpTUeY" name="settings.system.privacy">
<segment state="translated">
<source>settings.system.privacy</source>

View file

@ -12896,7 +12896,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
<unit id="lEcz6N1" name="settings.system.data_source_synonyms.configuration.help">
<segment state="translated">
<source>settings.system.data_source_synonyms.configuration.help</source>
<target>Definieren Sie Ihre eigenen Synonyme für die angegebenen Datenquellen. Erwartet wird ein JSON-Format mit Ihren bevorzugten Sprache-ISO-Codes. Beispiel: %format%.</target>
<target>Definieren Sie Ihre eigenen Synonyme für die angegebenen Datenquellen. Datenquelle, Sprache und Übersetzungen frei hinzufügen; Nicht verwendete Sprachen bleiben leer.</target>
</segment>
</unit>
<unit id="kd8nBt4" name="settings.behavior.data_source_synonyms.category">
@ -12935,6 +12935,42 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
<target>Projekt</target>
</segment>
</unit>
<unit id="dGuR4cl" name="settings.behavior.data_source_synonyms.collection.add_entry">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.collection.add_entry</source>
<target>Eintrag hinzufügen</target>
</segment>
</unit>
<unit id="nctZn5c" name="settings.behavior.data_source_synonyms.collection.remove_entry">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.collection.remove_entry</source>
<target>Eintrag entfernen</target>
</segment>
</unit>
<unit id="kdoci8c" name="settings.behavior.data_source_synonyms.row_type.form.datasource">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.row_type.form.datasource</source>
<target>Datenquelle</target>
</segment>
</unit>
<unit id="cjcUeb3" name="settings.behavior.data_source_synonyms.row_type.form.locale">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.row_type.form.locale</source>
<target>Sprache</target>
</segment>
</unit>
<unit id="jcueBn3" name="settings.behavior.data_source_synonyms.row_type.form.translation_singular">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.row_type.form.translation_singular</source>
<target>Übersetzung (Singular)</target>
</segment>
</unit>
<unit id="ncud7Rc" name="settings.behavior.data_source_synonyms.row_type.form.translation_plural">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.row_type.form.translation_plural</source>
<target>Übersetzung (Plural)</target>
</segment>
</unit>
<unit id="cvpTUeY" name="settings.system.privacy">
<segment state="translated">
<source>settings.system.privacy</source>

View file

@ -12897,7 +12897,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<unit id="lEcz6N1" name="settings.system.data_source_synonyms.configuration.help">
<segment state="translated">
<source>settings.system.data_source_synonyms.configuration.help</source>
<target>Define your own synonyms for the given data sources. Expected in JSON-format with your preferred language iso-codes. Example: %format%.</target>
<target>Define your own synonyms for the given data sources. Add data source, language and translations freely; unused languages remain empty.</target>
</segment>
</unit>
<unit id="kd8nBt4" name="settings.behavior.data_source_synonyms.category">
@ -12936,6 +12936,42 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>Project</target>
</segment>
</unit>
<unit id="dGuR4cl" name="settings.behavior.data_source_synonyms.collection.add_entry">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.collection.add_entry</source>
<target>Add entry</target>
</segment>
</unit>
<unit id="nctZn5c" name="settings.behavior.data_source_synonyms.collection.remove_entry">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.collection.remove_entry</source>
<target>Remove entry</target>
</segment>
</unit>
<unit id="kdoci8c" name="settings.behavior.data_source_synonyms.row_type.form.datasource">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.row_type.form.datasource</source>
<target>Data source</target>
</segment>
</unit>
<unit id="cjcUeb3" name="settings.behavior.data_source_synonyms.row_type.form.locale">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.row_type.form.locale</source>
<target>Locale</target>
</segment>
</unit>
<unit id="jcueBn3" name="settings.behavior.data_source_synonyms.row_type.form.translation_singular">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.row_type.form.translation_singular</source>
<target>Translation singular</target>
</segment>
</unit>
<unit id="ncud7Rc" name="settings.behavior.data_source_synonyms.row_type.form.translation_plural">
<segment state="translated">
<source>settings.behavior.data_source_synonyms.row_type.form.translation_plural</source>
<target>Translation plural</target>
</segment>
</unit>
<unit id="cvpTUeY" name="settings.system.privacy">
<segment state="translated">
<source>settings.system.privacy</source>

View file

@ -365,5 +365,17 @@
<target>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í.</target>
</segment>
</unit>
<unit id="kduT6bD" name="settings.system.data_source_synonyms.row_type.value_not_blank">
<segment state="translated">
<source>settings.system.data_source_synonyms.row_type.value_not_blank</source>
<target>Tato hodnota nemůže být prázdná.</target>
</segment>
</unit>
<unit id="djd7Z5r" name="settings.system.data_source_synonyms.collection_type.duplicate">
<segment state="translated">
<source>settings.system.data_source_synonyms.collection_type.duplicate</source>
<target>Dvojitá kombinace zdroje dat a jazyka.</target>
</segment>
</unit>
</file>
</xliff>

View file

@ -365,5 +365,17 @@
<target>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.</target>
</segment>
</unit>
<unit id="kduT6bD" name="settings.system.data_source_synonyms.row_type.value_not_blank">
<segment state="translated">
<source>settings.system.data_source_synonyms.row_type.value_not_blank</source>
<target>Dieser Wert darf nicht leer sein.</target>
</segment>
</unit>
<unit id="djd7Z5r" name="settings.system.data_source_synonyms.collection_type.duplicate">
<segment state="translated">
<source>settings.system.data_source_synonyms.collection_type.duplicate</source>
<target>Doppelte Kombination aus Datenquelle und Sprache.</target>
</segment>
</unit>
</file>
</xliff>

View file

@ -365,5 +365,17 @@
<target>Invalid code. Check that your authenticator app is set up correctly and that both the server and authentication device has the time set correctly.</target>
</segment>
</unit>
<unit id="kduT6bD" name="settings.system.data_source_synonyms.row_type.value_not_blank">
<segment state="translated">
<source>settings.system.data_source_synonyms.row_type.value_not_blank</source>
<target>This value should not be blank.</target>
</segment>
</unit>
<unit id="djd7Z5r" name="settings.system.data_source_synonyms.collection_type.duplicate">
<segment state="translated">
<source>settings.system.data_source_synonyms.collection_type.duplicate</source>
<target>Duplicate combination of data source and locale.</target>
</segment>
</unit>
</file>
</xliff>