diff --git a/Readme.md b/Readme.md index 40a8947..058ba79 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] @@ -80,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 6f08107..2a046bc 100644 --- a/src/Command/FlushTranslationCacheCommand.php +++ b/src/Command/FlushTranslationCacheCommand.php @@ -1,6 +1,6 @@ defaultProvider) && $this->defaultProvider !== '') { + if ($this->translationProviderResolver->hasDefaultProvider()) { $domainsFetched += $this->fetchTranslations( - $this->defaultProvider, + $this->translationProviderResolver->getDefaultProvider(), self::REMOTE_DOMAIN, $this->getAllLocales(), $translationPath, @@ -62,13 +59,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); } - foreach ($this->salesChannelProviders as $salesChannelId => $providerName) { - if (!is_string($providerName)) { - continue; - } - + foreach ($this->getSalesChannelIdsWithProviderOverride() as $salesChannelId) { $domainsFetched += $this->fetchTranslations( - $providerName, + $this->translationProviderResolver->getSalesChannelProvider($salesChannelId), $salesChannelId, $this->getLocalesForSalesChannel($salesChannelId), $translationPath, @@ -93,23 +86,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->getProviderName($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; @@ -123,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; } @@ -143,12 +135,33 @@ private function fetchTranslations( return 1; } + 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 + ) + )); + } + /** * @return list */ 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 @@ configurationResolver->respectTranslationFiles($extension->salesChannelId); + // Force usage of translation files - if ($this->respectTranslationFiles) { + if ($respectTranslationFiles) { $this->respectTranslationFiles($extension); } @@ -42,6 +43,7 @@ public function __invoke(StorefrontSnippetsExtension $extension): void public static function skip(callable $callback): void { self::$skip = true; + try { $callback(); } finally { @@ -71,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 @@ 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 { 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->providerMap[$salesChannelId] ?? null; + $providerName = $this->resolveSalesChannelProviderName($salesChannelId); return $providerName !== null && $this->providerCollection->has($providerName); } @@ -57,11 +60,11 @@ public function getSalesChannelProvider(string $salesChannelId): ProviderInterfa return $this->providers[$salesChannelId]; } - if (!$this->hasProvider($salesChannelId)) { - throw new RuntimeException(\sprintf('No provider for salesChannel "%s" not found.', $salesChannelId)); + if (!$this->hasSalesChannelProvider($salesChannelId)) { + throw new RuntimeException(\sprintf('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); @@ -81,11 +84,23 @@ 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 + { + $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 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); + } + + 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..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; @@ -9,6 +7,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..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() { @@ -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 new file mode 100644 index 0000000..ae16b9e --- /dev/null +++ b/src/Resources/config/config.xml @@ -0,0 +1,39 @@ + + + + Translation Bridge + Translation Bridge + + + defaultProvider + + + + 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. + + + 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. + + + + + 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..4025996 100644 --- a/src/ShopwareTranslationBridge.php +++ b/src/ShopwareTranslationBridge.php @@ -1,115 +1,11 @@ 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 @@ +