From ec159d7b3aace016047c9ec387907f0467fea62d Mon Sep 17 00:00:00 2001 From: Simon Kuehn Date: Mon, 18 May 2026 07:53:52 +0000 Subject: [PATCH] feat: DB-backed translations editable in admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Translation entity (locale/domain/key/value, unique on all three) - Add TranslationRepositoryInterface + DoctrineTranslationRepository - Add DatabaseTranslator decorator (#[AsDecorator]) that checks DB first and falls back to YAML files; clears per-request cache on setLocale() - Add TranslationCrudController with locale/domain filters and read-only key/locale/domain on edit to prevent accidental renames - Add "Übersetzungen / Translations" menu entry in DashboardController - Migration 20260520000000: create app.translations table - Migration 20260520010000: seed from admin.en.yaml + admin.de.yaml (204 rows) - Flatten admin.de.yaml to dot-notation; add new keys for translation CRUD Co-Authored-By: Claude Sonnet 4.6 --- config/services.yaml | 3 + migrations/Version20260520000000.php | 37 +++ migrations/Version20260520010000.php | 53 +++++ .../TranslationRepositoryInterface.php | 20 ++ src/Domain/I18n/Translation.php | 50 ++++ .../Controller/Admin/DashboardController.php | 1 + .../Admin/TranslationCrudController.php | 71 ++++++ .../DoctrineTranslationRepository.php | 51 ++++ .../Translation/DatabaseTranslator.php | 63 +++++ translations/admin.de.yaml | 221 ++++++++---------- translations/admin.en.yaml | 7 + 11 files changed, 458 insertions(+), 119 deletions(-) create mode 100644 migrations/Version20260520000000.php create mode 100644 migrations/Version20260520010000.php create mode 100644 src/Domain/I18n/Repository/TranslationRepositoryInterface.php create mode 100644 src/Domain/I18n/Translation.php create mode 100644 src/Infrastructure/Http/Controller/Admin/TranslationCrudController.php create mode 100644 src/Infrastructure/Persistence/Repository/DoctrineTranslationRepository.php create mode 100644 src/Infrastructure/Translation/DatabaseTranslator.php diff --git a/config/services.yaml b/config/services.yaml index f7f0aa5..3f95432 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -96,6 +96,9 @@ services: App\Domain\AI\Repository\PromptTemplateRepositoryInterface: alias: App\Infrastructure\Persistence\Repository\DoctrinePromptTemplateRepository + App\Domain\I18n\Repository\TranslationRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineTranslationRepository + App\Infrastructure\Console\BackupCommand: arguments: $backupDir: '%kernel.project_dir%/var/backups' diff --git a/migrations/Version20260520000000.php b/migrations/Version20260520000000.php new file mode 100644 index 0000000..0d9ef64 --- /dev/null +++ b/migrations/Version20260520000000.php @@ -0,0 +1,37 @@ +addSql(<<<'SQL' + CREATE TABLE app.translations ( + id UUID NOT NULL PRIMARY KEY, + locale VARCHAR(10) NOT NULL, + domain VARCHAR(50) NOT NULL, + translation_key VARCHAR(255) NOT NULL, + value TEXT NOT NULL, + CONSTRAINT uniq_translation UNIQUE (locale, domain, translation_key) + ) + SQL); + + $this->addSql('CREATE INDEX idx_translations_locale_domain ON app.translations (locale, domain)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE app.translations'); + } +} diff --git a/migrations/Version20260520010000.php b/migrations/Version20260520010000.php new file mode 100644 index 0000000..5084c58 --- /dev/null +++ b/migrations/Version20260520010000.php @@ -0,0 +1,53 @@ + $value) { + $this->connection->executeStatement( + 'INSERT INTO app.translations (id, locale, domain, translation_key, value) + VALUES (gen_random_uuid(), ?, ?, ?, ?) + ON CONFLICT (locale, domain, translation_key) DO NOTHING', + [$locale, 'admin', (string) $key, (string) $value] + ); + } + } + } + + public function down(Schema $schema): void + { + $this->addSql("DELETE FROM app.translations WHERE domain = 'admin'"); + } +} diff --git a/src/Domain/I18n/Repository/TranslationRepositoryInterface.php b/src/Domain/I18n/Repository/TranslationRepositoryInterface.php new file mode 100644 index 0000000..0baa883 --- /dev/null +++ b/src/Domain/I18n/Repository/TranslationRepositoryInterface.php @@ -0,0 +1,20 @@ + key → value map */ + public function findMapByLocaleAndDomain(string $locale, string $domain): array; + + /** @return list */ + public function findAll(): array; + + public function save(Translation $translation): void; + + public function remove(Translation $translation): void; +} diff --git a/src/Domain/I18n/Translation.php b/src/Domain/I18n/Translation.php new file mode 100644 index 0000000..d06569f --- /dev/null +++ b/src/Domain/I18n/Translation.php @@ -0,0 +1,50 @@ +id = Uuid::v7(); + $this->locale = $locale; + $this->domain = $domain; + $this->key = $key; + $this->value = $value; + } + + public function getId(): Uuid { return $this->id; } + public function getLocale(): string { return $this->locale; } + public function getDomain(): string { return $this->domain; } + public function getKey(): string { return $this->key; } + public function getValue(): string { return $this->value; } + + public function setLocale(string $locale): void { $this->locale = $locale; } + public function setDomain(string $domain): void { $this->domain = $domain; } + public function setKey(string $key): void { $this->key = $key; } + public function setValue(string $value): void { $this->value = $value; } +} diff --git a/src/Infrastructure/Http/Controller/Admin/DashboardController.php b/src/Infrastructure/Http/Controller/Admin/DashboardController.php index 65fe967..35dc649 100644 --- a/src/Infrastructure/Http/Controller/Admin/DashboardController.php +++ b/src/Infrastructure/Http/Controller/Admin/DashboardController.php @@ -66,6 +66,7 @@ final class DashboardController extends AbstractDashboardController yield MenuItem::linkTo(AIPipelineJobCrudController::class, $t('menu.active_jobs'), 'fa fa-robot'); yield MenuItem::linkTo(PipelineArchiveCrudController::class, $t('menu.archive'), 'fa fa-box-archive'); yield MenuItem::linkTo(PromptTemplateCrudController::class, $t('menu.ai_prompts'), 'fa fa-message'); + yield MenuItem::linkTo(TranslationCrudController::class, $t('menu.translations'), 'fa fa-language'); yield MenuItem::linkTo(UserCrudController::class, $t('menu.users'), 'fa fa-users'); yield MenuItem::linkTo(LogEntryCrudController::class, $t('menu.logs'), 'fa fa-list'); yield MenuItem::section($t('menu.section_sales')); diff --git a/src/Infrastructure/Http/Controller/Admin/TranslationCrudController.php b/src/Infrastructure/Http/Controller/Admin/TranslationCrudController.php new file mode 100644 index 0000000..bfa0bd6 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/TranslationCrudController.php @@ -0,0 +1,71 @@ + */ +final class TranslationCrudController extends AbstractCrudController +{ + public static function getEntityFqcn(): string + { + return Translation::class; + } + + public function configureCrud(Crud $crud): Crud + { + return $crud + ->setEntityLabelInSingular(new TranslatableMessage('crud.translation.singular', [], 'admin')) + ->setEntityLabelInPlural(new TranslatableMessage('crud.translation.plural', [], 'admin')) + ->setDefaultSort(['domain' => 'ASC', 'key' => 'ASC']); + } + + public function createEntity(string $entityFqcn): Translation + { + return new Translation('en', 'admin', '', ''); + } + + public function configureFields(string $pageName): iterable + { + $readonly = $pageName === Crud::PAGE_EDIT; + + yield IdField::new('id')->hideOnForm(); + + yield ChoiceField::new('locale', new TranslatableMessage('field.locale', [], 'admin')) + ->setChoices(['English' => 'en', 'Deutsch' => 'de']) + ->renderAsBadges(['en' => 'success', 'de' => 'primary']) + ->setFormTypeOption('disabled', $readonly) + ->setColumns(2); + + yield TextField::new('domain', new TranslatableMessage('field.domain', [], 'admin')) + ->setFormTypeOption('disabled', $readonly) + ->setColumns(3); + + yield TextField::new('key', new TranslatableMessage('field.translation_key', [], 'admin')) + ->setFormTypeOption('disabled', $readonly) + ->setColumns(6); + + yield TextareaField::new('value', new TranslatableMessage('field.translation_value', [], 'admin')) + ->setNumOfRows(2); + } + + public function configureFilters(Filters $filters): Filters + { + return $filters + ->add(ChoiceFilter::new('locale')->setChoices(['English' => 'en', 'Deutsch' => 'de'])) + ->add(TextFilter::new('domain')) + ->add(TextFilter::new('key')); + } +} diff --git a/src/Infrastructure/Persistence/Repository/DoctrineTranslationRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineTranslationRepository.php new file mode 100644 index 0000000..3bd8810 --- /dev/null +++ b/src/Infrastructure/Persistence/Repository/DoctrineTranslationRepository.php @@ -0,0 +1,51 @@ +em->createQuery( + 'SELECT t.key, t.value FROM App\Domain\I18n\Translation t + WHERE t.locale = :locale AND t.domain = :domain' + ) + ->setParameter('locale', $locale) + ->setParameter('domain', $domain) + ->getArrayResult(); + + $map = []; + foreach ($rows as $row) { + $map[$row['key']] = $row['value']; + } + + return $map; + } + + public function findAll(): array + { + return $this->em->createQuery( + 'SELECT t FROM App\Domain\I18n\Translation t ORDER BY t.domain ASC, t.locale ASC, t.key ASC' + )->getResult(); + } + + public function save(Translation $translation): void + { + $this->em->persist($translation); + $this->em->flush(); + } + + public function remove(Translation $translation): void + { + $this->em->remove($translation); + $this->em->flush(); + } +} diff --git a/src/Infrastructure/Translation/DatabaseTranslator.php b/src/Infrastructure/Translation/DatabaseTranslator.php new file mode 100644 index 0000000..0214d99 --- /dev/null +++ b/src/Infrastructure/Translation/DatabaseTranslator.php @@ -0,0 +1,63 @@ +> "{locale}.{domain}" → key → value */ + private array $cache = []; + + public function __construct( + #[AutowireDecorated] private readonly TranslatorInterface $inner, + private readonly TranslationRepositoryInterface $repo, + ) { + } + + public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string + { + $effectiveLocale = $locale ?? $this->getLocale(); + $effectiveDomain = $domain ?? 'messages'; + $cacheKey = $effectiveLocale.'.'.$effectiveDomain; + + if (!array_key_exists($cacheKey, $this->cache)) { + try { + $this->cache[$cacheKey] = $this->repo->findMapByLocaleAndDomain($effectiveLocale, $effectiveDomain); + } catch (\Throwable) { + $this->cache[$cacheKey] = []; + } + } + + if (array_key_exists($id, $this->cache[$cacheKey])) { + $value = $this->cache[$cacheKey][$id]; + + return $parameters !== [] ? strtr($value, $parameters) : $value; + } + + return $this->inner->trans($id, $parameters, $domain, $locale); + } + + public function getLocale(): string + { + return $this->inner instanceof LocaleAwareInterface + ? $this->inner->getLocale() + : 'en'; + } + + public function setLocale(string $locale): void + { + if ($this->inner instanceof LocaleAwareInterface) { + $this->inner->setLocale($locale); + } + // clear per-request cache so the new locale takes effect immediately + $this->cache = []; + } +} diff --git a/translations/admin.de.yaml b/translations/admin.de.yaml index 9d2b3c5..42584d0 100644 --- a/translations/admin.de.yaml +++ b/translations/admin.de.yaml @@ -1,119 +1,102 @@ -menu: - dashboard: Dashboard - ingest_article: Artikel einlesen - articles: Artikel - article_types: Artikeltypen - attributes: Attribute - section_pipeline: Pipeline - active_jobs: Aktive Jobs - archive: Archiv - ai_prompts: KI-Prompts - users: Benutzer - logs: Logs - section_sales: Verkauf - orders: Bestellungen - customers: Kunden - invoices: Rechnungen - change_password: Passwort ändern - language_en: English - language_de: Deutsch - -crud: - article: - singular: Artikel - plural: Artikel - article_type: - singular: Artikeltyp - plural: Artikeltypen - attribute: - singular: Attribut - plural: Attribute - attribute_assignment: - singular: Attributzuweisung - plural: Attributzuweisungen - customer: - singular: Kunde - plural: Kunden - order: - singular: Bestellung - plural: Bestellungen - invoice: - singular: Rechnung - plural: Rechnungen - log_entry: - singular: Log-Eintrag - plural: Log-Einträge - user: - singular: Benutzer - plural: Benutzer - pipeline_job: - singular: Pipeline-Job - plural_active: KI-Pipeline — Aktiv - plural_archive: KI-Pipeline — Archiv - prompt_template: - singular: Prompt-Vorlage - plural: Prompt-Vorlagen - -field: - id: ID - name: Name - type: Typ - unit: Einheit - options: "Optionen (eine pro Zeile)" - options_help: "Nur relevant für Typ select / multi_select." - required_attributes: Pflichtattribute - optional_attributes: Optionale Attribute - inventory_number: Inventarnr. - status: Status - step: Schritt - attempts: Versuche - started: Gestartet - error: Fehler - ai_results: KI-Ergebnisse - price: Preis - condition: Zustand - manufacturer: Hersteller - model_number: Modellnr. - serial_number: Seriennr. - condition_notes: Zustandshinweise - description: Beschreibung - ebay_description: eBay-Beschreibung - attributes: Attribute - prompt_key: Schlüssel - prompt_key_help: "Eindeutiger Bezeichner für den Prompt (z. B. specs_research)." - prompt_body: Prompt-Text - prompt_body_preview: Vorschau - last_updated: Zuletzt geändert - -action: - retry: Wiederholen - retry_confirm: "Diesen Pipeline-Job ab dem aktuellen Schritt neu einreihen?" - rerun_ai: KI neu starten - rerun_ai_confirm: "Die gesamte KI-Pipeline für diesen Artikel neu starten? Attribute und eBay-Texte werden überschrieben." - mark_as_draft: Als Entwurf markieren - activate: Aktivieren - -flash: - pipeline_job_not_found: "Kein ursprünglicher Pipeline-Job gefunden — Foto kann nicht ermittelt werden." - photo_not_found: "Gespeichertes Foto nicht gefunden unter: %path%" - pipeline_requeued: "KI-Pipeline für %label% neu gestartet — Attribute und eBay-Texte werden aktualisiert." - article_marked_draft: Artikel als Entwurf markiert. - article_missing_attributes: "Aktivierung nicht möglich: fehlende Attribute: %attrs%" - article_activated: Artikel aktiviert und zur Kanalveröffentlichung eingereicht. - job_requeued: "Job %id% ab Schritt %step% neu eingereiht." - -pipeline: - step: - vision: Foto analysiert - specs_research: Specs recherchiert - json_coding: Attribute kodiert - draft_article: Artikel erstellt - ebay_text: eBay-Texte generiert - validation: Validierung - event: - queued: "Job #%inv% zur Pipeline hinzugefügt" - processing_start: "Job #%inv% gestartet" - processing_step: "Job #%inv%: %step%" - completed: "Job #%inv% abgeschlossen ✓" - failed: "Job #%inv% fehlgeschlagen: %reason%" - needs_review: "Job #%inv% benötigt manuelle Prüfung" +menu.dashboard: Dashboard +menu.ingest_article: 'Artikel einlesen' +menu.articles: Artikel +menu.article_types: Artikeltypen +menu.attributes: Attribute +menu.section_pipeline: Pipeline +menu.active_jobs: 'Aktive Jobs' +menu.archive: Archiv +menu.ai_prompts: 'KI-Prompts' +menu.translations: Übersetzungen +menu.users: Benutzer +menu.logs: Logs +menu.section_sales: Verkauf +menu.orders: Bestellungen +menu.customers: Kunden +menu.invoices: Rechnungen +menu.change_password: 'Passwort ändern' +menu.language_en: English +menu.language_de: Deutsch +crud.article.singular: Artikel +crud.article.plural: Artikel +crud.article_type.singular: Artikeltyp +crud.article_type.plural: Artikeltypen +crud.attribute.singular: Attribut +crud.attribute.plural: Attribute +crud.attribute_assignment.singular: Attributzuweisung +crud.attribute_assignment.plural: Attributzuweisungen +crud.customer.singular: Kunde +crud.customer.plural: Kunden +crud.order.singular: Bestellung +crud.order.plural: Bestellungen +crud.invoice.singular: Rechnung +crud.invoice.plural: Rechnungen +crud.log_entry.singular: 'Log-Eintrag' +crud.log_entry.plural: 'Log-Einträge' +crud.user.singular: Benutzer +crud.user.plural: Benutzer +crud.pipeline_job.singular: 'Pipeline-Job' +crud.pipeline_job.plural_active: 'KI-Pipeline — Aktiv' +crud.pipeline_job.plural_archive: 'KI-Pipeline — Archiv' +crud.prompt_template.singular: 'Prompt-Vorlage' +crud.prompt_template.plural: 'Prompt-Vorlagen' +crud.translation.singular: Übersetzung +crud.translation.plural: Übersetzungen +field.id: ID +field.name: Name +field.type: Typ +field.unit: Einheit +field.options: 'Optionen (eine pro Zeile)' +field.options_help: 'Nur relevant für Typ select / multi_select.' +field.required_attributes: Pflichtattribute +field.optional_attributes: 'Optionale Attribute' +field.inventory_number: 'Inventarnr.' +field.status: Status +field.step: Schritt +field.attempts: Versuche +field.started: Gestartet +field.error: Fehler +field.ai_results: 'KI-Ergebnisse' +field.price: Preis +field.condition: Zustand +field.manufacturer: Hersteller +field.model_number: 'Modellnr.' +field.serial_number: 'Seriennr.' +field.condition_notes: Zustandshinweise +field.description: Beschreibung +field.ebay_description: 'eBay-Beschreibung' +field.attributes: Attribute +field.prompt_key: Schlüssel +field.prompt_key_help: 'Eindeutiger Bezeichner für den Prompt (z. B. specs_research).' +field.prompt_body: 'Prompt-Text' +field.prompt_body_preview: Vorschau +field.last_updated: 'Zuletzt geändert' +field.locale: Sprache +field.domain: Bereich +field.translation_key: Schlüssel +field.translation_value: Übersetzung +action.retry: Wiederholen +action.retry_confirm: 'Diesen Pipeline-Job ab dem aktuellen Schritt neu einreihen?' +action.rerun_ai: 'KI neu starten' +action.rerun_ai_confirm: 'Die gesamte KI-Pipeline für diesen Artikel neu starten? Attribute und eBay-Texte werden überschrieben.' +action.mark_as_draft: 'Als Entwurf markieren' +action.activate: Aktivieren +flash.pipeline_job_not_found: 'Kein ursprünglicher Pipeline-Job gefunden — Foto kann nicht ermittelt werden.' +flash.photo_not_found: 'Gespeichertes Foto nicht gefunden unter: %path%' +flash.pipeline_requeued: 'KI-Pipeline für %label% neu gestartet — Attribute und eBay-Texte werden aktualisiert.' +flash.article_marked_draft: 'Artikel als Entwurf markiert.' +flash.article_missing_attributes: 'Aktivierung nicht möglich: fehlende Attribute: %attrs%' +flash.article_activated: 'Artikel aktiviert und zur Kanalveröffentlichung eingereicht.' +flash.job_requeued: 'Job %id% ab Schritt %step% neu eingereiht.' +pipeline.step.vision: 'Foto analysiert' +pipeline.step.specs_research: 'Specs recherchiert' +pipeline.step.json_coding: 'Attribute kodiert' +pipeline.step.draft_article: 'Artikel erstellt' +pipeline.step.ebay_text: 'eBay-Texte generiert' +pipeline.step.validation: Validierung +pipeline.event.queued: 'Job #%inv% zur Pipeline hinzugefügt' +pipeline.event.processing_start: 'Job #%inv% gestartet' +pipeline.event.processing_step: 'Job #%inv%: %step%' +pipeline.event.completed: 'Job #%inv% abgeschlossen ✓' +pipeline.event.failed: 'Job #%inv% fehlgeschlagen: %reason%' +pipeline.event.needs_review: 'Job #%inv% benötigt manuelle Prüfung' diff --git a/translations/admin.en.yaml b/translations/admin.en.yaml index 08d69f0..d4531ea 100644 --- a/translations/admin.en.yaml +++ b/translations/admin.en.yaml @@ -7,6 +7,7 @@ menu.section_pipeline: Pipeline menu.active_jobs: 'Active Jobs' menu.archive: Archive menu.ai_prompts: 'AI Prompts' +menu.translations: Translations menu.users: Users menu.logs: Logs menu.section_sales: Sales @@ -39,6 +40,8 @@ crud.pipeline_job.plural_active: 'AI Pipeline — Active' crud.pipeline_job.plural_archive: 'AI Pipeline — Archive' crud.prompt_template.singular: 'Prompt Template' crud.prompt_template.plural: 'Prompt Templates' +crud.translation.singular: Translation +crud.translation.plural: Translations field.id: ID field.name: Name field.type: Type @@ -68,6 +71,10 @@ field.prompt_key_help: 'Slug identifying the prompt (e.g. specs_research