diff --git a/assets/controllers/pages/synonyms_collection_controller.js b/assets/controllers/pages/synonyms_collection_controller.js new file mode 100644 index 000000000..6b2f48110 --- /dev/null +++ b/assets/controllers/pages/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/translation.yaml b/config/packages/translation.yaml index cbc1cd7e5..a3f529e31 100644 --- a/config/packages/translation.yaml +++ b/config/packages/translation.yaml @@ -1,7 +1,7 @@ framework: default_locale: 'en' # Just enable the locales we need for performance reasons. - enabled_locale: '%partdb.locale_menu%' + enabled_locale: ['en', 'de', 'it', 'fr', 'ru', 'ja', 'cs', 'da', 'zh', 'pl'] translator: default_path: '%kernel.project_dir%/translations' fallbacks: diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 674aa3177..95ae4f3b1 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,6 +1,6 @@ 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/synonyms_collection.html.twig'] paths: '%kernel.project_dir%/assets/css': css @@ -20,4 +20,4 @@ twig: when@test: twig: - strict_variables: true \ No newline at end of file + strict_variables: true diff --git a/config/permissions.yaml b/config/permissions.yaml index 7acee7f0e..5adfb79d6 100644 --- a/config/permissions.yaml +++ b/config/permissions.yaml @@ -18,7 +18,7 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co parts: # e.g. this maps to perms_parts in User/Group database group: "data" - label: "perm.parts" + label: "{{part}}" operations: # Here are all possible operations are listed => the op name is mapped to bit value read: label: "perm.read" @@ -71,7 +71,7 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co storelocations: &PART_CONTAINING - label: "perm.storelocations" + label: "{{storage_location}}" group: "data" operations: read: @@ -103,39 +103,39 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co footprints: <<: *PART_CONTAINING - label: "perm.part.footprints" + label: "{{footprint}}" categories: <<: *PART_CONTAINING - label: "perm.part.categories" + label: "{{category}}" suppliers: <<: *PART_CONTAINING - label: "perm.part.supplier" + label: "{{supplier}}" manufacturers: <<: *PART_CONTAINING - label: "perm.part.manufacturers" + label: "{{manufacturer}}" projects: <<: *PART_CONTAINING - label: "perm.projects" + label: "{{project}}" attachment_types: <<: *PART_CONTAINING - label: "perm.part.attachment_types" + label: "{{attachment_type}}" currencies: <<: *PART_CONTAINING - label: "perm.currencies" + label: "{{currency}}" measurement_units: <<: *PART_CONTAINING - label: "perm.measurement_units" + label: "{{measurement_unit}}" part_custom_states: <<: *PART_CONTAINING - label: "perm.part_custom_states" + label: "{{part_custom_state}}" tools: label: "perm.part.tools" @@ -377,4 +377,4 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co manage_tokens: label: "perm.api.manage_tokens" alsoSet: ['access_api'] - apiTokenRole: ROLE_API_FULL \ No newline at end of file + apiTokenRole: ROLE_API_FULL diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php index 3479cf849..15c945f6a 100644 --- a/src/Controller/SettingsController.php +++ b/src/Controller/SettingsController.php @@ -64,7 +64,7 @@ public function systemSettings(Request $request, TagAwareCacheInterface $cache): $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', 'synonyms']); $this->addFlash('success', t('settings.flash.saved')); } diff --git a/src/EventListener/RegisterSynonymsAsTranslationParametersListener.php b/src/EventListener/RegisterSynonymsAsTranslationParametersListener.php new file mode 100644 index 000000000..b216aad45 --- /dev/null +++ b/src/EventListener/RegisterSynonymsAsTranslationParametersListener.php @@ -0,0 +1,93 @@ +. + */ + +declare(strict_types=1); + + +namespace App\EventListener; + +use App\Services\ElementTypeNameGenerator; +use App\Services\ElementTypes; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\Translation\Translator; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; +use Symfony\Contracts\Cache\TagAwareCacheInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +#[AsEventListener] +readonly class RegisterSynonymsAsTranslationParametersListener +{ + private Translator $translator; + + public function __construct( + #[Autowire(service: 'translator.default')] TranslatorInterface $translator, + private TagAwareCacheInterface $cache, + private ElementTypeNameGenerator $typeNameGenerator) + { + if (!$translator instanceof Translator) { + throw new \RuntimeException('Translator must be an instance of Symfony\Component\Translation\Translator or this listener cannot be used.'); + } + $this->translator = $translator; + } + + public function getSynonymPlaceholders(): array + { + return $this->cache->get('partdb_synonym_placeholders', function (ItemInterface $item) { + $item->tag('synonyms'); + + + $placeholders = []; + + //Generate a placeholder for each element type + foreach (ElementTypes::cases() as $elementType) { + //We have a placeholder for singular + $placeholders['{' . $elementType->value . '}'] = $this->typeNameGenerator->typeLabel($elementType); + //We have a placeholder for plural + $placeholders['{{' . $elementType->value . '}}'] = $this->typeNameGenerator->typeLabelPlural($elementType); + + //And we have lowercase versions for both + $placeholders['[' . $elementType->value . ']'] = mb_strtolower($this->typeNameGenerator->typeLabel($elementType)); + $placeholders['[[' . $elementType->value . ']]'] = mb_strtolower($this->typeNameGenerator->typeLabelPlural($elementType)); + } + + return $placeholders; + }); + } + + public function __invoke(RequestEvent $event): void + { + //If we already added the parameters, skip adding them again + if (isset($this->translator->getGlobalParameters()['@@partdb_synonyms_registered@@'])) { + return; + } + + //Register all placeholders for synonyms + $placeholders = $this->getSynonymPlaceholders(); + foreach ($placeholders as $key => $value) { + $this->translator->addGlobalParameter($key, $value); + } + + //Register the marker parameter to avoid double registration + $this->translator->addGlobalParameter('@@partdb_synonyms_registered@@', 'registered'); + } +} diff --git a/src/Form/Type/LanguageMenuEntriesType.php b/src/Form/Settings/LanguageMenuEntriesType.php similarity index 95% rename from src/Form/Type/LanguageMenuEntriesType.php rename to src/Form/Settings/LanguageMenuEntriesType.php index a3bba77f5..9bc2e850b 100644 --- a/src/Form/Type/LanguageMenuEntriesType.php +++ b/src/Form/Settings/LanguageMenuEntriesType.php @@ -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; diff --git a/src/Form/Settings/TypeSynonymRowType.php b/src/Form/Settings/TypeSynonymRowType.php new file mode 100644 index 000000000..332db907a --- /dev/null +++ b/src/Form/Settings/TypeSynonymRowType.php @@ -0,0 +1,142 @@ +. + */ + +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); + } +} diff --git a/src/Form/Settings/TypeSynonymsCollectionType.php b/src/Form/Settings/TypeSynonymsCollectionType.php new file mode 100644 index 000000000..4756930ad --- /dev/null +++ b/src/Form/Settings/TypeSynonymsCollectionType.php @@ -0,0 +1,223 @@ +. + */ + +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'; + } +} diff --git a/src/Form/Type/LocaleSelectType.php b/src/Form/Type/LocaleSelectType.php index d47fb57fd..6dc6f9fc0 100644 --- a/src/Form/Type/LocaleSelectType.php +++ b/src/Form/Type/LocaleSelectType.php @@ -30,7 +30,6 @@ /** * A locale select field that uses the preferred languages from the configuration. - */ class LocaleSelectType extends AbstractType { diff --git a/src/Services/ElementTypeNameGenerator.php b/src/Services/ElementTypeNameGenerator.php index deb4cf30c..19bb19f58 100644 --- a/src/Services/ElementTypeNameGenerator.php +++ b/src/Services/ElementTypeNameGenerator.php @@ -24,68 +24,31 @@ use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentContainingDBElement; -use App\Entity\Attachments\AttachmentType; use App\Entity\Base\AbstractDBElement; use App\Entity\Contracts\NamedElementInterface; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; -use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parameters\AbstractParameter; -use App\Entity\Parts\Category; -use App\Entity\Parts\Footprint; -use App\Entity\Parts\Manufacturer; -use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Part; -use App\Entity\Parts\PartAssociation; -use App\Entity\Parts\PartCustomState; use App\Entity\Parts\PartLot; -use App\Entity\Parts\StorageLocation; -use App\Entity\Parts\Supplier; -use App\Entity\PriceInformations\Currency; use App\Entity\PriceInformations\Orderdetail; use App\Entity\PriceInformations\Pricedetail; use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\ProjectBOMEntry; -use App\Entity\UserSystem\Group; -use App\Entity\UserSystem\User; use App\Exceptions\EntityNotSupportedException; +use App\Settings\SynonymSettings; use Symfony\Contracts\Translation\TranslatorInterface; /** * @see \App\Tests\Services\ElementTypeNameGeneratorTest */ -class ElementTypeNameGenerator +final readonly class ElementTypeNameGenerator { - protected array $mapping; - public function __construct(protected TranslatorInterface $translator, private readonly EntityURLGenerator $entityURLGenerator) + public function __construct( + private TranslatorInterface $translator, + private EntityURLGenerator $entityURLGenerator, + private SynonymSettings $synonymsSettings, + ) { - //Child classes has to become before parent classes - $this->mapping = [ - Attachment::class => $this->translator->trans('attachment.label'), - Category::class => $this->translator->trans('category.label'), - AttachmentType::class => $this->translator->trans('attachment_type.label'), - Project::class => $this->translator->trans('project.label'), - ProjectBOMEntry::class => $this->translator->trans('project_bom_entry.label'), - Footprint::class => $this->translator->trans('footprint.label'), - Manufacturer::class => $this->translator->trans('manufacturer.label'), - MeasurementUnit::class => $this->translator->trans('measurement_unit.label'), - Part::class => $this->translator->trans('part.label'), - PartLot::class => $this->translator->trans('part_lot.label'), - StorageLocation::class => $this->translator->trans('storelocation.label'), - Supplier::class => $this->translator->trans('supplier.label'), - Currency::class => $this->translator->trans('currency.label'), - Orderdetail::class => $this->translator->trans('orderdetail.label'), - Pricedetail::class => $this->translator->trans('pricedetail.label'), - Group::class => $this->translator->trans('group.label'), - User::class => $this->translator->trans('user.label'), - AbstractParameter::class => $this->translator->trans('parameter.label'), - LabelProfile::class => $this->translator->trans('label_profile.label'), - PartAssociation::class => $this->translator->trans('part_association.label'), - BulkInfoProviderImportJob::class => $this->translator->trans('bulk_info_provider_import_job.label'), - BulkInfoProviderImportJobPart::class => $this->translator->trans('bulk_info_provider_import_job_part.label'), - PartCustomState::class => $this->translator->trans('part_custom_state.label'), - ]; } /** @@ -99,27 +62,69 @@ public function __construct(protected TranslatorInterface $translator, private r * @return string the localized label for the entity type * * @throws EntityNotSupportedException when the passed entity is not supported + * @deprecated Use label() instead */ public function getLocalizedTypeLabel(object|string $entity): string { - $class = is_string($entity) ? $entity : $entity::class; + return $this->typeLabel($entity); + } - //Check if we have a direct array entry for our entity class, then we can use it - if (isset($this->mapping[$class])) { - return $this->mapping[$class]; - } + private function resolveSynonymLabel(ElementTypes $type, ?string $locale, bool $plural): ?string + { + $locale ??= $this->translator->getLocale(); + + if ($this->synonymsSettings->isSynonymDefinedForType($type)) { + if ($plural) { + $syn = $this->synonymsSettings->getPluralSynonymForType($type, $locale); + } else { + $syn = $this->synonymsSettings->getSingularSynonymForType($type, $locale); + } - //Otherwise iterate over array and check for inheritance (needed when the proxy element from doctrine are passed) - foreach ($this->mapping as $class_to_check => $translation) { - if (is_a($entity, $class_to_check, true)) { - return $translation; + if ($syn === null) { + //Try to fall back to english + if ($plural) { + $syn = $this->synonymsSettings->getPluralSynonymForType($type, 'en'); + } else { + $syn = $this->synonymsSettings->getSingularSynonymForType($type, 'en'); + } } + + return $syn; } - //When nothing was found throw an exception - throw new EntityNotSupportedException(sprintf('No localized label for the element with type %s was found!', is_object($entity) ? $entity::class : (string) $entity)); + return null; + } + + /** + * Gets a localized label for the type of the entity. If user defined synonyms are defined, + * these are used instead of the default labels. + * @param object|string $entity + * @param string|null $locale + * @return string + */ + public function typeLabel(object|string $entity, ?string $locale = null): string + { + $type = ElementTypes::fromValue($entity); + + return $this->resolveSynonymLabel($type, $locale, false) + ?? $this->translator->trans($type->getDefaultLabelKey(), locale: $locale); + } + + /** + * Similar to label(), but returns the plural version of the label. + * @param object|string $entity + * @param string|null $locale + * @return string + */ + public function typeLabelPlural(object|string $entity, ?string $locale = null): string + { + $type = ElementTypes::fromValue($entity); + + return $this->resolveSynonymLabel($type, $locale, true) + ?? $this->translator->trans($type->getDefaultPluralLabelKey(), locale: $locale); } + /** * Returns a string like in the format ElementType: ElementName. * For example this could be something like: "Part: BC547". @@ -134,7 +139,7 @@ public function getLocalizedTypeLabel(object|string $entity): string */ public function getTypeNameCombination(NamedElementInterface $entity, bool $use_html = false): string { - $type = $this->getLocalizedTypeLabel($entity); + $type = $this->typeLabel($entity); if ($use_html) { return '' . $type . ': ' . htmlspecialchars($entity->getName()); } @@ -144,7 +149,7 @@ public function getTypeNameCombination(NamedElementInterface $entity, bool $use_ /** - * Returns a HTML formatted label for the given enitity in the format "Type: Name" (on elements with a name) and + * Returns a HTML formatted label for the given entity in the format "Type: Name" (on elements with a name) and * "Type: ID" (on elements without a name). If possible the value is given as a link to the element. * @param AbstractDBElement $entity The entity for which the label should be generated * @param bool $include_associated If set to true, the associated entity (like the part belonging to a part lot) is included in the label to give further information @@ -165,7 +170,7 @@ public function formatLabelHTMLForEntity(AbstractDBElement $entity, bool $includ } else { //Target does not have a name $tmp = sprintf( '%s: %s', - $this->getLocalizedTypeLabel($entity), + $this->typeLabel($entity), $entity->getID() ); } @@ -209,7 +214,7 @@ public function formatElementDeletedHTML(string $class, int $id): string { return sprintf( '%s: %s [%s]', - $this->getLocalizedTypeLabel($class), + $this->typeLabel($class), $id, $this->translator->trans('log.target_deleted') ); diff --git a/src/Services/ElementTypes.php b/src/Services/ElementTypes.php new file mode 100644 index 000000000..6ce8f8514 --- /dev/null +++ b/src/Services/ElementTypes.php @@ -0,0 +1,229 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services; + +use App\Entity\Attachments\Attachment; +use App\Entity\Attachments\AttachmentType; +use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob; +use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; +use App\Entity\LabelSystem\LabelProfile; +use App\Entity\Parameters\AbstractParameter; +use App\Entity\Parts\Category; +use App\Entity\Parts\Footprint; +use App\Entity\Parts\Manufacturer; +use App\Entity\Parts\MeasurementUnit; +use App\Entity\Parts\Part; +use App\Entity\Parts\PartAssociation; +use App\Entity\Parts\PartCustomState; +use App\Entity\Parts\PartLot; +use App\Entity\Parts\StorageLocation; +use App\Entity\Parts\Supplier; +use App\Entity\PriceInformations\Currency; +use App\Entity\PriceInformations\Orderdetail; +use App\Entity\PriceInformations\Pricedetail; +use App\Entity\ProjectSystem\Project; +use App\Entity\ProjectSystem\ProjectBOMEntry; +use App\Entity\UserSystem\Group; +use App\Entity\UserSystem\User; +use App\Exceptions\EntityNotSupportedException; +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +enum ElementTypes: string implements TranslatableInterface +{ + case ATTACHMENT = "attachment"; + case CATEGORY = "category"; + case ATTACHMENT_TYPE = "attachment_type"; + case PROJECT = "project"; + case PROJECT_BOM_ENTRY = "project_bom_entry"; + case FOOTPRINT = "footprint"; + case MANUFACTURER = "manufacturer"; + case MEASUREMENT_UNIT = "measurement_unit"; + case PART = "part"; + case PART_LOT = "part_lot"; + case STORAGE_LOCATION = "storage_location"; + case SUPPLIER = "supplier"; + case CURRENCY = "currency"; + case ORDERDETAIL = "orderdetail"; + case PRICEDETAIL = "pricedetail"; + case GROUP = "group"; + case USER = "user"; + case PARAMETER = "parameter"; + case LABEL_PROFILE = "label_profile"; + case PART_ASSOCIATION = "part_association"; + case BULK_INFO_PROVIDER_IMPORT_JOB = "bulk_info_provider_import_job"; + case BULK_INFO_PROVIDER_IMPORT_JOB_PART = "bulk_info_provider_import_job_part"; + case PART_CUSTOM_STATE = "part_custom_state"; + + //Child classes has to become before parent classes + private const CLASS_MAPPING = [ + Attachment::class => self::ATTACHMENT, + Category::class => self::CATEGORY, + AttachmentType::class => self::ATTACHMENT_TYPE, + Project::class => self::PROJECT, + ProjectBOMEntry::class => self::PROJECT_BOM_ENTRY, + Footprint::class => self::FOOTPRINT, + Manufacturer::class => self::MANUFACTURER, + MeasurementUnit::class => self::MEASUREMENT_UNIT, + Part::class => self::PART, + PartLot::class => self::PART_LOT, + StorageLocation::class => self::STORAGE_LOCATION, + Supplier::class => self::SUPPLIER, + Currency::class => self::CURRENCY, + Orderdetail::class => self::ORDERDETAIL, + Pricedetail::class => self::PRICEDETAIL, + Group::class => self::GROUP, + User::class => self::USER, + AbstractParameter::class => self::PARAMETER, + LabelProfile::class => self::LABEL_PROFILE, + PartAssociation::class => self::PART_ASSOCIATION, + BulkInfoProviderImportJob::class => self::BULK_INFO_PROVIDER_IMPORT_JOB, + BulkInfoProviderImportJobPart::class => self::BULK_INFO_PROVIDER_IMPORT_JOB_PART, + PartCustomState::class => self::PART_CUSTOM_STATE, + ]; + + /** + * Gets the default translation key for the label of the element type (singular form). + */ + public function getDefaultLabelKey(): string + { + return match ($this) { + self::ATTACHMENT => 'attachment.label', + self::CATEGORY => 'category.label', + self::ATTACHMENT_TYPE => 'attachment_type.label', + self::PROJECT => 'project.label', + self::PROJECT_BOM_ENTRY => 'project_bom_entry.label', + self::FOOTPRINT => 'footprint.label', + self::MANUFACTURER => 'manufacturer.label', + self::MEASUREMENT_UNIT => 'measurement_unit.label', + self::PART => 'part.label', + self::PART_LOT => 'part_lot.label', + self::STORAGE_LOCATION => 'storelocation.label', + self::SUPPLIER => 'supplier.label', + self::CURRENCY => 'currency.label', + self::ORDERDETAIL => 'orderdetail.label', + self::PRICEDETAIL => 'pricedetail.label', + self::GROUP => 'group.label', + self::USER => 'user.label', + self::PARAMETER => 'parameter.label', + self::LABEL_PROFILE => 'label_profile.label', + self::PART_ASSOCIATION => 'part_association.label', + self::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.label', + self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => 'bulk_info_provider_import_job_part.label', + self::PART_CUSTOM_STATE => 'part_custom_state.label', + }; + } + + public function getDefaultPluralLabelKey(): string + { + return match ($this) { + self::ATTACHMENT => 'attachment.labelp', + self::CATEGORY => 'category.labelp', + self::ATTACHMENT_TYPE => 'attachment_type.labelp', + self::PROJECT => 'project.labelp', + self::PROJECT_BOM_ENTRY => 'project_bom_entry.labelp', + self::FOOTPRINT => 'footprint.labelp', + self::MANUFACTURER => 'manufacturer.labelp', + self::MEASUREMENT_UNIT => 'measurement_unit.labelp', + self::PART => 'part.labelp', + self::PART_LOT => 'part_lot.labelp', + self::STORAGE_LOCATION => 'storelocation.labelp', + self::SUPPLIER => 'supplier.labelp', + self::CURRENCY => 'currency.labelp', + self::ORDERDETAIL => 'orderdetail.labelp', + self::PRICEDETAIL => 'pricedetail.labelp', + self::GROUP => 'group.labelp', + self::USER => 'user.labelp', + self::PARAMETER => 'parameter.labelp', + self::LABEL_PROFILE => 'label_profile.labelp', + self::PART_ASSOCIATION => 'part_association.labelp', + self::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.labelp', + self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => 'bulk_info_provider_import_job_part.labelp', + self::PART_CUSTOM_STATE => 'part_custom_state.labelp', + }; + } + + /** + * Used to get a user-friendly representation of the object that can be translated. + * For this the singular default label key is used. + * @param TranslatorInterface $translator + * @param string|null $locale + * @return string + */ + public function trans(TranslatorInterface $translator, ?string $locale = null): string + { + return $translator->trans($this->getDefaultLabelKey(), locale: $locale); + } + + /** + * Determines the ElementType from a value, which can either be an enum value, an ElementTypes instance, a class name or an object instance. + * @param string|object $value + * @return self + */ + public static function fromValue(string|object $value): self + { + if ($value instanceof self) { + return $value; + } + if (is_object($value)) { + return self::fromClass($value); + } + + + //Otherwise try to parse it as enum value first + $enumValue = self::tryFrom($value); + + //Otherwise try to get it from class name + return $enumValue ?? self::fromClass($value); + } + + /** + * Determines the ElementType from a class name or object instance. + * @param string|object $class + * @throws EntityNotSupportedException if the class is not supported + * @return self + */ + public static function fromClass(string|object $class): self + { + if (is_object($class)) { + $className = get_class($class); + } else { + $className = $class; + } + + if (array_key_exists($className, self::CLASS_MAPPING)) { + return self::CLASS_MAPPING[$className]; + } + + //Otherwise we need to check for inheritance + foreach (self::CLASS_MAPPING as $entityClass => $elementType) { + if (is_a($className, $entityClass, true)) { + return $elementType; + } + } + + throw new EntityNotSupportedException(sprintf('No localized label for the element with type %s was found!', $className)); + } + +} diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php index 56064ba46..37a09b09b 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -38,6 +38,7 @@ use App\Entity\UserSystem\User; use App\Helpers\Trees\TreeViewNode; use App\Services\Cache\UserCacheKeyGenerator; +use App\Services\ElementTypeNameGenerator; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Cache\ItemInterface; @@ -50,8 +51,14 @@ */ class ToolsTreeBuilder { - public function __construct(protected TranslatorInterface $translator, protected UrlGeneratorInterface $urlGenerator, protected TagAwareCacheInterface $cache, protected UserCacheKeyGenerator $keyGenerator, protected Security $security) - { + public function __construct( + protected TranslatorInterface $translator, + protected UrlGeneratorInterface $urlGenerator, + protected TagAwareCacheInterface $cache, + protected UserCacheKeyGenerator $keyGenerator, + protected Security $security, + private readonly ElementTypeNameGenerator $elementTypeNameGenerator, + ) { } /** @@ -139,7 +146,7 @@ protected function getToolsNode(): array $this->translator->trans('info_providers.search.title'), $this->urlGenerator->generate('info_providers_search') ))->setIcon('fa-treeview fa-fw fa-solid fa-cloud-arrow-down'); - + $nodes[] = (new TreeViewNode( $this->translator->trans('info_providers.bulk_import.manage_jobs'), $this->urlGenerator->generate('bulk_info_provider_manage') @@ -160,67 +167,67 @@ protected function getEditNodes(): array if ($this->security->isGranted('read', new AttachmentType())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.attachment_types'), + $this->elementTypeNameGenerator->typeLabelPlural(AttachmentType::class), $this->urlGenerator->generate('attachment_type_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-file-alt'); } if ($this->security->isGranted('read', new Category())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.categories'), + $this->elementTypeNameGenerator->typeLabelPlural(Category::class), $this->urlGenerator->generate('category_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-tags'); } if ($this->security->isGranted('read', new Project())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.projects'), + $this->elementTypeNameGenerator->typeLabelPlural(Project::class), $this->urlGenerator->generate('project_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-archive'); } if ($this->security->isGranted('read', new Supplier())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.suppliers'), + $this->elementTypeNameGenerator->typeLabelPlural(Supplier::class), $this->urlGenerator->generate('supplier_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-truck'); } if ($this->security->isGranted('read', new Manufacturer())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.manufacturer'), + $this->elementTypeNameGenerator->typeLabelPlural(Manufacturer::class), $this->urlGenerator->generate('manufacturer_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-industry'); } if ($this->security->isGranted('read', new StorageLocation())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.storelocation'), + $this->elementTypeNameGenerator->typeLabelPlural(StorageLocation::class), $this->urlGenerator->generate('store_location_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-cube'); } if ($this->security->isGranted('read', new Footprint())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.footprint'), + $this->elementTypeNameGenerator->typeLabelPlural(Footprint::class), $this->urlGenerator->generate('footprint_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-microchip'); } if ($this->security->isGranted('read', new Currency())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.currency'), + $this->elementTypeNameGenerator->typeLabelPlural(Currency::class), $this->urlGenerator->generate('currency_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-coins'); } if ($this->security->isGranted('read', new MeasurementUnit())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.measurement_unit'), + $this->elementTypeNameGenerator->typeLabelPlural(MeasurementUnit::class), $this->urlGenerator->generate('measurement_unit_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-balance-scale'); } if ($this->security->isGranted('read', new LabelProfile())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.label_profile'), + $this->elementTypeNameGenerator->typeLabelPlural(LabelProfile::class), $this->urlGenerator->generate('label_profile_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-qrcode'); } if ($this->security->isGranted('read', new PartCustomState())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.part_custom_state'), + $this->elementTypeNameGenerator->typeLabelPlural(PartCustomState::class), $this->urlGenerator->generate('part_custom_state_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-tools'); } diff --git a/src/Services/Trees/TreeViewGenerator.php b/src/Services/Trees/TreeViewGenerator.php index 73ffa5baf..d55c87b7c 100644 --- a/src/Services/Trees/TreeViewGenerator.php +++ b/src/Services/Trees/TreeViewGenerator.php @@ -34,9 +34,9 @@ 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\ElementTypeNameGenerator; use App\Services\EntityURLGenerator; use App\Settings\BehaviorSettings\SidebarSettings; use Doctrine\ORM\EntityManagerInterface; @@ -67,6 +67,7 @@ public function __construct( protected TranslatorInterface $translator, private readonly UrlGeneratorInterface $router, private readonly SidebarSettings $sidebarSettings, + private readonly ElementTypeNameGenerator $elementTypeNameGenerator ) { $this->rootNodeEnabled = $this->sidebarSettings->rootNodeEnabled; $this->rootNodeExpandedByDefault = $this->sidebarSettings->rootNodeExpanded; @@ -212,15 +213,7 @@ protected function entityClassToRootNodeHref(string $class): ?string protected function entityClassToRootNodeString(string $class): string { - return match ($class) { - Category::class => $this->translator->trans('category.labelp'), - StorageLocation::class => $this->translator->trans('storelocation.labelp'), - Footprint::class => $this->translator->trans('footprint.labelp'), - Manufacturer::class => $this->translator->trans('manufacturer.labelp'), - Supplier::class => $this->translator->trans('supplier.labelp'), - Project::class => $this->translator->trans('project.labelp'), - default => $this->translator->trans('tree.root_node.text'), - }; + return $this->elementTypeNameGenerator->typeLabelPlural($class); } protected function entityClassToRootNodeIcon(string $class): ?string diff --git a/src/Settings/AppSettings.php b/src/Settings/AppSettings.php index 42831d087..14d9395e0 100644 --- a/src/Settings/AppSettings.php +++ b/src/Settings/AppSettings.php @@ -47,6 +47,12 @@ class AppSettings #[EmbeddedSettings()] public ?InfoProviderSettings $infoProviders = null; + #[EmbeddedSettings] + public ?SynonymSettings $synonyms = null; + #[EmbeddedSettings()] public ?MiscSettings $miscSettings = null; + + + } diff --git a/src/Settings/SynonymSettings.php b/src/Settings/SynonymSettings.php new file mode 100644 index 000000000..25fc87e9f --- /dev/null +++ b/src/Settings/SynonymSettings.php @@ -0,0 +1,116 @@ +. + */ + +declare(strict_types=1); + +namespace App\Settings; + +use App\Form\Settings\TypeSynonymsCollectionType; +use App\Services\ElementTypes; +use Jbtronics\SettingsBundle\ParameterTypes\ArrayType; +use Jbtronics\SettingsBundle\ParameterTypes\SerializeType; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(label: new TM("settings.synonyms"), description: "settings.synonyms.help")] +#[SettingsIcon("fa-language")] +class SynonymSettings +{ + use SettingsTrait; + + #[SettingsParameter( + ArrayType::class, + label: new TM("settings.synonyms.type_synonyms"), + description: new TM("settings.synonyms.type_synonyms.help"), + options: ['type' => SerializeType::class], + formType: TypeSynonymsCollectionType::class, + formOptions: [ + 'required' => false, + ], + )] + #[Assert\Type('array')] + #[Assert\All([new Assert\Type('array')])] + /** + * @var array> $typeSynonyms + * An array of the form: [ + * 'category' => [ + * 'en' => ['singular' => 'Category', 'plural' => 'Categories'], + * 'de' => ['singular' => 'Kategorie', 'plural' => 'Kategorien'], + * ], + * 'manufacturer' => [ + * 'en' => ['singular' => 'Manufacturer', 'plural' =>'Manufacturers'], + * ], + * ] + */ + public array $typeSynonyms = []; + + /** + * Checks if there is any synonym defined for the given type (no matter which language). + * @param ElementTypes $type + * @return bool + */ + public function isSynonymDefinedForType(ElementTypes $type): bool + { + return isset($this->typeSynonyms[$type->value]) && count($this->typeSynonyms[$type->value]) > 0; + } + + /** + * Returns the singular synonym for the given type and locale, or null if none is defined. + * @param ElementTypes $type + * @param string $locale + * @return string|null + */ + public function getSingularSynonymForType(ElementTypes $type, string $locale): ?string + { + return $this->typeSynonyms[$type->value][$locale]['singular'] ?? null; + } + + /** + * Returns the plural synonym for the given type and locale, or null if none is defined. + * @param ElementTypes $type + * @param string|null $locale + * @return string|null + */ + public function getPluralSynonymForType(ElementTypes $type, ?string $locale): ?string + { + return $this->typeSynonyms[$type->value][$locale]['plural'] + ?? $this->typeSynonyms[$type->value][$locale]['singular'] + ?? null; + } + + /** + * Sets a synonym for the given type and locale. + * @param ElementTypes $type + * @param string $locale + * @param string $singular + * @param string $plural + * @return void + */ + public function setSynonymForType(ElementTypes $type, string $locale, string $singular, string $plural): void + { + $this->typeSynonyms[$type->value][$locale] = [ + 'singular' => $singular, + 'plural' => $plural, + ]; + } +} diff --git a/src/Settings/SystemSettings/LocalizationSettings.php b/src/Settings/SystemSettings/LocalizationSettings.php index 7c83d1ef2..c6780c6c9 100644 --- a/src/Settings/SystemSettings/LocalizationSettings.php +++ b/src/Settings/SystemSettings/LocalizationSettings.php @@ -23,7 +23,7 @@ namespace App\Settings\SystemSettings; -use App\Form\Type\LanguageMenuEntriesType; +use App\Form\Settings\LanguageMenuEntriesType; use App\Form\Type\LocaleSelectType; use App\Settings\SettingsIcon; use Jbtronics\SettingsBundle\Metadata\EnvVarMode; diff --git a/src/Settings/SystemSettings/SystemSettings.php b/src/Settings/SystemSettings/SystemSettings.php index 71dd963d5..8cbeb560b 100644 --- a/src/Settings/SystemSettings/SystemSettings.php +++ b/src/Settings/SystemSettings/SystemSettings.php @@ -33,6 +33,8 @@ class SystemSettings #[EmbeddedSettings()] public ?LocalizationSettings $localization = null; + + #[EmbeddedSettings()] public ?CustomizationSettings $customization = null; diff --git a/src/Twig/EntityExtension.php b/src/Twig/EntityExtension.php index b5e5c3ca2..427a39b57 100644 --- a/src/Twig/EntityExtension.php +++ b/src/Twig/EntityExtension.php @@ -76,6 +76,8 @@ public function getFunctions(): array /* Gets a human readable label for the type of the given entity */ new TwigFunction('entity_type_label', fn(object|string $entity): string => $this->nameGenerator->getLocalizedTypeLabel($entity)), + new TwigFunction('type_label', fn(object|string $entity): string => $this->nameGenerator->typeLabel($entity)), + new TwigFunction('type_label_p', fn(object|string $entity): string => $this->nameGenerator->typeLabelPlural($entity)), ]; } diff --git a/templates/admin/attachment_type_admin.html.twig b/templates/admin/attachment_type_admin.html.twig index 06a8c09df..87a053afb 100644 --- a/templates/admin/attachment_type_admin.html.twig +++ b/templates/admin/attachment_type_admin.html.twig @@ -15,4 +15,4 @@ {% block new_title %} {% trans %}attachment_type.new{% endtrans %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/category_admin.html.twig b/templates/admin/category_admin.html.twig index d87cee7f4..3ddc14726 100644 --- a/templates/admin/category_admin.html.twig +++ b/templates/admin/category_admin.html.twig @@ -1,7 +1,7 @@ {% extends "admin/base_admin.html.twig" %} {% block card_title %} - {% trans %}category.labelp{% endtrans %} + {{ type_label_p(entity) }} {% endblock %} {% block additional_pills %} @@ -61,4 +61,4 @@ {{ form_row(form.eda_info.kicad_symbol) }} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/currency_admin.html.twig b/templates/admin/currency_admin.html.twig index fbd3822cd..a5d599707 100644 --- a/templates/admin/currency_admin.html.twig +++ b/templates/admin/currency_admin.html.twig @@ -3,7 +3,7 @@ {% import "vars.macro.twig" as vars %} {% block card_title %} - {% trans %}currency.caption{% endtrans %} + {{ type_label_p(entity) }} {% endblock %} {% block additional_controls %} @@ -41,4 +41,4 @@ {% block new_title %} {% trans %}currency.new{% endtrans %} -{% 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 a2c3e4afd..1ed39e9f4 100644 --- a/templates/admin/footprint_admin.html.twig +++ b/templates/admin/footprint_admin.html.twig @@ -1,7 +1,7 @@ {% extends "admin/base_admin.html.twig" %} {% block card_title %} - {% trans %}footprint.labelp{% endtrans %} + {{ type_label_p(entity) }} {% endblock %} {% block master_picture_block %} @@ -34,4 +34,4 @@ {{ form_row(form.eda_info.kicad_footprint) }} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/group_admin.html.twig b/templates/admin/group_admin.html.twig index 91975524a..831c08d58 100644 --- a/templates/admin/group_admin.html.twig +++ b/templates/admin/group_admin.html.twig @@ -1,7 +1,7 @@ {% extends "admin/base_admin.html.twig" %} {% block card_title %} - {% trans %}group.edit.caption{% endtrans %} + {{ type_label_p(entity) }} {% endblock %} @@ -27,4 +27,4 @@ {% block new_title %} {% trans %}group.new{% endtrans %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/label_profile_admin.html.twig b/templates/admin/label_profile_admin.html.twig index 10c2320f4..8702b18a6 100644 --- a/templates/admin/label_profile_admin.html.twig +++ b/templates/admin/label_profile_admin.html.twig @@ -1,7 +1,7 @@ {% extends "admin/base_admin.html.twig" %} {% block card_title %} - {% trans %}label_profile.caption{% endtrans %} + {{ type_label_p(entity) }} {% endblock %} {% block additional_pills %} @@ -58,4 +58,4 @@ {% block new_title %} {% trans %}label_profile.new{% endtrans %} -{% 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 5db892c04..4f8f1c2b0 100644 --- a/templates/admin/manufacturer_admin.html.twig +++ b/templates/admin/manufacturer_admin.html.twig @@ -1,7 +1,7 @@ {% extends "admin/base_company_admin.html.twig" %} {% block card_title %} - {% trans %}manufacturer.caption{% endtrans %} + {{ type_label_p(entity) }} {% endblock %} {% block edit_title %} @@ -10,4 +10,4 @@ {% block new_title %} {% trans %}manufacturer.new{% endtrans %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/measurement_unit_admin.html.twig b/templates/admin/measurement_unit_admin.html.twig index 317485091..14df73646 100644 --- a/templates/admin/measurement_unit_admin.html.twig +++ b/templates/admin/measurement_unit_admin.html.twig @@ -1,7 +1,7 @@ {% extends "admin/base_admin.html.twig" %} {% block card_title %} - {% trans %}measurement_unit.caption{% endtrans %} + {{ type_label_p(entity) }} {% endblock %} {% block edit_title %} diff --git a/templates/admin/part_custom_state_admin.html.twig b/templates/admin/part_custom_state_admin.html.twig index 004ceb657..9d8576468 100644 --- a/templates/admin/part_custom_state_admin.html.twig +++ b/templates/admin/part_custom_state_admin.html.twig @@ -1,7 +1,7 @@ {% extends "admin/base_admin.html.twig" %} {% block card_title %} - {% trans %}part_custom_state.caption{% endtrans %} + {{ type_label_p(entity) }} {% endblock %} {% block edit_title %} diff --git a/templates/admin/project_admin.html.twig b/templates/admin/project_admin.html.twig index 1a9950691..d199b63ce 100644 --- a/templates/admin/project_admin.html.twig +++ b/templates/admin/project_admin.html.twig @@ -3,7 +3,7 @@ {# @var entity App\Entity\ProjectSystem\Project #} {% block card_title %} - {% trans %}project.caption{% endtrans %} + {{ type_label_p(entity) }} {% endblock %} {% block edit_title %} @@ -59,4 +59,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 c93339dc1..b01ecc73a 100644 --- a/templates/admin/storelocation_admin.html.twig +++ b/templates/admin/storelocation_admin.html.twig @@ -2,7 +2,7 @@ {% import "label_system/dropdown_macro.html.twig" as dropdown %} {% block card_title %} - {% trans %}storelocation.labelp{% endtrans %} + {{ type_label_p(entity) }} {% endblock %} {% block additional_controls %} @@ -38,4 +38,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 ce38a5ca4..d0ca85aa0 100644 --- a/templates/admin/supplier_admin.html.twig +++ b/templates/admin/supplier_admin.html.twig @@ -1,7 +1,7 @@ {% extends "admin/base_company_admin.html.twig" %} {% block card_title %} - {% trans %}supplier.caption{% endtrans %} + {{ type_label_p(entity) }} {% endblock %} {% block additional_panes %} @@ -19,4 +19,4 @@ {% block new_title %} {% trans %}supplier.new{% endtrans %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/user_admin.html.twig b/templates/admin/user_admin.html.twig index 772b42d9c..9b241e562 100644 --- a/templates/admin/user_admin.html.twig +++ b/templates/admin/user_admin.html.twig @@ -5,7 +5,7 @@ {# @var entity \App\Entity\UserSystem\User #} {% block card_title %} - {% trans %}user.edit.caption{% endtrans %} + {{ type_label_p(entity) }} {% endblock %} {% block comment %}{% endblock %} @@ -111,4 +111,4 @@ {% block preview_picture %} -{% 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 366d42fe8..aaa871eaa 100644 --- a/templates/components/tree_macros.html.twig +++ b/templates/components/tree_macros.html.twig @@ -1,13 +1,15 @@ {% macro sidebar_dropdown() %} + {% set currentLocale = app.request.locale %} + {# Format is [mode, route, label, show_condition] #} {% set data_sources = [ - ['categories', path('tree_category_root'), 'category.labelp', is_granted('@categories.read') and is_granted('@parts.read')], - ['locations', path('tree_location_root'), 'storelocation.labelp', is_granted('@storelocations.read') and is_granted('@parts.read')], - ['footprints', path('tree_footprint_root'), 'footprint.labelp', is_granted('@footprints.read') and is_granted('@parts.read')], - ['manufacturers', path('tree_manufacturer_root'), 'manufacturer.labelp', is_granted('@manufacturers.read') and is_granted('@parts.read')], - ['suppliers', path('tree_supplier_root'), 'supplier.labelp', is_granted('@suppliers.read') and is_granted('@parts.read')], - ['projects', path('tree_device_root'), 'project.labelp', is_granted('@projects.read')], - ['tools', path('tree_tools'), 'tools.label', true], + ['categories', path('tree_category_root'), '@category@@', is_granted('@categories.read') and is_granted('@parts.read')], + ['locations', path('tree_location_root'), '@storage_location@@', is_granted('@storelocations.read') and is_granted('@parts.read'), ], + ['footprints', path('tree_footprint_root'), '@footprint@@', is_granted('@footprints.read') and is_granted('@parts.read')], + ['manufacturers', path('tree_manufacturer_root'), '@manufacturer@@', is_granted('@manufacturers.read') and is_granted('@parts.read'), 'manufacturer'], + ['suppliers', path('tree_supplier_root'), '@supplier@@', is_granted('@suppliers.read') and is_granted('@parts.read'), 'supplier'], + ['projects', path('tree_device_root'), '@project@@', is_granted('@projects.read'), 'project'], + ['tools', path('tree_tools'), 'tools.label', true, 'tool'], ] %} @@ -18,9 +20,20 @@ {% for source in data_sources %} {% if source[3] %} {# show_condition #} -
  • +
  • + {% if source[2] starts with '@' %} + {% set label = type_label_p(source[2]|replace({'@': ''})) %} + {% else %} + {% set label = source[2]|trans %} + {% endif %} + + +
  • {% endif %} {% endfor %} {% endmacro %} @@ -61,4 +74,4 @@
    -{% endmacro %} \ No newline at end of file +{% endmacro %} diff --git a/templates/form/synonyms_collection.html.twig b/templates/form/synonyms_collection.html.twig new file mode 100644 index 000000000..ee69dffc6 --- /dev/null +++ b/templates/form/synonyms_collection.html.twig @@ -0,0 +1,59 @@ +{% macro renderForm(child) %} +
    + {% form_theme child "form/vertical_bootstrap_layout.html.twig" %} +
    +
    {{ form_row(child.dataSource) }}
    +
    {{ form_row(child.locale) }}
    +
    {{ form_row(child.translation_singular) }}
    +
    {{ form_row(child.translation_plural) }}
    +
    + +
    +
    +
    +{% endmacro %} + +{% block type_synonyms_collection_widget %} + {% set _attrs = attr|default({}) %} + {% set _attrs = _attrs|merge({ + class: (_attrs.class|default('') ~ ' type_synonyms_collection-widget')|trim + }) %} + + {% set has_proto = prototype is defined %} + {% if has_proto %} + {% set __proto %} + {{- _self.renderForm(prototype) -}} + {% endset %} + {% set _proto_html = __proto|e('html_attr') %} + {% set _proto_name = form.vars.prototype_name|default('__name__') %} + {% set _index = form|length %} + {% endif %} + +
    +
    +
    {% trans%}settings.synonyms.type_synonym.type{% endtrans%}
    +
    {% trans%}settings.synonyms.type_synonym.language{% endtrans%}
    +
    {% trans%}settings.synonyms.type_synonym.translation_singular{% endtrans%}
    +
    {% trans%}settings.synonyms.type_synonym.translation_plural{% endtrans%}
    +
    +
    + +
    + {% for child in form %} + {{ _self.renderForm(child) }} + {% endfor %} +
    + +
    +{% endblock %} diff --git a/templates/form/vertical_bootstrap_layout.html.twig b/templates/form/vertical_bootstrap_layout.html.twig new file mode 100644 index 000000000..5f41d82ed --- /dev/null +++ b/templates/form/vertical_bootstrap_layout.html.twig @@ -0,0 +1,26 @@ +{% extends 'bootstrap_5_layout.html.twig' %} + + +{%- block choice_widget_collapsed -%} + {# Only add the BS5 form-select class if we dont use bootstrap-selectpicker #} + {# {% if attr["data-controller"] is defined and attr["data-controller"] not in ["elements--selectpicker"] %} + {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-select')|trim}) -%} + {% else %} + {# If it is an selectpicker add form-control class to fill whole width + {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-control')|trim}) -%} + {% endif %} + #} + + {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-select')|trim}) -%} + + {# If no data-controller was explictly defined add data-controller=elements--select #} + {% if attr["data-controller"] is not defined %} + {%- set attr = attr|merge({"data-controller": "elements--select"}) -%} + + {% if attr["data-empty-message"] is not defined %} + {%- set attr = attr|merge({"data-empty-message": ("selectpicker.nothing_selected"|trans)}) -%} + {% endif %} + {% endif %} + + {{- block("choice_widget_collapsed", "bootstrap_base_layout.html.twig") -}} +{%- endblock choice_widget_collapsed -%} diff --git a/templates/settings/settings.html.twig b/templates/settings/settings.html.twig index 5ddbd9004..96e0f2098 100644 --- a/templates/settings/settings.html.twig +++ b/templates/settings/settings.html.twig @@ -36,7 +36,7 @@ {% for section_widget in tab_widget %} {% set settings_object = section_widget.vars.value %} - {% if section_widget.vars.compound ?? false %} + {% if section_widget.vars.embedded_settings_metadata is defined %} {# Check if we have nested embedded settings or not #}
    diff --git a/tests/EventListener/RegisterSynonymsAsTranslationParametersTest.php b/tests/EventListener/RegisterSynonymsAsTranslationParametersTest.php new file mode 100644 index 000000000..d08edecb8 --- /dev/null +++ b/tests/EventListener/RegisterSynonymsAsTranslationParametersTest.php @@ -0,0 +1,49 @@ +. + */ + +namespace App\Tests\EventListener; + +use App\EventListener\RegisterSynonymsAsTranslationParametersListener; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +class RegisterSynonymsAsTranslationParametersTest extends KernelTestCase +{ + + private RegisterSynonymsAsTranslationParametersListener $listener; + + public function setUp(): void + { + self::bootKernel(); + $this->listener = self::getContainer()->get(RegisterSynonymsAsTranslationParametersListener::class); + } + + public function testGetSynonymPlaceholders(): void + { + $placeholders = $this->listener->getSynonymPlaceholders(); + + $this->assertIsArray($placeholders); + $this->assertSame('Part', $placeholders['{part}']); + $this->assertSame('Parts', $placeholders['{{part}}']); + //Lowercase versions: + $this->assertSame('part', $placeholders['[part]']); + $this->assertSame('parts', $placeholders['[[part]]']); + } +} diff --git a/tests/Services/ElementTypeNameGeneratorTest.php b/tests/Services/ElementTypeNameGeneratorTest.php index f99b0676b..8739dd06e 100644 --- a/tests/Services/ElementTypeNameGeneratorTest.php +++ b/tests/Services/ElementTypeNameGeneratorTest.php @@ -30,20 +30,27 @@ use App\Entity\Parts\Part; use App\Exceptions\EntityNotSupportedException; use App\Services\ElementTypeNameGenerator; +use App\Services\ElementTypes; use App\Services\Formatters\AmountFormatter; +use App\Settings\SynonymSettings; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class ElementTypeNameGeneratorTest extends WebTestCase { - /** - * @var AmountFormatter - */ - protected $service; + protected ElementTypeNameGenerator $service; + private SynonymSettings $synonymSettings; protected function setUp(): void { //Get an service instance. $this->service = self::getContainer()->get(ElementTypeNameGenerator::class); + $this->synonymSettings = self::getContainer()->get(SynonymSettings::class); + } + + protected function tearDown(): void + { + //Clean up synonym settings + $this->synonymSettings->typeSynonyms = []; } public function testGetLocalizedTypeNameCombination(): void @@ -84,4 +91,30 @@ public function getIDString(): string } }); } + + public function testTypeLabel(): void + { + //If no synonym is defined, the default label should be used + $this->assertSame('Part', $this->service->typeLabel(Part::class)); + $this->assertSame('Part', $this->service->typeLabel(new Part())); + $this->assertSame('Part', $this->service->typeLabel(ElementTypes::PART)); + $this->assertSame('Part', $this->service->typeLabel('part')); + + //Define a synonym for parts in english + $this->synonymSettings->setSynonymForType(ElementTypes::PART, 'en', 'Singular', 'Plurals'); + $this->assertSame('Singular', $this->service->typeLabel(Part::class)); + } + + public function testTypeLabelPlural(): void + { + //If no synonym is defined, the default label should be used + $this->assertSame('Parts', $this->service->typeLabelPlural(Part::class)); + $this->assertSame('Parts', $this->service->typeLabelPlural(new Part())); + $this->assertSame('Parts', $this->service->typeLabelPlural(ElementTypes::PART)); + $this->assertSame('Parts', $this->service->typeLabelPlural('part')); + + //Define a synonym for parts in english + $this->synonymSettings->setSynonymForType(ElementTypes::PART, 'en', 'Singular', 'Plurals'); + $this->assertSame('Plurals', $this->service->typeLabelPlural(Part::class)); + } } diff --git a/tests/Services/ElementTypesTest.php b/tests/Services/ElementTypesTest.php new file mode 100644 index 000000000..d4fa77ffc --- /dev/null +++ b/tests/Services/ElementTypesTest.php @@ -0,0 +1,79 @@ +. + */ + +namespace App\Tests\Services; + +use App\Entity\Parameters\CategoryParameter; +use App\Entity\Parts\Category; +use App\Exceptions\EntityNotSupportedException; +use App\Services\ElementTypes; +use PHPUnit\Framework\TestCase; + +class ElementTypesTest extends TestCase +{ + + public function testFromClass(): void + { + $this->assertSame(ElementTypes::CATEGORY, ElementTypes::fromClass(Category::class)); + $this->assertSame(ElementTypes::CATEGORY, ElementTypes::fromClass(new Category())); + + //Should also work with subclasses + $this->assertSame(ElementTypes::PARAMETER, ElementTypes::fromClass(CategoryParameter::class)); + $this->assertSame(ElementTypes::PARAMETER, ElementTypes::fromClass(new CategoryParameter())); + } + + public function testFromClassNotExisting(): void + { + $this->expectException(EntityNotSupportedException::class); + ElementTypes::fromClass(\LogicException::class); + } + + public function testFromValue(): void + { + //By enum value + $this->assertSame(ElementTypes::CATEGORY, ElementTypes::fromValue('category')); + $this->assertSame(ElementTypes::ATTACHMENT, ElementTypes::fromValue('attachment')); + + //From enum instance + $this->assertSame(ElementTypes::CATEGORY, ElementTypes::fromValue(ElementTypes::CATEGORY)); + + //From class string + $this->assertSame(ElementTypes::CATEGORY, ElementTypes::fromValue(Category::class)); + $this->assertSame(ElementTypes::PARAMETER, ElementTypes::fromValue(CategoryParameter::class)); + + //From class instance + $this->assertSame(ElementTypes::CATEGORY, ElementTypes::fromValue(new Category())); + $this->assertSame(ElementTypes::PARAMETER, ElementTypes::fromValue(new CategoryParameter())); + } + + public function testGetDefaultLabelKey(): void + { + $this->assertSame('category.label', ElementTypes::CATEGORY->getDefaultLabelKey()); + $this->assertSame('attachment.label', ElementTypes::ATTACHMENT->getDefaultLabelKey()); + } + + public function testGetDefaultPluralLabelKey(): void + { + $this->assertSame('category.labelp', ElementTypes::CATEGORY->getDefaultPluralLabelKey()); + $this->assertSame('attachment.labelp', ElementTypes::ATTACHMENT->getDefaultPluralLabelKey()); + } + + +} diff --git a/tests/Settings/SynonymSettingsTest.php b/tests/Settings/SynonymSettingsTest.php new file mode 100644 index 000000000..2d1407ac9 --- /dev/null +++ b/tests/Settings/SynonymSettingsTest.php @@ -0,0 +1,76 @@ +. + */ + +namespace App\Tests\Settings; + +use App\Services\ElementTypes; +use App\Settings\SynonymSettings; +use App\Tests\SettingsTestHelper; +use PHPUnit\Framework\TestCase; + +class SynonymSettingsTest extends TestCase +{ + + public function testGetSingularSynonymForType(): void + { + $settings = SettingsTestHelper::createSettingsDummy(SynonymSettings::class); + $settings->typeSynonyms['category'] = [ + 'en' => ['singular' => 'Category', 'plural' => 'Categories'], + 'de' => ['singular' => 'Kategorie', 'plural' => 'Kategorien'], + ]; + + $this->assertEquals('Category', $settings->getSingularSynonymForType(ElementTypes::CATEGORY, 'en')); + $this->assertEquals('Kategorie', $settings->getSingularSynonymForType(ElementTypes::CATEGORY, 'de')); + + //If no synonym is defined, it should return null + $this->assertNull($settings->getSingularSynonymForType(ElementTypes::MANUFACTURER, 'en')); + } + + public function testIsSynonymDefinedForType(): void + { + $settings = SettingsTestHelper::createSettingsDummy(SynonymSettings::class); + $settings->typeSynonyms['category'] = [ + 'en' => ['singular' => 'Category', 'plural' => 'Categories'], + 'de' => ['singular' => 'Kategorie', 'plural' => 'Kategorien'], + ]; + + $settings->typeSynonyms['supplier'] = []; + + $this->assertTrue($settings->isSynonymDefinedForType(ElementTypes::CATEGORY)); + $this->assertFalse($settings->isSynonymDefinedForType(ElementTypes::FOOTPRINT)); + $this->assertFalse($settings->isSynonymDefinedForType(ElementTypes::SUPPLIER)); + } + + public function testGetPluralSynonymForType(): void + { + $settings = SettingsTestHelper::createSettingsDummy(SynonymSettings::class); + $settings->typeSynonyms['category'] = [ + 'en' => ['singular' => 'Category', 'plural' => 'Categories'], + 'de' => ['singular' => 'Kategorie',], + ]; + + $this->assertEquals('Categories', $settings->getPluralSynonymForType(ElementTypes::CATEGORY, 'en')); + //Fallback to singular if no plural is defined + $this->assertEquals('Kategorie', $settings->getPluralSynonymForType(ElementTypes::CATEGORY, 'de')); + + //If no synonym is defined, it should return null + $this->assertNull($settings->getPluralSynonymForType(ElementTypes::MANUFACTURER, 'en')); + } +} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index a5d86338d..db4370f45 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -97,16 +97,6 @@ New category - - - Part-DB1\templates\AdminPages\CurrencyAdmin.html.twig:4 - Part-DB1\templates\AdminPages\CurrencyAdmin.html.twig:4 - - - currency.caption - Currency - - Part-DB1\templates\AdminPages\CurrencyAdmin.html.twig:12 @@ -418,16 +408,6 @@ New footprint - - - Part-DB1\templates\AdminPages\GroupAdmin.html.twig:4 - Part-DB1\templates\AdminPages\GroupAdmin.html.twig:4 - - - group.edit.caption - Groups - - Part-DB1\templates\AdminPages\GroupAdmin.html.twig:9 @@ -460,15 +440,6 @@ New group - - - Part-DB1\templates\AdminPages\LabelProfileAdmin.html.twig:4 - - - label_profile.caption - Label profiles - - Part-DB1\templates\AdminPages\LabelProfileAdmin.html.twig:8 @@ -507,17 +478,6 @@ New label profile - - - Part-DB1\templates\AdminPages\ManufacturerAdmin.html.twig:4 - Part-DB1\templates\AdminPages\ManufacturerAdmin.html.twig:4 - templates\AdminPages\ManufacturerAdmin.html.twig:4 - - - manufacturer.caption - Manufacturers - - Part-DB1\templates\AdminPages\ManufacturerAdmin.html.twig:8 @@ -538,22 +498,6 @@ New manufacturer - - - Part-DB1\templates\AdminPages\MeasurementUnitAdmin.html.twig:4 - Part-DB1\templates\AdminPages\MeasurementUnitAdmin.html.twig:4 - - - measurement_unit.caption - Measurement Unit - - - - - part_custom_state.caption - Custom part states - - Part-DB1\templates\AdminPages\StorelocationAdmin.html.twig:5 @@ -620,16 +564,6 @@ New supplier - - - Part-DB1\templates\AdminPages\UserAdmin.html.twig:8 - Part-DB1\templates\AdminPages\UserAdmin.html.twig:8 - - - user.edit.caption - Users - - Part-DB1\templates\AdminPages\UserAdmin.html.twig:14 @@ -4897,7 +4831,7 @@ If you have done this incorrectly or if a computer is no longer trusted, you can Measurement Unit - + part.table.partCustomState Custom part state @@ -5767,7 +5701,7 @@ If you have done this incorrectly or if a computer is no longer trusted, you can Measuring unit - + part.edit.partCustomState Custom part state @@ -6060,7 +5994,7 @@ If you have done this incorrectly or if a computer is no longer trusted, you can Measurement unit - + part_custom_state.label Custom part state @@ -6309,7 +6243,7 @@ If you have done this incorrectly or if a computer is no longer trusted, you can Measurement Unit - + tree.tools.edit.part_custom_state Custom part states @@ -7724,16 +7658,6 @@ Element 1 -> Element 1.2]]> System - - - obsolete - obsolete - - - perm.parts - Parts - - obsolete @@ -7994,16 +7918,6 @@ Element 1 -> Element 1.2]]> Orders - - - obsolete - obsolete - - - perm.storelocations - Storage locations - - obsolete @@ -8024,66 +7938,6 @@ Element 1 -> Element 1.2]]> List parts - - - obsolete - obsolete - - - perm.part.footprints - Footprints - - - - - obsolete - obsolete - - - perm.part.categories - Categories - - - - - obsolete - obsolete - - - perm.part.supplier - Suppliers - - - - - obsolete - obsolete - - - perm.part.manufacturers - Manufacturers - - - - - obsolete - obsolete - - - perm.projects - Projects - - - - - obsolete - obsolete - - - perm.part.attachment_types - Attachment types - - obsolete @@ -8594,12 +8448,6 @@ Element 1 -> Element 1.2]]> Measurement unit - - - perm.part_custom_states - Custom part state - - obsolete @@ -10995,7 +10843,7 @@ Element 1 -> Element 1.2]]> Measuring Unit - + log.element_edited.changed_fields.partCustomState Custom part state @@ -11265,13 +11113,13 @@ Element 1 -> Element 1.2]]> Edit Measurement Unit - + part_custom_state.new New custom part state - + part_custom_state.edit Edit custom part state @@ -14406,31 +14254,6 @@ Please note, that you can not impersonate a disabled user. If you try you will g - - - project.builds.no_bom_entries - Project has no BOM entries - - - - - settings.behavior.sidebar.data_structure_nodes_table_include_children - Tables should include children nodes by default - - - - - settings.behavior.sidebar.data_structure_nodes_table_include_children.help - If checked, the part tables for categories, footprints, etc. should include all parts of child categories. If not checked, only parts that strictly belong to the clicked node are shown. - - - - - info_providers.search.error.oauth_reconnect - You need to reconnect OAuth for following providers: %provider% -You can do this in the provider info list. - - project.builds.no_bom_entries @@ -14468,5 +14291,126 @@ You can do this in the provider info list. A PCRE-compatible regular expression every IPN has to fulfill. Leave empty to allow all everything as IPN. + + + user.labelp + Users + + + + + currency.labelp + Currencies + + + + + measurement_unit.labelp + Measurement units + + + + + attachment_type.labelp + Attachment types + + + + + label_profile.labelp + Label profiles + + + + + part_custom_state.labelp + Custom part states + + + + + group.labelp + Groups + + + + + settings.synonyms.type_synonym.type + Type + + + + + settings.synonyms.type_synonym.language + Language + + + + + settings.synonyms.type_synonym.translation_singular + Translation Singular + + + + + settings.synonyms.type_synonym.translation_plural + Translation Plural + + + + + settings.synonyms.type_synonym.add_entry + Add entry + + + + + settings.synonyms.type_synonym.remove_entry + Remove entry + + + + + settings.synonyms + Synonyms + + + + + settings.synonyms.help + The synonyms systems allow overriding how Part-DB call certain things. This can be useful, especially if Part-DB is used in a different context than electronics. +Please note that this system is currently experimental, and the synonyms defined here might not show up at all places. + + + + + settings.synonyms.type_synonyms + Type synonyms + + + + + settings.synonyms.type_synonyms.help + Type synonyms allow you to replace the labels of built-in data types. For example, you can rename "Footprint" to something else. + + + + + {{part}} + Parts + + + + + log.element_edited.changed_fields.part_ipn_prefix + IPN prefix + + + + + part.labelp + Parts + + diff --git a/translations/messages.ru.xlf b/translations/messages.ru.xlf index 9c91a4b10..0fbf7a42d 100644 --- a/translations/messages.ru.xlf +++ b/translations/messages.ru.xlf @@ -737,7 +737,7 @@ user.edit.tfa.disable_tfa_message - Это выключит <b>все активные двухфакторной способы аутентификации пользователя</b>и удалит <b>резервные коды</b>! + Это выключит <b>все активные двухфакторной способы аутентификации пользователя</b>и удалит <b>резервные коды</b>! <br> Пользователь должен будет снова настроить все методы двухфакторной аутентификации и распечатать новые резервные коды! <br><br> <b>Делайте это только в том случае, если вы абсолютно уверены в личности пользователя (обращающегося за помощью), в противном случае учетная запись может быть взломана злоумышленником!</b> @@ -3806,7 +3806,7 @@ tfa_backup.reset_codes.confirm_message - Это удалит все предыдущие коды и создаст набор новых. Это не может быть отменено. + Это удалит все предыдущие коды и создаст набор новых. Это не может быть отменено. Не забудьте распечатать новы кода и хранить их в безопасном месте! diff --git a/translations/validators.en.xlf b/translations/validators.en.xlf index 6ad144607..86045227b 100644 --- a/translations/validators.en.xlf +++ b/translations/validators.en.xlf @@ -347,13 +347,13 @@ Due to technical limitations, it is not possible to select dates after the 2038-01-19 on 32-bit systems! - + validator.fileSize.invalidFormat Invalid file size format. Use an integer number plus K, M, G as suffix for Kilo, Mega or Gigabytes. - + validator.invalid_range The given range is not valid! @@ -365,5 +365,11 @@ 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.synonyms.type_synonyms.collection_type.duplicate + There is already a translation defined for this type and language! + +