From d806dab7a810b4885c327f1d188e08d769d9dd81 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Tue, 7 Jul 2026 08:33:38 +0200 Subject: [PATCH 01/18] 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 02/18] 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 03/18] 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 04/18] 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 05/18] 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() { From dbd8e4aa9dfaa0414a7e3d4ff071115adf21edd9 Mon Sep 17 00:00:00 2001 From: Markus Uderhardt <161821817+markus-uderhardt@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:52:47 +0200 Subject: [PATCH 06/18] Update src/Core/System/Snippet/TranslationProviderResolver.php Co-authored-by: Sascha Heilmeier --- src/Core/System/Snippet/TranslationProviderResolver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/System/Snippet/TranslationProviderResolver.php b/src/Core/System/Snippet/TranslationProviderResolver.php index dc3f3f7..a75ed37 100644 --- a/src/Core/System/Snippet/TranslationProviderResolver.php +++ b/src/Core/System/Snippet/TranslationProviderResolver.php @@ -40,7 +40,7 @@ public function getDefaultProvider(): ProviderInterface $providerName = $this->resolveDefaultProviderName(); assert(is_string($providerName)); - return $this->providers['default'] = $this->providerCollection->get($providerName); + return $this->providers['default'] ??= $this->providerCollection->get($this->resolveDefaultProviderName()); } public function hasSalesChannelProvider(string $salesChannelId): bool From 66a7596cc9fe3c6e9232ce3af48a473433fe69de Mon Sep 17 00:00:00 2001 From: Markus Uderhardt <161821817+markus-uderhardt@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:55:01 +0200 Subject: [PATCH 07/18] Update src/Resolver/ConfigurationResolver.php Co-authored-by: Sascha Heilmeier --- src/Resolver/ConfigurationResolver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Resolver/ConfigurationResolver.php b/src/Resolver/ConfigurationResolver.php index fd77e10..2e4d44b 100644 --- a/src/Resolver/ConfigurationResolver.php +++ b/src/Resolver/ConfigurationResolver.php @@ -12,7 +12,7 @@ use Shopware\Core\System\SystemConfig\SystemConfigService; -class ConfigurationResolver +readonly class ConfigurationResolver { public const string PLUGIN_CONFIG_PREFIX = 'ShopwareTranslationBridge.config'; From c5fba920e34d8454f4362859be032ca6116decec Mon Sep 17 00:00:00 2001 From: Markus Uderhardt <161821817+markus-uderhardt@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:55:27 +0200 Subject: [PATCH 08/18] Update src/Core/System/Snippet/TranslationProviderResolver.php Co-authored-by: Sascha Heilmeier --- src/Core/System/Snippet/TranslationProviderResolver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/System/Snippet/TranslationProviderResolver.php b/src/Core/System/Snippet/TranslationProviderResolver.php index a75ed37..24cf28b 100644 --- a/src/Core/System/Snippet/TranslationProviderResolver.php +++ b/src/Core/System/Snippet/TranslationProviderResolver.php @@ -65,7 +65,7 @@ public function getSalesChannelProvider(string $salesChannelId): ProviderInterfa } $providerName = $this->resolveSalesChannelProviderName($salesChannelId); - assert(is_string($providerName), 'Provider map value must be string'); + assert(is_string($providerName), 'ProviderName value must be string'); return $this->providers[$salesChannelId] = $this->providerCollection->get($providerName); } From 703087067e7d525424f95695a984fe452fd8e1d2 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Mon, 27 Jul 2026 14:39:51 +0200 Subject: [PATCH 09/18] refactor: removed obsolete file --- src/ShopwareTranslationBridgeConfig.php | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 src/ShopwareTranslationBridgeConfig.php diff --git a/src/ShopwareTranslationBridgeConfig.php b/src/ShopwareTranslationBridgeConfig.php deleted file mode 100644 index 8dd8521..0000000 --- a/src/ShopwareTranslationBridgeConfig.php +++ /dev/null @@ -1,18 +0,0 @@ - Date: Thu, 30 Jul 2026 08:36:03 +0200 Subject: [PATCH 10/18] refactor: (phpstan) use Filesystem instead of direct mkdir --- src/Command/PullSnippetsCommand.php | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Command/PullSnippetsCommand.php b/src/Command/PullSnippetsCommand.php index 7bf2aea..57cbd7d 100644 --- a/src/Command/PullSnippetsCommand.php +++ b/src/Command/PullSnippetsCommand.php @@ -5,7 +5,6 @@ namespace Netlogix\ShopwareTranslationBridge\Command; use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\TranslationProviderResolverInterface; -use RuntimeException; use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; @@ -17,6 +16,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Translation\MessageCatalogue; use Symfony\Component\Translation\Provider\ProviderInterface; use Symfony\Component\Translation\Writer\TranslationWriterInterface; @@ -214,12 +214,6 @@ private function resolveTranslationPath(): string private function ensureDirectoryExists(string $path): void { - if (is_dir($path)) { - return; - } - - if (!mkdir($path, 0777, true) && !is_dir($path)) { - throw new RuntimeException(sprintf('Unable to create translation directory "%s".', $path)); - } + (new Filesystem())->mkdir($path); } } From 39001eb99d38326565ee59aa71254158d338388a Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Thu, 30 Jul 2026 08:36:37 +0200 Subject: [PATCH 11/18] feat: changed method call to make sure provider is found --- src/Command/PushSnippetsCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Command/PushSnippetsCommand.php b/src/Command/PushSnippetsCommand.php index ae1b14e..7df53c6 100644 --- a/src/Command/PushSnippetsCommand.php +++ b/src/Command/PushSnippetsCommand.php @@ -143,7 +143,7 @@ private function resolveProviders(InputInterface $input): array $providers = []; foreach ($salesChannelIds as $salesChannelId) { - $providers[] = $this->translationProviderResolver->getSalesChannelProvider($salesChannelId); + $providers[] = $this->translationProviderResolver->getProvider($salesChannelId); } return $providers; From 92f81f12757d813894c2c596dc49c171fe5b1b85 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Thu, 30 Jul 2026 08:37:14 +0200 Subject: [PATCH 12/18] refactor: refactored structure of resolver classes --- .../Snippet/TranslationProviderResolver.php | 36 ++++++------------- src/Resolver/ConfigurationResolver.php | 8 ++--- 2 files changed, 13 insertions(+), 31 deletions(-) diff --git a/src/Core/System/Snippet/TranslationProviderResolver.php b/src/Core/System/Snippet/TranslationProviderResolver.php index 24cf28b..73d561b 100644 --- a/src/Core/System/Snippet/TranslationProviderResolver.php +++ b/src/Core/System/Snippet/TranslationProviderResolver.php @@ -11,16 +11,13 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Translation\Provider\ProviderInterface; use Symfony\Component\Translation\Provider\TranslationProviderCollection; -use Symfony\Contracts\Service\ResetInterface; -class TranslationProviderResolver implements TranslationProviderResolverInterface, ResetInterface +readonly class TranslationProviderResolver implements TranslationProviderResolverInterface { - private array $providers = []; - public function __construct( #[Autowire(service: 'translation.provider_collection')] - private readonly TranslationProviderCollection $providerCollection, - private readonly ConfigurationResolver $configurationResolver + private TranslationProviderCollection $providerCollection, + private ConfigurationResolver $configurationResolver ) { } @@ -33,14 +30,13 @@ public function hasDefaultProvider(): bool public function getDefaultProvider(): ProviderInterface { - if (!$this->hasDefaultProvider()) { - throw new RuntimeException(\sprintf('Provider "%s" not found.', $this->resolveDefaultProviderName() ?? '')); - } - $providerName = $this->resolveDefaultProviderName(); - assert(is_string($providerName)); - return $this->providers['default'] ??= $this->providerCollection->get($this->resolveDefaultProviderName()); + if ($providerName === null || !$this->providerCollection->has($providerName)) { + throw new RuntimeException(\sprintf('Provider "%s" not found.', $providerName ?? '')); + } + + return $this->providerCollection->get($providerName); } public function hasSalesChannelProvider(string $salesChannelId): bool @@ -56,18 +52,13 @@ public function hasSalesChannelProvider(string $salesChannelId): bool public function getSalesChannelProvider(string $salesChannelId): ProviderInterface { - if (array_key_exists($salesChannelId, $this->providers)) { - return $this->providers[$salesChannelId]; - } + $providerName = $this->resolveSalesChannelProviderName($salesChannelId); - if (!$this->hasSalesChannelProvider($salesChannelId)) { + if ($providerName === null || !$this->providerCollection->has($providerName)) { throw new RuntimeException(\sprintf('Provider for salesChannel "%s" not found.', $salesChannelId)); } - $providerName = $this->resolveSalesChannelProviderName($salesChannelId); - assert(is_string($providerName), 'ProviderName value must be string'); - - return $this->providers[$salesChannelId] = $this->providerCollection->get($providerName); + return $this->providerCollection->get($providerName); } public function hasProvider(string $salesChannelId): bool @@ -87,11 +78,6 @@ public function getProvider(string $salesChannelId): ProviderInterface throw new RuntimeException(\sprintf('Provider for salesChannel "%s" not found.', $salesChannelId)); } - public function reset(): void - { - $this->providers = []; - } - private function resolveDefaultProviderName(): ?string { $providerName = $this->configurationResolver->getDefaultProviderName(); diff --git a/src/Resolver/ConfigurationResolver.php b/src/Resolver/ConfigurationResolver.php index 2e4d44b..fe91669 100644 --- a/src/Resolver/ConfigurationResolver.php +++ b/src/Resolver/ConfigurationResolver.php @@ -21,7 +21,7 @@ public const string KEY_RESPECT_TRANSLATION_FILES = self::PLUGIN_CONFIG_PREFIX . '.respectTranslationFiles'; public function __construct( - private readonly SystemConfigService $systemConfigService, + private SystemConfigService $systemConfigService, ) { } @@ -37,10 +37,6 @@ public function getDefaultProviderName(): string 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; + return $this->systemConfigService->getString(self::KEY_DEFAULT_PROVIDER, $salesChannelId); } } From d40c8444b99fab4b0dea0721ce3a5337b8281877 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Tue, 4 Aug 2026 15:57:12 +0200 Subject: [PATCH 13/18] refactor: improved provider handling and added more structure --- src/Command/PullSnippetsCommand.php | 27 ++++--- src/Command/PushSnippetsCommand.php | 2 +- .../UpdateTranslationController.php | 13 ++-- .../MissingDefaultProviderException.php | 16 +++++ .../MissingSalesChannelProviderException.php | 23 ++++++ .../TranslationProviderException.php | 16 +++++ .../Exception/UnknownProviderException.php | 35 +++++++++ .../Snippet/TranslationProviderResolver.php | 71 ++++--------------- .../TranslationProviderResolverInterface.php | 12 +--- src/Resolver/ConfigurationResolver.php | 20 +++--- 10 files changed, 142 insertions(+), 93 deletions(-) create mode 100644 src/Core/System/Snippet/Exception/MissingDefaultProviderException.php create mode 100644 src/Core/System/Snippet/Exception/MissingSalesChannelProviderException.php create mode 100644 src/Core/System/Snippet/Exception/TranslationProviderException.php create mode 100644 src/Core/System/Snippet/Exception/UnknownProviderException.php diff --git a/src/Command/PullSnippetsCommand.php b/src/Command/PullSnippetsCommand.php index 57cbd7d..7e30060 100644 --- a/src/Command/PullSnippetsCommand.php +++ b/src/Command/PullSnippetsCommand.php @@ -5,6 +5,7 @@ namespace Netlogix\ShopwareTranslationBridge\Command; use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\TranslationProviderResolverInterface; +use Netlogix\ShopwareTranslationBridge\Resolver\ConfigurationResolver; use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; @@ -30,6 +31,7 @@ class PullSnippetsCommand extends Command public function __construct( private readonly TranslationProviderResolverInterface $translationProviderResolver, + private readonly ConfigurationResolver $configurationResolver, private readonly EntityRepository $languageRepository, private readonly EntityRepository $salesChannelRepository, #[Autowire(service: 'translation.writer')] @@ -45,13 +47,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new SymfonyStyle($input, $output); $translationPath = $this->resolveTranslationPath(); - $this->ensureDirectoryExists($translationPath); $domainsFetched = 0; - if ($this->translationProviderResolver->hasDefaultProvider()) { + $defaultProviderName = $this->configurationResolver->getProviderName(); + + if ($this->translationProviderResolver->hasProvider()) { $domainsFetched += $this->fetchTranslations( - $this->translationProviderResolver->getDefaultProvider(), + $this->translationProviderResolver->getProvider(), self::REMOTE_DOMAIN, $this->getAllLocales(), $translationPath, @@ -59,9 +62,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); } - foreach ($this->getSalesChannelIdsWithProviderOverride() as $salesChannelId) { + foreach ($this->getSalesChannelIdsWithProviderOverride($defaultProviderName) as $salesChannelId) { $domainsFetched += $this->fetchTranslations( - $this->translationProviderResolver->getSalesChannelProvider($salesChannelId), + $this->translationProviderResolver->getProvider($salesChannelId), $salesChannelId, $this->getLocalesForSalesChannel($salesChannelId), $translationPath, @@ -141,17 +144,23 @@ private function getProviderName(ProviderInterface $provider): string } /** + * Returns sales channels whose configured provider differs from the global default, + * i.e. a real per-channel override. Channels that merely inherit the default are + * excluded so their translations are not pulled redundantly. + * * @return list */ - private function getSalesChannelIdsWithProviderOverride(): array + private function getSalesChannelIdsWithProviderOverride(?string $defaultProviderName): array { $result = $this->salesChannelRepository->searchIds(new Criteria(), Context::createCLIContext()); return array_values(array_filter( $result->getIds(), - fn (string $salesChannelId): bool => $this->translationProviderResolver->hasSalesChannelProvider( - $salesChannelId - ) + function (string $salesChannelId) use ($defaultProviderName): bool { + $providerName = $this->configurationResolver->getProviderName($salesChannelId); + + return $providerName !== null && $providerName !== $defaultProviderName; + } )); } diff --git a/src/Command/PushSnippetsCommand.php b/src/Command/PushSnippetsCommand.php index 7df53c6..3a29857 100644 --- a/src/Command/PushSnippetsCommand.php +++ b/src/Command/PushSnippetsCommand.php @@ -138,7 +138,7 @@ private function resolveProviders(InputInterface $input): array assert(is_array($salesChannelIds)); if ($salesChannelIds === [] || in_array('default', $salesChannelIds, true)) { - return [$this->translationProviderResolver->getDefaultProvider()]; + return [$this->translationProviderResolver->getProvider()]; } $providers = []; diff --git a/src/Core/Framework/Api/Controller/UpdateTranslationController.php b/src/Core/Framework/Api/Controller/UpdateTranslationController.php index 0c33bc3..f6d1f4b 100644 --- a/src/Core/Framework/Api/Controller/UpdateTranslationController.php +++ b/src/Core/Framework/Api/Controller/UpdateTranslationController.php @@ -40,13 +40,12 @@ public function __invoke(): JsonApiResponse ->searchIds(new Criteria(), Context::createCLIContext()) ->getIds(); - // If there is no default provider we only have to update the salesChannels which have translation provider - if (!$this->translationProviderResolver->hasDefaultProvider()) { - $salesChannelIds = array_filter( - $salesChannelIds, - $this->translationProviderResolver->hasSalesChannelProvider(...) - ); - } + // Keep only sales channels that resolve to a provider. Thanks to Shopware's config + // inheritance this covers both a global default and channel-specific overrides. + $salesChannelIds = array_values(array_filter( + $salesChannelIds, + $this->translationProviderResolver->hasProvider(...) + )); if ($salesChannelIds === []) { return new JsonApiResponse([ diff --git a/src/Core/System/Snippet/Exception/MissingDefaultProviderException.php b/src/Core/System/Snippet/Exception/MissingDefaultProviderException.php new file mode 100644 index 0000000..d21822b --- /dev/null +++ b/src/Core/System/Snippet/Exception/MissingDefaultProviderException.php @@ -0,0 +1,16 @@ +salesChannelId; + } +} diff --git a/src/Core/System/Snippet/Exception/TranslationProviderException.php b/src/Core/System/Snippet/Exception/TranslationProviderException.php new file mode 100644 index 0000000..a52545a --- /dev/null +++ b/src/Core/System/Snippet/Exception/TranslationProviderException.php @@ -0,0 +1,16 @@ +providerName; + } + + public function getSalesChannelId(): ?string + { + return $this->salesChannelId; + } +} diff --git a/src/Core/System/Snippet/TranslationProviderResolver.php b/src/Core/System/Snippet/TranslationProviderResolver.php index 73d561b..9752515 100644 --- a/src/Core/System/Snippet/TranslationProviderResolver.php +++ b/src/Core/System/Snippet/TranslationProviderResolver.php @@ -4,10 +4,10 @@ namespace Netlogix\ShopwareTranslationBridge\Core\System\Snippet; -use InvalidArgumentException; +use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\Exception\MissingDefaultProviderException; +use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\Exception\MissingSalesChannelProviderException; +use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\Exception\UnknownProviderException; use Netlogix\ShopwareTranslationBridge\Resolver\ConfigurationResolver; -use RuntimeException; -use Shopware\Core\Framework\Uuid\Uuid; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Translation\Provider\ProviderInterface; use Symfony\Component\Translation\Provider\TranslationProviderCollection; @@ -21,72 +21,27 @@ public function __construct( ) { } - public function hasDefaultProvider(): bool + public function hasProvider(?string $salesChannelId = null): bool { - $providerName = $this->resolveDefaultProviderName(); + $providerName = $this->configurationResolver->getProviderName($salesChannelId); return $providerName !== null && $this->providerCollection->has($providerName); } - public function getDefaultProvider(): ProviderInterface + public function getProvider(?string $salesChannelId = null): ProviderInterface { - $providerName = $this->resolveDefaultProviderName(); + $providerName = $this->configurationResolver->getProviderName($salesChannelId); - if ($providerName === null || !$this->providerCollection->has($providerName)) { - throw new RuntimeException(\sprintf('Provider "%s" not found.', $providerName ?? '')); + if ($providerName === null) { + throw $salesChannelId === null + ? new MissingDefaultProviderException() + : new MissingSalesChannelProviderException($salesChannelId); } - return $this->providerCollection->get($providerName); - } - - public function hasSalesChannelProvider(string $salesChannelId): bool - { - if (!Uuid::isValid($salesChannelId)) { - throw new InvalidArgumentException(\sprintf('SalesChannelId "%s" is not a valid UUID.', $salesChannelId)); - } - - $providerName = $this->resolveSalesChannelProviderName($salesChannelId); - - return $providerName !== null && $this->providerCollection->has($providerName); - } - - public function getSalesChannelProvider(string $salesChannelId): ProviderInterface - { - $providerName = $this->resolveSalesChannelProviderName($salesChannelId); - - if ($providerName === null || !$this->providerCollection->has($providerName)) { - throw new RuntimeException(\sprintf('Provider for salesChannel "%s" not found.', $salesChannelId)); + if (!$this->providerCollection->has($providerName)) { + throw new UnknownProviderException($providerName, $salesChannelId); } return $this->providerCollection->get($providerName); } - - public function hasProvider(string $salesChannelId): bool - { - return $this->hasDefaultProvider() || $this->hasSalesChannelProvider($salesChannelId); - } - - public function getProvider(string $salesChannelId): ProviderInterface - { - if ($this->hasSalesChannelProvider($salesChannelId)) { - return $this->getSalesChannelProvider($salesChannelId); - } - if ($this->hasDefaultProvider()) { - return $this->getDefaultProvider(); - } - - throw new RuntimeException(\sprintf('Provider for salesChannel "%s" not found.', $salesChannelId)); - } - - private function resolveDefaultProviderName(): ?string - { - $providerName = $this->configurationResolver->getDefaultProviderName(); - - return $providerName !== '' ? $providerName : null; - } - - private function resolveSalesChannelProviderName(string $salesChannelId): ?string - { - return $this->configurationResolver->getSalesChannelProviderOverride($salesChannelId); - } } diff --git a/src/Core/System/Snippet/TranslationProviderResolverInterface.php b/src/Core/System/Snippet/TranslationProviderResolverInterface.php index c1f6564..e7e1681 100644 --- a/src/Core/System/Snippet/TranslationProviderResolverInterface.php +++ b/src/Core/System/Snippet/TranslationProviderResolverInterface.php @@ -8,15 +8,7 @@ interface TranslationProviderResolverInterface { - public function hasDefaultProvider(): bool; + public function hasProvider(?string $salesChannelId = null): bool; - public function getDefaultProvider(): ProviderInterface; - - public function hasSalesChannelProvider(string $salesChannelId): bool; - - public function getSalesChannelProvider(string $salesChannelId): ProviderInterface; - - public function hasProvider(string $salesChannelId): bool; - - public function getProvider(string $salesChannelId): ProviderInterface; + public function getProvider(?string $salesChannelId = null): ProviderInterface; } diff --git a/src/Resolver/ConfigurationResolver.php b/src/Resolver/ConfigurationResolver.php index fe91669..4f5151b 100644 --- a/src/Resolver/ConfigurationResolver.php +++ b/src/Resolver/ConfigurationResolver.php @@ -12,7 +12,7 @@ use Shopware\Core\System\SystemConfig\SystemConfigService; -readonly class ConfigurationResolver +class ConfigurationResolver { public const string PLUGIN_CONFIG_PREFIX = 'ShopwareTranslationBridge.config'; @@ -21,7 +21,7 @@ public const string KEY_RESPECT_TRANSLATION_FILES = self::PLUGIN_CONFIG_PREFIX . '.respectTranslationFiles'; public function __construct( - private SystemConfigService $systemConfigService, + private readonly SystemConfigService $systemConfigService, ) { } @@ -30,13 +30,17 @@ public function respectTranslationFiles(?string $salesChannelId = null): bool return $this->systemConfigService->getBool(self::KEY_RESPECT_TRANSLATION_FILES, $salesChannelId); } - public function getDefaultProviderName(): string + /** + * Returns the effective provider name for the given scope. + * + * Relies on Shopware's SystemConfig inheritance: with a sales channel id the + * channel-specific value is returned if set, otherwise the global value. Returns + * null when nothing is configured so callers can safely no-op on a fresh install. + */ + public function getProviderName(?string $salesChannelId = null): ?string { - return $this->systemConfigService->getString(self::KEY_DEFAULT_PROVIDER); - } + $providerName = $this->systemConfigService->getString(self::KEY_DEFAULT_PROVIDER, $salesChannelId); - public function getSalesChannelProviderOverride(string $salesChannelId): ?string - { - return $this->systemConfigService->getString(self::KEY_DEFAULT_PROVIDER, $salesChannelId); + return $providerName !== '' ? $providerName : null; } } From 4a78440710e391787c3750358cbffe1b0144cc83 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Tue, 4 Aug 2026 16:05:16 +0200 Subject: [PATCH 14/18] refactor: made class readonly --- src/Resolver/ConfigurationResolver.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Resolver/ConfigurationResolver.php b/src/Resolver/ConfigurationResolver.php index 4f5151b..1483c0c 100644 --- a/src/Resolver/ConfigurationResolver.php +++ b/src/Resolver/ConfigurationResolver.php @@ -12,7 +12,7 @@ use Shopware\Core\System\SystemConfig\SystemConfigService; -class ConfigurationResolver +readonly class ConfigurationResolver { public const string PLUGIN_CONFIG_PREFIX = 'ShopwareTranslationBridge.config'; @@ -21,7 +21,7 @@ class ConfigurationResolver public const string KEY_RESPECT_TRANSLATION_FILES = self::PLUGIN_CONFIG_PREFIX . '.respectTranslationFiles'; public function __construct( - private readonly SystemConfigService $systemConfigService, + private SystemConfigService $systemConfigService, ) { } From d382b0191e4c760fcbecb6472cbd79497767a097 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Wed, 5 Aug 2026 14:26:20 +0200 Subject: [PATCH 15/18] refactor: refactored and improved custom exceptions --- .../MissingDefaultProviderException.php | 10 ++++-- .../MissingSalesChannelProviderException.php | 13 +++----- .../TranslationProviderException.php | 16 ---------- .../TranslationProviderExceptionInterface.php | 10 ++++++ .../Exception/UnknownProviderException.php | 31 +++++++++---------- .../Snippet/TranslationProviderResolver.php | 10 ++++-- 6 files changed, 43 insertions(+), 47 deletions(-) delete mode 100644 src/Core/System/Snippet/Exception/TranslationProviderException.php create mode 100644 src/Core/System/Snippet/Exception/TranslationProviderExceptionInterface.php diff --git a/src/Core/System/Snippet/Exception/MissingDefaultProviderException.php b/src/Core/System/Snippet/Exception/MissingDefaultProviderException.php index d21822b..5a6897e 100644 --- a/src/Core/System/Snippet/Exception/MissingDefaultProviderException.php +++ b/src/Core/System/Snippet/Exception/MissingDefaultProviderException.php @@ -7,10 +7,14 @@ /** * Thrown when a default (global) translation provider is required but none is configured. */ -final class MissingDefaultProviderException extends TranslationProviderException +final class MissingDefaultProviderException extends \RuntimeException implements TranslationProviderExceptionInterface { - public function __construct() + public function __construct(int $code = 0, ?\Throwable $previous = null) { - parent::__construct('No default translation provider configured.'); + parent::__construct( + \sprintf('No default translation provider configured.'), + $code, + $previous + ); } } diff --git a/src/Core/System/Snippet/Exception/MissingSalesChannelProviderException.php b/src/Core/System/Snippet/Exception/MissingSalesChannelProviderException.php index 8857cbb..f133df8 100644 --- a/src/Core/System/Snippet/Exception/MissingSalesChannelProviderException.php +++ b/src/Core/System/Snippet/Exception/MissingSalesChannelProviderException.php @@ -7,17 +7,14 @@ /** * Thrown when no translation provider can be resolved for a specific sales channel. */ -final class MissingSalesChannelProviderException extends TranslationProviderException +final class MissingSalesChannelProviderException extends \RuntimeException implements TranslationProviderExceptionInterface { - public function __construct(private readonly string $salesChannelId) + public function __construct(public readonly string $salesChannelId, int $code = 0, ?\Throwable $previous = null) { parent::__construct( - \sprintf('No translation provider configured for salesChannel "%s".', $salesChannelId) + \sprintf('No translation provider configured for salesChannel "%s".', $salesChannelId), + $code, + $previous ); } - - public function getSalesChannelId(): string - { - return $this->salesChannelId; - } } diff --git a/src/Core/System/Snippet/Exception/TranslationProviderException.php b/src/Core/System/Snippet/Exception/TranslationProviderException.php deleted file mode 100644 index a52545a..0000000 --- a/src/Core/System/Snippet/Exception/TranslationProviderException.php +++ /dev/null @@ -1,16 +0,0 @@ -providerName; - } - - public function getSalesChannelId(): ?string - { - return $this->salesChannelId; + ), + $code, + $previous + ); } } diff --git a/src/Core/System/Snippet/TranslationProviderResolver.php b/src/Core/System/Snippet/TranslationProviderResolver.php index 9752515..99fba0e 100644 --- a/src/Core/System/Snippet/TranslationProviderResolver.php +++ b/src/Core/System/Snippet/TranslationProviderResolver.php @@ -34,12 +34,16 @@ public function getProvider(?string $salesChannelId = null): ProviderInterface if ($providerName === null) { throw $salesChannelId === null - ? new MissingDefaultProviderException() - : new MissingSalesChannelProviderException($salesChannelId); + ? new MissingDefaultProviderException(code: 1785932654) + : new MissingSalesChannelProviderException(salesChannelId: $salesChannelId, code: 1785932765); } if (!$this->providerCollection->has($providerName)) { - throw new UnknownProviderException($providerName, $salesChannelId); + throw new UnknownProviderException( + providerName: $providerName, + salesChannelId: $salesChannelId, + code: 1785932876 + ); } return $this->providerCollection->get($providerName); From b4beaf2cd3008715ae410ffe0ce1fa87aaefc84b Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Wed, 5 Aug 2026 14:36:43 +0200 Subject: [PATCH 16/18] refactor: improved naming and function definitions --- src/Core/System/Snippet/Listener/LoadTranslationsListener.php | 3 ++- src/Resolver/ConfigurationResolver.php | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Core/System/Snippet/Listener/LoadTranslationsListener.php b/src/Core/System/Snippet/Listener/LoadTranslationsListener.php index 721c21d..30985e8 100644 --- a/src/Core/System/Snippet/Listener/LoadTranslationsListener.php +++ b/src/Core/System/Snippet/Listener/LoadTranslationsListener.php @@ -28,7 +28,8 @@ public function __invoke(StorefrontSnippetsExtension $extension): void return; } - $respectTranslationFiles = $this->configurationResolver->respectTranslationFiles($extension->salesChannelId); + $respectTranslationFiles = $extension->salesChannelId !== null + && $this->configurationResolver->respectTranslationFiles($extension->salesChannelId); // Force usage of translation files if ($respectTranslationFiles) { diff --git a/src/Resolver/ConfigurationResolver.php b/src/Resolver/ConfigurationResolver.php index 1483c0c..58af106 100644 --- a/src/Resolver/ConfigurationResolver.php +++ b/src/Resolver/ConfigurationResolver.php @@ -16,7 +16,7 @@ { public const string PLUGIN_CONFIG_PREFIX = 'ShopwareTranslationBridge.config'; - public const string KEY_DEFAULT_PROVIDER = self::PLUGIN_CONFIG_PREFIX . '.defaultProvider'; + public const string KEY_DEFAULT_PROVIDER = self::PLUGIN_CONFIG_PREFIX . '.keyProvider'; public const string KEY_RESPECT_TRANSLATION_FILES = self::PLUGIN_CONFIG_PREFIX . '.respectTranslationFiles'; @@ -25,7 +25,7 @@ public function __construct( ) { } - public function respectTranslationFiles(?string $salesChannelId = null): bool + public function respectTranslationFiles(string $salesChannelId): bool { return $this->systemConfigService->getBool(self::KEY_RESPECT_TRANSLATION_FILES, $salesChannelId); } From fd1823778feee9afbadc57ef3e2f83af6c3d39ef Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Wed, 5 Aug 2026 14:52:31 +0200 Subject: [PATCH 17/18] refactor: added more log infos --- src/Command/PullSnippetsCommand.php | 33 ++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/Command/PullSnippetsCommand.php b/src/Command/PullSnippetsCommand.php index 7e30060..b6cdc71 100644 --- a/src/Command/PullSnippetsCommand.php +++ b/src/Command/PullSnippetsCommand.php @@ -50,19 +50,32 @@ protected function execute(InputInterface $input, OutputInterface $output): int $domainsFetched = 0; + if (!$this->translationProviderResolver->hasProvider()) { + $io->warning( + 'No translation provider configured. Configure a default provider or at least one sales channel provider.' + ); + + return Command::SUCCESS; + } + $defaultProviderName = $this->configurationResolver->getProviderName(); - if ($this->translationProviderResolver->hasProvider()) { - $domainsFetched += $this->fetchTranslations( - $this->translationProviderResolver->getProvider(), - self::REMOTE_DOMAIN, - $this->getAllLocales(), - $translationPath, - $io - ); + $io->note('Fetching translations for keyProvider'); + $domainsFetched += $this->fetchTranslations( + $this->translationProviderResolver->getProvider(), + self::REMOTE_DOMAIN, + $this->getAllLocales(), + $translationPath, + $io + ); + + $salesChannelIdsWithProvider = $this->getSalesChannelIdsWithProviderOverride($defaultProviderName); + if (empty($salesChannelIdsWithProvider)) { + $io->note('No own sales-channels providers found.'); } - foreach ($this->getSalesChannelIdsWithProviderOverride($defaultProviderName) as $salesChannelId) { + foreach ($salesChannelIdsWithProvider as $salesChannelId) { + $io->note(sprintf('Fetching translations for Sales-Channel-ID: %s.', $salesChannelId)); $domainsFetched += $this->fetchTranslations( $this->translationProviderResolver->getProvider($salesChannelId), $salesChannelId, @@ -150,7 +163,7 @@ private function getProviderName(ProviderInterface $provider): string * * @return list */ - private function getSalesChannelIdsWithProviderOverride(?string $defaultProviderName): array + private function getSalesChannelIdsWithProviderOverride(string $defaultProviderName): array { $result = $this->salesChannelRepository->searchIds(new Criteria(), Context::createCLIContext()); From 551fed93381ddddc755b57efca56deedbcf16280 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Wed, 5 Aug 2026 15:06:43 +0200 Subject: [PATCH 18/18] feat: updated readme --- Readme.md | 100 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 63 insertions(+), 37 deletions(-) diff --git a/Readme.md b/Readme.md index 058ba79..d3a8a92 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,14 @@ # Shopware Translation Bridge -This plugin provides a bridge to connect Shopware with any Translation Provider that is supported by the [Symfony Translation Component](https://symfony.com/doc/current/translation.html#translation-providers). It allows you to manage your storefront snippets via a third-party translation service. +This plugin provides a bridge to connect Shopware with any translation provider supported by the [Symfony Translation Component](https://symfony.com/doc/current/translation.html#translation-providers). It lets you manage your storefront snippets through a third-party translation service (e.g. Tolgee, Crowdin, Lokalise) instead of maintaining them by hand in the administration. + +At its core the plugin does two things: it applies provider translations to the storefront at runtime, and it provides CLI commands and an API to synchronise snippets between Shopware and the provider in both directions. + +## Requirements + +* PHP 8.3+ +* Shopware 6.7.1 or higher (`shopware/core` and `shopware/storefront`) +* `symfony/translation` 7.x ## Installation @@ -11,75 +19,93 @@ bin/console plugin:install --activate ShopwareTranslationBridge ## Configuration -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`. +The Symfony translation providers themselves (their DSNs) are configured the usual Symfony way, e.g. in `config/packages/translation.yaml` under `framework.translator.providers`. The service name you give a provider there (for example `tolgee`) is the value you reference in the plugin settings. -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: +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 | +| Field | Type | Default | Description | |---------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------| -| 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` | +| Default translation provider | `string` | empty | Service name from `framework.translator.providers` (e.g. `tolgee`). Empty means no provider is used. | +| Respect local translation files | `bool` | `true` | Whether to overlay the snippets 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. +Both fields can be overridden per sales channel using the sales channel selector at the top of the config screen — pick a sales channel, set a different value, and save. This uses Shopware's standard system-config inheritance: a sales channel without its own value automatically falls back to the global default. Leaving everything empty is a valid, safe state — the plugin simply stays inactive and does not alter Shopware's default translation behaviour. ## Commands -This plugin provides three commands to manage translations. +### Pull snippets -### Push Snippets - -Pushes all local snippets to the configured translation provider. +Pulls snippets from the configured provider(s) and writes them locally into the directory defined by `framework.translator.default_path`. ```bash -bin/console sw:snippets:push [salesChannelId1] [salesChannelId2] +bin/console sw:snippets:pull ``` -**Arguments:** +The command takes no arguments or options; it resolves everything from the plugin configuration: -* `salesChannelId` (optional, multiple): The sales channel ID(s) to push translations for. If "default" or empty, the default provider is used. +* The global default provider (if configured) is pulled for all system locales and written to the `messages` translation domain. +* Every sales channel whose configured provider **differs** from the global default is pulled for that channel's locales and written to a domain named after the sales channel id. Sales channels that merely inherit the default are not pulled again — the `messages` domain already covers them. -**Options:** - -* `--force` / `-f`: Overwrite existing translations on the provider. -* `--delete-missing`: Delete translations on the provider that do not exist locally. -* `--locales` / `-l` (multiple): Specify the locales to push (e.g., `en-GB`, `de-DE`). If not provided, all relevant locales are pushed. +If nothing is configured, the command prints a warning and exits without writing anything. -### Pull Snippets +### Push 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 sales channel with an explicit provider override is persisted to a domain that matches the sales channel id. +Pushes local snippets to the configured provider. ```bash -bin/console sw:snippets:pull [salesChannelId1] +bin/console sw:snippets:push [salesChannelId ...] ``` **Arguments:** -* `salesChannelId` (optional, multiple): The sales channel ID(s) to pull translations for. If "default" or empty, the default provider is used. +* `salesChannelId` (optional, multiple): the sales channel id(s) to push for. If omitted or set to `default`, the global default provider is used; otherwise the effective provider of each given sales channel is used. **Options:** -* `--locales` / `-l` (multiple): Specify the locales to pull. If not provided, all relevant locales are pulled. +* `--force` / `-f`: overwrite translations that already exist on the provider (removes messages that are not synchronised). +* `--delete-missing`: delete translations on the provider that no longer exist locally. +* `--locales` / `-l` (multiple): restrict to specific locales (e.g. `de-DE`, `en-GB`). If omitted, all relevant locales are pushed. Locales that are not enabled cause the command to fail without touching the provider. -### Flush Translation Cache +### Flush translation cache -Flushes the translation cache. This is useful after pulling new translations to make them visible in the storefront. +Invalidates the translation cache. Useful after a pull to make new translations visible in the storefront. ```bash bin/console sw:cache:translation:flush ``` -## API Endpoint +## API endpoints + +Both endpoints live under the `/api` scope and require an authenticated admin API token. + +### Trigger a translation update -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). +Dispatches an asynchronous translation refresh for the given sales channels. This is intended for webhooks from translation providers (e.g. fired when a translation job completes). -* **URL:** `/api/_action/nlx-translation/update` -* **Body (JSON):** - ```json - { - "salesChannelIds": ["SALES_CHANNEL_ID_1", "SALES_CHANNEL_ID_2"] - } - ``` +* **URL:** `POST /api/_action/nlx-translation/update` +* **Body (JSON):** + ```json + { + "salesChannelIds": ["SALES_CHANNEL_ID_1", "SALES_CHANNEL_ID_2"] + } + ``` -## Asynchronous Processing +Sales channels without a resolvable provider are filtered out. If none remain, the endpoint responds with HTTP 503 and `errorMissingTranslationProvider`. -When the API endpoint is called, a message is dispatched to the Shopware message queue for each specified sales channel. A message handler then processes the queue and updates the translations for each sales channel asynchronously in the background. +### List available providers + +Returns the translation providers registered in `framework.translator.providers`. This backs the provider select field in the administration. + +* **URL:** `GET /api/_action/nlx-translation/providers` + +## Asynchronous processing + +When the update endpoint is called, the sales channel ids are dispatched to the Shopware message queue in batches. A message handler processes the queue and, per sales channel, invalidates the translation cache and warms up the catalogue for each of the channel's domains — so the refresh happens in the background without blocking the request. + +## Development + +```bash +composer test # run the unit test suite (PHPUnit) +composer phpstan # static analysis +composer lint # mago lint +composer format:fix # mago formatter +```