From d806dab7a810b4885c327f1d188e08d769d9dd81 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Tue, 7 Jul 2026 08:33:38 +0200 Subject: [PATCH 1/5] refactor: moved plugin settings to shopware backend --- src/Command/PullSnippetsCommand.php | 60 ++++++---- .../Listener/LoadTranslationsListener.php | 13 ++- .../Snippet/TranslationProviderResolver.php | 44 ++++++-- src/Resources/config/config.xml | 38 +++++++ src/Resources/config/services.xml | 19 ++++ src/ShopwareTranslationBridge.php | 106 +----------------- src/ShopwareTranslationBridgeConfig.php | 18 +++ 7 files changed, 160 insertions(+), 138 deletions(-) create mode 100644 src/Resources/config/config.xml create mode 100644 src/Resources/config/services.xml create mode 100644 src/ShopwareTranslationBridgeConfig.php diff --git a/src/Command/PullSnippetsCommand.php b/src/Command/PullSnippetsCommand.php index 0337830..9d6b49a 100644 --- a/src/Command/PullSnippetsCommand.php +++ b/src/Command/PullSnippetsCommand.php @@ -4,12 +4,16 @@ namespace Netlogix\ShopwareTranslationBridge\Command; +use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\TranslationProviderResolverInterface; +use Netlogix\ShopwareTranslationBridge\ShopwareTranslationBridgeConfig; use RuntimeException; use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter; use Shopware\Core\System\Language\LanguageCollection; use Shopware\Core\System\SalesChannel\SalesChannelEntity; +use Shopware\Core\System\SystemConfig\SystemConfigEntity; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -17,7 +21,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Translation\MessageCatalogue; -use Symfony\Component\Translation\Provider\TranslationProviderCollection; +use Symfony\Component\Translation\Provider\ProviderInterface; use Symfony\Component\Translation\Writer\TranslationWriterInterface; #[AsCommand('sw:snippets:pull')] @@ -27,18 +31,15 @@ class PullSnippetsCommand extends Command private const string STORAGE_DIRECTORY = 'nlx-storefront-translation'; function __construct( - #[Autowire(service: 'translation.provider_collection')] - private readonly TranslationProviderCollection $providers, + private readonly TranslationProviderResolverInterface $translationProviderResolver, private readonly EntityRepository $languageRepository, private readonly EntityRepository $salesChannelRepository, + #[Autowire(service: 'system_config.repository')] + private readonly EntityRepository $systemConfigRepository, #[Autowire(service: 'translation.writer')] private readonly TranslationWriterInterface $translationWriter, #[Autowire(param: 'translator.default_path')] - private readonly string $translatorDefaultPath, - #[Autowire(param: 'nlx_storefront_translation.default_provider')] - private readonly ?string $defaultProvider, - #[Autowire(param: 'nlx_storefront_translation.sales_channel_provider')] - private readonly array $salesChannelProviders + private readonly string $translatorDefaultPath ) { parent::__construct(); } @@ -52,9 +53,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $domainsFetched = 0; - if (is_string($this->defaultProvider) && $this->defaultProvider !== '') { + if ($this->translationProviderResolver->hasDefaultProvider()) { $domainsFetched += $this->fetchTranslations( - $this->defaultProvider, + $this->translationProviderResolver->getDefaultProvider(), self::REMOTE_DOMAIN, $this->getAllLocales(), $translationPath, @@ -62,13 +63,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); } - foreach ($this->salesChannelProviders as $salesChannelId => $providerName) { - if (!is_string($providerName)) { + foreach ($this->getSalesChannelIdsWithProviderOverride() as $salesChannelId) { + if (!$this->translationProviderResolver->hasSalesChannelProvider($salesChannelId)) { continue; } $domainsFetched += $this->fetchTranslations( - $providerName, + $this->translationProviderResolver->getSalesChannelProvider($salesChannelId), $salesChannelId, $this->getLocalesForSalesChannel($salesChannelId), $translationPath, @@ -93,23 +94,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int * @param list $locales */ private function fetchTranslations( - string $providerName, + ProviderInterface $provider, string $targetDomain, array $locales, string $translationPath, SymfonyStyle $io ): int { + $providerName = $this->describeProvider($provider); + if ($locales === []) { $io->note(sprintf('Skipping "%s" because no locales were found.', $targetDomain)); return 0; } - if (!$this->providers->has($providerName)) { - throw new RuntimeException(sprintf('Provider "%s" not found.', $providerName)); - } - - $provider = $this->providers->get($providerName); $translationBag = $provider->read([self::REMOTE_DOMAIN], $locales); $written = 0; @@ -143,6 +141,30 @@ private function fetchTranslations( return 1; } + private function describeProvider(ProviderInterface $provider): string + { + return parse_url((string) $provider, \PHP_URL_SCHEME) ?: 'unknown'; + } + + private function getSalesChannelIdsWithProviderOverride(): array + { + $criteria = new Criteria(); + $criteria->addFilter(new EqualsFilter('configurationKey', ShopwareTranslationBridgeConfig::KEY_DEFAULT_PROVIDER)); + + $result = $this->systemConfigRepository->search($criteria, Context::createCLIContext()); + + $salesChannelIds = []; + foreach ($result->getEntities() as $systemConfig) { + assert($systemConfig instanceof SystemConfigEntity); + $salesChannelId = $systemConfig->getSalesChannelId(); + if ($salesChannelId !== null) { + $salesChannelIds[$salesChannelId] = true; + } + } + + return array_keys($salesChannelIds); + } + /** * @return list */ diff --git a/src/Core/System/Snippet/Listener/LoadTranslationsListener.php b/src/Core/System/Snippet/Listener/LoadTranslationsListener.php index 4c2b3d6..2a4e5ae 100644 --- a/src/Core/System/Snippet/Listener/LoadTranslationsListener.php +++ b/src/Core/System/Snippet/Listener/LoadTranslationsListener.php @@ -5,8 +5,9 @@ namespace Netlogix\ShopwareTranslationBridge\Core\System\Snippet\Listener; use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\TranslationProviderResolverInterface; +use Netlogix\ShopwareTranslationBridge\ShopwareTranslationBridgeConfig; use Shopware\Core\System\Snippet\Extension\StorefrontSnippetsExtension; -use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Shopware\Core\System\SystemConfig\SystemConfigService; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; #[AsEventListener(StorefrontSnippetsExtension::NAME . '.pre')] @@ -18,8 +19,7 @@ class LoadTranslationsListener function __construct( private readonly TranslationProviderResolverInterface $providerResolver, - #[Autowire(param: 'nlx_storefront_translation.respect_translation_files')] - private readonly bool $respectTranslationFiles + private readonly SystemConfigService $systemConfigService ) { } @@ -29,8 +29,13 @@ public function __invoke(StorefrontSnippetsExtension $extension): void return; } + $respectTranslationFiles = $this->systemConfigService->getBool( + ShopwareTranslationBridgeConfig::KEY_RESPECT_TRANSLATION_FILES, + $extension->salesChannelId + ); + // Force usage of translation files - if ($this->respectTranslationFiles) { + if ($respectTranslationFiles) { $this->respectTranslationFiles($extension); } diff --git a/src/Core/System/Snippet/TranslationProviderResolver.php b/src/Core/System/Snippet/TranslationProviderResolver.php index f5c24c4..1709a90 100644 --- a/src/Core/System/Snippet/TranslationProviderResolver.php +++ b/src/Core/System/Snippet/TranslationProviderResolver.php @@ -5,8 +5,10 @@ namespace Netlogix\ShopwareTranslationBridge\Core\System\Snippet; use InvalidArgumentException; +use Netlogix\ShopwareTranslationBridge\ShopwareTranslationBridgeConfig; use RuntimeException; use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\System\SystemConfig\SystemConfigService; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Translation\Provider\ProviderInterface; use Symfony\Component\Translation\Provider\TranslationProviderCollection; @@ -19,25 +21,27 @@ class TranslationProviderResolver implements TranslationProviderResolverInterfac function __construct( #[Autowire(service: 'translation.provider_collection')] private readonly TranslationProviderCollection $providerCollection, - #[Autowire(param: 'nlx_storefront_translation.default_provider')] - private readonly ?string $defaultProvider, - #[Autowire(param: 'nlx_storefront_translation.sales_channel_provider')] - private readonly array $providerMap + private readonly SystemConfigService $systemConfigService ) { } public function hasDefaultProvider(): bool { - return $this->defaultProvider !== null && $this->providerCollection->has($this->defaultProvider); + $providerName = $this->resolveDefaultProviderName(); + + return $providerName !== null && $this->providerCollection->has($providerName); } public function getDefaultProvider(): ProviderInterface { if (!$this->hasDefaultProvider()) { - throw new RuntimeException(\sprintf('Provider "%s" not found.', $this->defaultProvider)); + throw new RuntimeException(\sprintf('Provider "%s" not found.', $this->resolveDefaultProviderName() ?? '')); } - return $this->providers['default'] = $this->providerCollection->get($this->defaultProvider); + $providerName = $this->resolveDefaultProviderName(); + assert(is_string($providerName)); + + return $this->providers['default'] = $this->providerCollection->get($providerName); } public function hasSalesChannelProvider(string $salesChannelId): bool @@ -46,7 +50,7 @@ public function hasSalesChannelProvider(string $salesChannelId): bool throw new InvalidArgumentException(\sprintf('Provider "%s" is not a valid UUID.', $salesChannelId)); } - $providerName = $this->providerMap[$salesChannelId] ?? null; + $providerName = $this->resolveSalesChannelProviderName($salesChannelId); return $providerName !== null && $this->providerCollection->has($providerName); } @@ -57,11 +61,11 @@ public function getSalesChannelProvider(string $salesChannelId): ProviderInterfa return $this->providers[$salesChannelId]; } - if (!$this->hasProvider($salesChannelId)) { + if (!$this->hasSalesChannelProvider($salesChannelId)) { throw new RuntimeException(\sprintf('No provider for salesChannel "%s" not found.', $salesChannelId)); } - $providerName = $this->providerMap[$salesChannelId]; + $providerName = $this->resolveSalesChannelProviderName($salesChannelId); assert(is_string($providerName), 'Provider map value must be string'); return $this->providers[$salesChannelId] = $this->providerCollection->get($providerName); @@ -88,4 +92,24 @@ public function reset(): void { unset($this->providers); } + + private function resolveDefaultProviderName(): ?string + { + $providerName = $this->systemConfigService->getString(ShopwareTranslationBridgeConfig::KEY_DEFAULT_PROVIDER); + + return $providerName !== '' ? $providerName : null; + } + + private function resolveSalesChannelProviderName(string $salesChannelId): ?string + { + $config = $this->systemConfigService->getDomain( + ShopwareTranslationBridgeConfig::DOMAIN, + $salesChannelId, + false + ); + + $providerName = $config[ShopwareTranslationBridgeConfig::KEY_DEFAULT_PROVIDER] ?? null; + + return is_string($providerName) && $providerName !== '' ? $providerName : null; + } } diff --git a/src/Resources/config/config.xml b/src/Resources/config/config.xml new file mode 100644 index 0000000..fa4defe --- /dev/null +++ b/src/Resources/config/config.xml @@ -0,0 +1,38 @@ + + + + Translation Bridge + Translation Bridge + + + defaultProvider + + + + Name of the configured Symfony Translation provider DSN (e.g. "tolgee"). Select a sales + channel above to override the provider for that sales channel only. + + + Name des konfigurierten Symfony-Translation-Provider-DSN (z. B. "tolgee"). Wählen Sie oben + einen Verkaufskanal aus, um den Provider nur für diesen Verkaufskanal zu überschreiben. + + tolgee + + + + respectTranslationFiles + + + + When enabled, existing local snippet/translation files always take precedence over the + configured translation provider. + + + Wenn aktiviert, haben vorhandene lokale Snippet-/Übersetzungsdateien immer Vorrang vor dem + konfigurierten Übersetzungsanbieter. + + true + + + diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml new file mode 100644 index 0000000..7338c9f --- /dev/null +++ b/src/Resources/config/services.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/src/ShopwareTranslationBridge.php b/src/ShopwareTranslationBridge.php index 1a94b9c..a7c9ae0 100644 --- a/src/ShopwareTranslationBridge.php +++ b/src/ShopwareTranslationBridge.php @@ -4,112 +4,8 @@ namespace Netlogix\ShopwareTranslationBridge; -use Netlogix\ShopwareTranslationBridge\Core\Framework\Adapter\Translator\TranslationCacheInvalidation; -use Netlogix\ShopwareTranslationBridge\Core\Framework\Adapter\Translator\TranslationCacheInvalidationInterface; -use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\SalesChannelTranslationRefresher; -use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\SalesChannelTranslationRefresherInterface; -use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\TranslationProviderResolver; -use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\TranslationProviderResolverInterface; -use Override; use Shopware\Core\Framework\Plugin; -use Shopware\Core\Framework\Uuid\Uuid; -use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; -use Symfony\Component\DependencyInjection\Container; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Exception\RuntimeException; -use Symfony\Component\DependencyInjection\Extension\ConfigurableExtensionInterface; -use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; -use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; -use Symfony\Component\HttpKernel\Bundle\BundleExtension; -class ShopwareTranslationBridge extends Plugin implements ConfigurableExtensionInterface +class ShopwareTranslationBridge extends Plugin { - private string $extensionAlias; - - public function configure(DefinitionConfigurator $definition): void - { - $definition - ->rootNode() - ->children() - ->scalarNode('default_provider') - ->defaultNull() - ->end() - ->booleanNode('respect_translation_files') - ->defaultTrue() - ->end() - ->arrayNode('sales_channel_providers') - ->useAttributeAsKey('salesChannelId') - ->arrayPrototype() - ->beforeNormalization() - ->ifString() - ->then(static fn(string $v): array => ['provider' => $v]) - ->end() - ->children() - ->scalarNode('provider') - ->isRequired() - ->end() - ->end() - ->end() - ->end() - ->end(); - } - - public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void - { - } - - public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void - { - $services = $container->services()->defaults()->autowire()->autoconfigure(); - - $services->load(__NAMESPACE__ . '\\', '*'); - - $services->alias(TranslationProviderResolverInterface::class, TranslationProviderResolver::class); - $services->alias(SalesChannelTranslationRefresherInterface::class, SalesChannelTranslationRefresher::class); - $services->alias(TranslationCacheInvalidationInterface::class, TranslationCacheInvalidation::class); - - $defaultProvider = $config['default_provider'] ?? null; - assert($defaultProvider === null || is_string($defaultProvider)); - $container->parameters()->set('nlx_storefront_translation.default_provider', $defaultProvider); - - $respectTranslationFiles = $config['respect_translation_files'] ?? null; - assert(is_bool($respectTranslationFiles)); - $container->parameters()->set('nlx_storefront_translation.respect_translation_files', $respectTranslationFiles); - - $salesChannelProviders = $config['sales_channel_providers'] ?? null; - assert(is_array($salesChannelProviders)); - $container->parameters()->set( - 'nlx_storefront_translation.sales_channel_provider', - $this->processSalesChannelProviders($salesChannelProviders) - ); - } - - #[Override] - public function getContainerExtension(): ?ExtensionInterface - { - if (!isset($this->extensionAlias)) { - $this->extensionAlias = Container::underscore(preg_replace('/Bundle$/', '', $this->getName())); - } - - $this->extension ??= new BundleExtension($this, $this->extensionAlias); - - return $this->extension === false ? null : $this->extension; - } - - private function processSalesChannelProviders(array $salesChannelProviders): array - { - $providerMap = []; - foreach ($salesChannelProviders as $salesChannelId => $providerConfig) { - assert(is_string($salesChannelId)); - if (!Uuid::isValid($salesChannelId)) { - throw new RuntimeException('Invalid salesChannel UUID: ' . $salesChannelId); - } - - $providerName = $providerConfig['provider'] ?? null; - assert(is_string($providerName)); - $providerMap[$salesChannelId] = $providerName; - } - - return $providerMap; - } } diff --git a/src/ShopwareTranslationBridgeConfig.php b/src/ShopwareTranslationBridgeConfig.php new file mode 100644 index 0000000..8dd8521 --- /dev/null +++ b/src/ShopwareTranslationBridgeConfig.php @@ -0,0 +1,18 @@ + Date: Tue, 7 Jul 2026 08:34:21 +0200 Subject: [PATCH 2/5] feat: updated readme --- Readme.md | 37 +++++++++++-------------------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/Readme.md b/Readme.md index 40a8947..8770c9b 100644 --- a/Readme.md +++ b/Readme.md @@ -11,31 +11,16 @@ bin/console plugin:install --activate ShopwareTranslationBridge ## Configuration -The connection to the translation provider is configured via a DSN (Data Source Name). You need to create a configuration file, for example `config/packages/shopware_translation_bridge.yaml`, to set up the providers. - -The plugin uses the DSN from the `ShopwareTranslationBridge.config.providerDsn` system config key as a default. You can also configure a specific DSN for each sales channel. - -| option | type | default | info | -|---------------------------|----------------|---------|----------------------------------------------------------------------------------------------------| -| default_provider | `null\|string` | `null` | Service name from `framework.translator.providers`. If `null` there is no fallback provider. | -| respect_translation_files | `bool` | `true` | should it overlay the snippet files with the translation files `framework.translator.default_path` | -| sales_channel_providers | `array` | `[]` | SalesChannel specific providers. Like `default_provider` but individiual for every salesChannel | - -### Example Configuration - -Here is an example of how to configure different providers for different sales channels. - -```yaml -# config/packages/shopware_translation_bridge.yaml -shopware_translation_bridge: - # Define a default provider for all sales channels - default_provider: 'providerServiceName' - respect_translation_files: true - sales_channel_providers: - # Assign a specific provider for a sales channel by its ID - 2b919afec10730f413cb5682bbed09fd: - provider: 'providerServiceName' -``` +The Symfony Translation providers themselves (the DSNs) are still configured the usual Symfony way, e.g. in `config/packages/translation.yaml` under `framework.translator.providers`. + +Everything specific to this plugin is configured as regular **Shopware plugin settings** - no extra config file needed. Open Administration > Extensions > My extensions > Translation Bridge > Config: + +| field | type | default | info | +|---------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------| +| Default translation provider | `string` | empty | Service name from `framework.translator.providers` (e.g. `tolgee`). Empty means no fallback provider. | +| Respect local translation files | `bool` | `true` | Whether to overlay the snippet files with the translation files from `framework.translator.default_path` | + +Both fields can be overridden per sales channel using the sales channel selector at the top of that config screen - pick a sales channel, set a different "Default translation provider" (or "Respect local translation files"), and save. Leaving a sales channel's field empty falls back to the global default. ## Commands @@ -61,7 +46,7 @@ bin/console sw:snippets:push [salesChannelId1] [salesChannelId2] ### Pull Snippets -Pulls all snippets from the configured translation provider and saves them locally inside the translation directory defined by `framework.translator.default_path`. The default provider (if configured) is written to the `messages` translation domain, while every entry of `sales_channel_providers` is persisted to a domain that matches the configured sales channel id. +Pulls all snippets from the configured translation provider and saves them locally inside the translation directory defined by `framework.translator.default_path`. The default provider (if configured) is written to the `messages` translation domain, while every sales channel with an explicit provider override is persisted to a domain that matches the sales channel id. ```bash bin/console sw:snippets:pull [salesChannelId1] From 1fc193aed760f1a8b5fd1c576a29b6f1b239512c Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Wed, 8 Jul 2026 13:55:44 +0200 Subject: [PATCH 3/5] refactor: refactored shopware settings handling --- src/Command/FlushTranslationCacheCommand.php | 4 +- src/Command/PullSnippetsCommand.php | 44 +++++--------- src/Command/PushSnippetsCommand.php | 6 +- .../TranslationCacheInvalidation.php | 2 +- .../TranslationCacheInvalidationInterface.php | 2 +- .../TranslationProviderOptionsController.php | 42 +++++++++++++ .../UpdateTranslationController.php | 14 +++-- src/Core/System/RelevantLocaleResolver.php | 6 +- .../RelevantLocaleResolverInterface.php | 2 +- .../Listener/LoadTranslationsListener.php | 20 +++---- .../SalesChannelTranslationRefresher.php | 9 ++- ...esChannelTranslationRefresherInterface.php | 2 +- .../Snippet/TranslationProviderResolver.php | 21 ++----- .../TranslationProviderResolverInterface.php | 2 +- .../TranslationUpdateHandler.php | 4 +- src/Message/TranslationUpdateMessage.php | 7 +-- src/Resolver/ConfigurationResolver.php | 60 +++++++++++++++++++ .../nlx-translation-provider-select/index.js | 58 ++++++++++++++++++ .../nlx-translation-provider-select.html.twig | 11 ++++ src/Resources/app/administration/src/main.js | 5 ++ .../src/service/translationApi.Service.js | 11 ++++ src/Resources/config/config.xml | 15 ++--- src/ShopwareTranslationBridge.php | 2 +- 23 files changed, 256 insertions(+), 93 deletions(-) create mode 100644 src/Core/Framework/Api/Controller/TranslationProviderOptionsController.php create mode 100644 src/Resolver/ConfigurationResolver.php create mode 100644 src/Resources/app/administration/src/component/nlx-translation-provider-select/index.js create mode 100644 src/Resources/app/administration/src/component/nlx-translation-provider-select/nlx-translation-provider-select.html.twig diff --git a/src/Command/FlushTranslationCacheCommand.php b/src/Command/FlushTranslationCacheCommand.php index 6f08107..e7529a3 100644 --- a/src/Command/FlushTranslationCacheCommand.php +++ b/src/Command/FlushTranslationCacheCommand.php @@ -1,6 +1,6 @@ getSalesChannelIdsWithProviderOverride() as $salesChannelId) { - if (!$this->translationProviderResolver->hasSalesChannelProvider($salesChannelId)) { - continue; - } - $domainsFetched += $this->fetchTranslations( $this->translationProviderResolver->getSalesChannelProvider($salesChannelId), $salesChannelId, @@ -100,7 +92,7 @@ private function fetchTranslations( string $translationPath, SymfonyStyle $io ): int { - $providerName = $this->describeProvider($provider); + $providerName = $this->getProviderName($provider); if ($locales === []) { $io->note(sprintf('Skipping "%s" because no locales were found.', $targetDomain)); @@ -121,7 +113,9 @@ private function fetchTranslations( $newCatalogue = new MessageCatalogue($catalogue->getLocale()); $newCatalogue->add($messages, $targetDomain); - $this->translationWriter->write($newCatalogue, 'json', ['path' => $translationPath]); + $this->translationWriter->write($newCatalogue, 'json', [ + 'path' => $translationPath, + ]); ++$written; } @@ -141,28 +135,19 @@ private function fetchTranslations( return 1; } - private function describeProvider(ProviderInterface $provider): string + private function getProviderName(ProviderInterface $provider): string { return parse_url((string) $provider, \PHP_URL_SCHEME) ?: 'unknown'; } private function getSalesChannelIdsWithProviderOverride(): array { - $criteria = new Criteria(); - $criteria->addFilter(new EqualsFilter('configurationKey', ShopwareTranslationBridgeConfig::KEY_DEFAULT_PROVIDER)); - - $result = $this->systemConfigRepository->search($criteria, Context::createCLIContext()); + $result = $this->salesChannelRepository->searchIds(new Criteria(), Context::createCLIContext()); - $salesChannelIds = []; - foreach ($result->getEntities() as $systemConfig) { - assert($systemConfig instanceof SystemConfigEntity); - $salesChannelId = $systemConfig->getSalesChannelId(); - if ($salesChannelId !== null) { - $salesChannelIds[$salesChannelId] = true; - } - } - - return array_keys($salesChannelIds); + return array_values(array_filter( + $result->getIds(), + fn (string $salesChannelId): bool => $this->translationProviderResolver->hasSalesChannelProvider($salesChannelId) + )); } /** @@ -170,7 +155,8 @@ private function getSalesChannelIdsWithProviderOverride(): array */ private function getAllLocales(): array { - $criteria = new Criteria()->addAssociation('locale'); + $criteria = new Criteria() + ->addAssociation('locale'); $result = $this->languageRepository->search($criteria, Context::createCLIContext()); $languages = $result->getEntities(); assert($languages instanceof LanguageCollection); diff --git a/src/Command/PushSnippetsCommand.php b/src/Command/PushSnippetsCommand.php index 8bbadea..ae1b14e 100644 --- a/src/Command/PushSnippetsCommand.php +++ b/src/Command/PushSnippetsCommand.php @@ -1,6 +1,6 @@ ['api'], + '_acl' => ['system_config:read'], + ], + methods: ['GET'] +)] +class TranslationProviderOptionsController extends AbstractController +{ + public function __construct( + #[Autowire(service: 'translation.provider_collection')] + private readonly TranslationProviderCollection $providers, + ) { + } + + public function __invoke(): JsonApiResponse + { + $options = array_map( + static fn (string $name): array => [ + 'value' => $name, + 'label' => $name, + ], + $this->providers->keys() + ); + + return new JsonApiResponse(['options' => $options]); + } +} diff --git a/src/Core/Framework/Api/Controller/UpdateTranslationController.php b/src/Core/Framework/Api/Controller/UpdateTranslationController.php index c8db77b..0c33bc3 100644 --- a/src/Core/Framework/Api/Controller/UpdateTranslationController.php +++ b/src/Core/Framework/Api/Controller/UpdateTranslationController.php @@ -1,6 +1,6 @@ ['api'], - '_acl' => ['system:cache:info'] + '_acl' => ['system:cache:info'], ], methods: ['POST'] )] class UpdateTranslationController extends AbstractController { - function __construct( + public function __construct( private readonly EntityRepository $salesChannelRepository, private readonly MessageBusInterface $messageBus, private readonly TranslationProviderResolverInterface $translationProviderResolver, @@ -34,7 +34,7 @@ function __construct( ) { } - function __invoke(): JsonApiResponse + public function __invoke(): JsonApiResponse { $salesChannelIds = $this->salesChannelRepository ->searchIds(new Criteria(), Context::createCLIContext()) @@ -51,7 +51,7 @@ function __invoke(): JsonApiResponse if ($salesChannelIds === []) { return new JsonApiResponse([ 'success' => false, - 'error' => 'errorMissingTranslationProvider' + 'error' => 'errorMissingTranslationProvider', ], Response::HTTP_SERVICE_UNAVAILABLE); } @@ -59,6 +59,8 @@ function __invoke(): JsonApiResponse $this->messageBus->dispatch(new TranslationUpdateMessage(...$chunk)); } - return new JsonApiResponse(['success' => true]); + return new JsonApiResponse([ + 'success' => true, + ]); } } diff --git a/src/Core/System/RelevantLocaleResolver.php b/src/Core/System/RelevantLocaleResolver.php index 7930570..ff299ce 100644 --- a/src/Core/System/RelevantLocaleResolver.php +++ b/src/Core/System/RelevantLocaleResolver.php @@ -1,6 +1,6 @@ merge($salesChannelLanguages); } - return array_filter($languages->map(fn(LanguageEntity $language) => $language->getLocale()?->getCode())); + return array_filter($languages->map(fn (LanguageEntity $language) => $language->getLocale()?->getCode())); } } diff --git a/src/Core/System/RelevantLocaleResolverInterface.php b/src/Core/System/RelevantLocaleResolverInterface.php index d4c1de3..6d07fae 100644 --- a/src/Core/System/RelevantLocaleResolverInterface.php +++ b/src/Core/System/RelevantLocaleResolverInterface.php @@ -1,6 +1,6 @@ systemConfigService->getBool( - ShopwareTranslationBridgeConfig::KEY_RESPECT_TRANSLATION_FILES, - $extension->salesChannelId - ); + $respectTranslationFiles = $this->configurationResolver->respectTranslationFiles($extension->salesChannelId); // Force usage of translation files if ($respectTranslationFiles) { @@ -47,6 +43,7 @@ public function __invoke(StorefrontSnippetsExtension $extension): void public static function skip(callable $callback): void { self::$skip = true; + try { $callback(); } finally { @@ -76,10 +73,7 @@ private function applyProviderTranslations(StorefrontSnippetsExtension $extensio $provider = $this->providerResolver->getProvider($extension->salesChannelId); - $locales = array_unique(array_filter([ - $extension->locale, - $extension->fallbackLocale - ])); + $locales = array_unique(array_filter([$extension->locale, $extension->fallbackLocale])); $translationBag = $provider->read([self::TRANSLATION_DOMAIN], $locales); diff --git a/src/Core/System/Snippet/SalesChannelTranslationRefresher.php b/src/Core/System/Snippet/SalesChannelTranslationRefresher.php index 47b2e64..3d0b277 100644 --- a/src/Core/System/Snippet/SalesChannelTranslationRefresher.php +++ b/src/Core/System/Snippet/SalesChannelTranslationRefresher.php @@ -1,6 +1,6 @@ addFilter(new EqualsAnyFilter('salesChannelId', $salesChannelIds)); $criteria->addAssociation('language.locale'); + try { $this->salesChannelDomainRepository->search($criteria, Context::createCLIContext())->map( $this->warmUpTranslation(...) @@ -47,7 +48,9 @@ private function warmUpTranslation(SalesChannelDomainEntity $domain): void $this->translator->injectSettings( $domain->getSalesChannelId(), $domain->getLanguageId(), - $domain->getLanguage()->getLocale()->getCode(), + $domain->getLanguage() + ->getLocale() + ->getCode(), Context::createCLIContext() ); $this->translator->getCatalogue(); diff --git a/src/Core/System/Snippet/SalesChannelTranslationRefresherInterface.php b/src/Core/System/Snippet/SalesChannelTranslationRefresherInterface.php index 5ad68c8..b89c05d 100644 --- a/src/Core/System/Snippet/SalesChannelTranslationRefresherInterface.php +++ b/src/Core/System/Snippet/SalesChannelTranslationRefresherInterface.php @@ -1,6 +1,6 @@ systemConfigService->getString(ShopwareTranslationBridgeConfig::KEY_DEFAULT_PROVIDER); + $providerName = $this->configurationResolver->getDefaultProviderName(); return $providerName !== '' ? $providerName : null; } private function resolveSalesChannelProviderName(string $salesChannelId): ?string { - $config = $this->systemConfigService->getDomain( - ShopwareTranslationBridgeConfig::DOMAIN, - $salesChannelId, - false - ); - - $providerName = $config[ShopwareTranslationBridgeConfig::KEY_DEFAULT_PROVIDER] ?? null; - - return is_string($providerName) && $providerName !== '' ? $providerName : null; + return $this->configurationResolver->getSalesChannelProviderOverride($salesChannelId); } } diff --git a/src/Core/System/Snippet/TranslationProviderResolverInterface.php b/src/Core/System/Snippet/TranslationProviderResolverInterface.php index c288d15..c1f6564 100644 --- a/src/Core/System/Snippet/TranslationProviderResolverInterface.php +++ b/src/Core/System/Snippet/TranslationProviderResolverInterface.php @@ -1,6 +1,6 @@ systemConfigService->getBool( + self::KEY_RESPECT_TRANSLATION_FILES, + $salesChannelId + ); + } + + public function getDefaultProviderName(): string + { + return $this->systemConfigService->getString( + self::KEY_DEFAULT_PROVIDER + ); + } + + /** + * Returns the provider name only if it was explicitly configured for this sales channel + * (i.e. not inherited from the global default). Returns null if the sales channel has no + * override of its own, even if a global default provider is configured. + */ + public function getSalesChannelProviderOverride(string $salesChannelId): ?string + { + $config = $this->systemConfigService->getDomain( + self::PLUGIN_CONFIG_PREFIX, + $salesChannelId, + false + ); + + $providerName = $config[self::KEY_DEFAULT_PROVIDER] ?? null; + + return is_string($providerName) && $providerName !== '' ? $providerName : null; + } +} diff --git a/src/Resources/app/administration/src/component/nlx-translation-provider-select/index.js b/src/Resources/app/administration/src/component/nlx-translation-provider-select/index.js new file mode 100644 index 0000000..ce7ffd9 --- /dev/null +++ b/src/Resources/app/administration/src/component/nlx-translation-provider-select/index.js @@ -0,0 +1,58 @@ +import template from './nlx-translation-provider-select.html.twig'; + +export default { + template, + + inject: ['nlxTranslationApiService'], + + props: { + value: { + type: String, + required: false, + default: null, + }, + label: { + required: false, + default: null, + }, + helpText: { + required: false, + default: null, + }, + error: { + type: Object, + required: false, + default: null, + }, + }, + + emits: ['update:value'], + + data() { + return { + isLoading: false, + options: [], + }; + }, + + created() { + this.createdComponent(); + }, + + methods: { + async createdComponent() { + this.isLoading = true; + + try { + const response = await this.nlxTranslationApiService.getProviders(); + this.options = response.options ?? []; + } finally { + this.isLoading = false; + } + }, + + onChange(value) { + this.$emit('update:value', value); + }, + }, +}; diff --git a/src/Resources/app/administration/src/component/nlx-translation-provider-select/nlx-translation-provider-select.html.twig b/src/Resources/app/administration/src/component/nlx-translation-provider-select/nlx-translation-provider-select.html.twig new file mode 100644 index 0000000..f88d345 --- /dev/null +++ b/src/Resources/app/administration/src/component/nlx-translation-provider-select/nlx-translation-provider-select.html.twig @@ -0,0 +1,11 @@ +{% block nlx_translation_provider_select %} + +{% endblock %} diff --git a/src/Resources/app/administration/src/main.js b/src/Resources/app/administration/src/main.js index 0faaa95..ca0bd50 100644 --- a/src/Resources/app/administration/src/main.js +++ b/src/Resources/app/administration/src/main.js @@ -9,6 +9,11 @@ Shopware.Component.register( () => import('./module/nlx-translation-update') ); +Shopware.Component.register( + 'nlx-translation-provider-select', + () => import('./component/nlx-translation-provider-select') +); + Shopware.Component.override( 'sw-settings-cache-index', () => import('./overrride/module/sw-settings-cache-index') diff --git a/src/Resources/app/administration/src/service/translationApi.Service.js b/src/Resources/app/administration/src/service/translationApi.Service.js index dfbd583..1486c80 100644 --- a/src/Resources/app/administration/src/service/translationApi.Service.js +++ b/src/Resources/app/administration/src/service/translationApi.Service.js @@ -17,4 +17,15 @@ export default class NlxTranslationApiService extends ApiService { ) .then((response) => ApiService.handleResponse(response)); } + + getProviders() { + return this.httpClient + .get( + `${this.getApiBasePath()}/providers`, + { + headers: this.getBasicHeaders(), + } + ) + .then((response) => ApiService.handleResponse(response)); + } } diff --git a/src/Resources/config/config.xml b/src/Resources/config/config.xml index fa4defe..ae16b9e 100644 --- a/src/Resources/config/config.xml +++ b/src/Resources/config/config.xml @@ -5,20 +5,21 @@ Translation Bridge Translation Bridge - + defaultProvider - Name of the configured Symfony Translation provider DSN (e.g. "tolgee"). Select a sales - channel above to override the provider for that sales channel only. + Symfony Translation provider configured under "framework.translator.providers" in + translation.yaml. Select a sales channel above to override the provider for that sales + channel only. - Name des konfigurierten Symfony-Translation-Provider-DSN (z. B. "tolgee"). Wählen Sie oben - einen Verkaufskanal aus, um den Provider nur für diesen Verkaufskanal zu überschreiben. + Symfony-Translation-Provider, der unter "framework.translator.providers" in der + translation.yaml konfiguriert ist. Wählen Sie oben einen Verkaufskanal aus, um den Provider + nur für diesen Verkaufskanal zu überschreiben. - tolgee - + respectTranslationFiles diff --git a/src/ShopwareTranslationBridge.php b/src/ShopwareTranslationBridge.php index a7c9ae0..4025996 100644 --- a/src/ShopwareTranslationBridge.php +++ b/src/ShopwareTranslationBridge.php @@ -1,6 +1,6 @@ Date: Thu, 9 Jul 2026 09:27:56 +0200 Subject: [PATCH 4/5] refactor: code styling --- src/Resolver/ConfigurationResolver.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Resolver/ConfigurationResolver.php b/src/Resolver/ConfigurationResolver.php index 14ca68e..ee25c59 100644 --- a/src/Resolver/ConfigurationResolver.php +++ b/src/Resolver/ConfigurationResolver.php @@ -39,12 +39,6 @@ public function getDefaultProviderName(): string self::KEY_DEFAULT_PROVIDER ); } - - /** - * Returns the provider name only if it was explicitly configured for this sales channel - * (i.e. not inherited from the global default). Returns null if the sales channel has no - * override of its own, even if a global default provider is configured. - */ public function getSalesChannelProviderOverride(string $salesChannelId): ?string { $config = $this->systemConfigService->getDomain( From 7a66bd7a2de750fe36492a8e6e0247f661d4dec5 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Tue, 14 Jul 2026 11:05:45 +0200 Subject: [PATCH 5/5] feat: added feedback and fixed small issues --- Readme.md | 5 ++--- src/Command/FlushTranslationCacheCommand.php | 2 +- src/Command/PullSnippetsCommand.php | 7 ++++++- .../TranslationProviderOptionsController.php | 4 +++- .../Snippet/TranslationProviderResolver.php | 8 ++++---- .../TranslationUpdateHandler.php | 2 +- src/Resolver/ConfigurationResolver.php | 16 ++++------------ src/Resources/app/administration/src/main.js | 4 +--- .../src/service/translationApi.Service.js | 2 +- 9 files changed, 23 insertions(+), 27 deletions(-) rename src/{MesageHandler => MessageHandler}/TranslationUpdateHandler.php (91%) diff --git a/Readme.md b/Readme.md index 8770c9b..058ba79 100644 --- a/Readme.md +++ b/Readme.md @@ -65,15 +65,14 @@ bin/console sw:snippets:pull [salesChannelId1] Flushes the translation cache. This is useful after pulling new translations to make them visible in the storefront. ```bash -bin/console sw:cache:flush:translation +bin/console sw:cache:translation:flush ``` ## API Endpoint This plugin provides an API endpoint to trigger a translation update for specific sales channels. This is useful for integrating with webhooks from translation providers (e.g., when translations are completed). -* **URL:** `/api/_action/nlx/translation/update` -* **Method:** `POST` +* **URL:** `/api/_action/nlx-translation/update` * **Body (JSON):** ```json { diff --git a/src/Command/FlushTranslationCacheCommand.php b/src/Command/FlushTranslationCacheCommand.php index e7529a3..2a046bc 100644 --- a/src/Command/FlushTranslationCacheCommand.php +++ b/src/Command/FlushTranslationCacheCommand.php @@ -10,7 +10,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -#[AsCommand('cache:translation:flush')] +#[AsCommand('sw:cache:translation:flush')] class FlushTranslationCacheCommand extends Command { public function __construct( diff --git a/src/Command/PullSnippetsCommand.php b/src/Command/PullSnippetsCommand.php index e1aeffd..7bf2aea 100644 --- a/src/Command/PullSnippetsCommand.php +++ b/src/Command/PullSnippetsCommand.php @@ -140,13 +140,18 @@ private function getProviderName(ProviderInterface $provider): string return parse_url((string) $provider, \PHP_URL_SCHEME) ?: 'unknown'; } + /** + * @return list + */ private function getSalesChannelIdsWithProviderOverride(): array { $result = $this->salesChannelRepository->searchIds(new Criteria(), Context::createCLIContext()); return array_values(array_filter( $result->getIds(), - fn (string $salesChannelId): bool => $this->translationProviderResolver->hasSalesChannelProvider($salesChannelId) + fn (string $salesChannelId): bool => $this->translationProviderResolver->hasSalesChannelProvider( + $salesChannelId + ) )); } diff --git a/src/Core/Framework/Api/Controller/TranslationProviderOptionsController.php b/src/Core/Framework/Api/Controller/TranslationProviderOptionsController.php index 2884020..a2a3a0f 100644 --- a/src/Core/Framework/Api/Controller/TranslationProviderOptionsController.php +++ b/src/Core/Framework/Api/Controller/TranslationProviderOptionsController.php @@ -37,6 +37,8 @@ public function __invoke(): JsonApiResponse $this->providers->keys() ); - return new JsonApiResponse(['options' => $options]); + return new JsonApiResponse([ + 'options' => $options, + ]); } } diff --git a/src/Core/System/Snippet/TranslationProviderResolver.php b/src/Core/System/Snippet/TranslationProviderResolver.php index 8f07c91..dc3f3f7 100644 --- a/src/Core/System/Snippet/TranslationProviderResolver.php +++ b/src/Core/System/Snippet/TranslationProviderResolver.php @@ -46,7 +46,7 @@ public function getDefaultProvider(): ProviderInterface public function hasSalesChannelProvider(string $salesChannelId): bool { if (!Uuid::isValid($salesChannelId)) { - throw new InvalidArgumentException(\sprintf('Provider "%s" is not a valid UUID.', $salesChannelId)); + throw new InvalidArgumentException(\sprintf('SalesChannelId "%s" is not a valid UUID.', $salesChannelId)); } $providerName = $this->resolveSalesChannelProviderName($salesChannelId); @@ -61,7 +61,7 @@ public function getSalesChannelProvider(string $salesChannelId): ProviderInterfa } if (!$this->hasSalesChannelProvider($salesChannelId)) { - throw new RuntimeException(\sprintf('No provider for salesChannel "%s" not found.', $salesChannelId)); + throw new RuntimeException(\sprintf('Provider for salesChannel "%s" not found.', $salesChannelId)); } $providerName = $this->resolveSalesChannelProviderName($salesChannelId); @@ -84,12 +84,12 @@ public function getProvider(string $salesChannelId): ProviderInterface return $this->getDefaultProvider(); } - throw new RuntimeException(\sprintf('No provider for salesChannel "%s" not found.', $salesChannelId)); + throw new RuntimeException(\sprintf('Provider for salesChannel "%s" not found.', $salesChannelId)); } public function reset(): void { - unset($this->providers); + $this->providers = []; } private function resolveDefaultProviderName(): ?string diff --git a/src/MesageHandler/TranslationUpdateHandler.php b/src/MessageHandler/TranslationUpdateHandler.php similarity index 91% rename from src/MesageHandler/TranslationUpdateHandler.php rename to src/MessageHandler/TranslationUpdateHandler.php index 2cb302f..770f153 100644 --- a/src/MesageHandler/TranslationUpdateHandler.php +++ b/src/MessageHandler/TranslationUpdateHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Netlogix\ShopwareTranslationBridge\MesageHandler; +namespace Netlogix\ShopwareTranslationBridge\MessageHandler; use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\SalesChannelTranslationRefresherInterface; use Netlogix\ShopwareTranslationBridge\Message\TranslationUpdateMessage; diff --git a/src/Resolver/ConfigurationResolver.php b/src/Resolver/ConfigurationResolver.php index ee25c59..fd77e10 100644 --- a/src/Resolver/ConfigurationResolver.php +++ b/src/Resolver/ConfigurationResolver.php @@ -27,25 +27,17 @@ public function __construct( public function respectTranslationFiles(?string $salesChannelId = null): bool { - return $this->systemConfigService->getBool( - self::KEY_RESPECT_TRANSLATION_FILES, - $salesChannelId - ); + return $this->systemConfigService->getBool(self::KEY_RESPECT_TRANSLATION_FILES, $salesChannelId); } public function getDefaultProviderName(): string { - return $this->systemConfigService->getString( - self::KEY_DEFAULT_PROVIDER - ); + return $this->systemConfigService->getString(self::KEY_DEFAULT_PROVIDER); } + public function getSalesChannelProviderOverride(string $salesChannelId): ?string { - $config = $this->systemConfigService->getDomain( - self::PLUGIN_CONFIG_PREFIX, - $salesChannelId, - false - ); + $config = $this->systemConfigService->getDomain(self::PLUGIN_CONFIG_PREFIX, $salesChannelId, false); $providerName = $config[self::KEY_DEFAULT_PROVIDER] ?? null; diff --git a/src/Resources/app/administration/src/main.js b/src/Resources/app/administration/src/main.js index ca0bd50..055db51 100644 --- a/src/Resources/app/administration/src/main.js +++ b/src/Resources/app/administration/src/main.js @@ -1,6 +1,4 @@ -import './module/nlx-translation-update' -import './overrride/module/sw-settings-cache-index' -import TranslationApiService from "./service/translationApi.Service"; +import TranslationApiService from './service/translationApi.Service'; const {Application} = Shopware; diff --git a/src/Resources/app/administration/src/service/translationApi.Service.js b/src/Resources/app/administration/src/service/translationApi.Service.js index 1486c80..8b3db90 100644 --- a/src/Resources/app/administration/src/service/translationApi.Service.js +++ b/src/Resources/app/administration/src/service/translationApi.Service.js @@ -3,7 +3,7 @@ const {ApiService} = Shopware.Classes; export default class NlxTranslationApiService extends ApiService { constructor(httpClient, loginService, apiEndpoint = '_action/nlx-translation') { super(httpClient, loginService, apiEndpoint); - this.name = 'nlxNeosContentApiService'; + this.name = 'nlxTranslationApiService'; } updateTranslation() {