From a57d96330cd008cbaa8b9c97af8cc450b1cf40bb Mon Sep 17 00:00:00 2001 From: Sascha Date: Mon, 16 Feb 2026 09:04:06 +0100 Subject: [PATCH 01/12] feat: init commit # Conflicts: # Readme.md --- .gitignore | 16 ++ AGENTS.md | 18 ++ Readme.md | 61 +++++ composer.json | 56 +++++ mago.toml | 41 ++++ phpunit.xml | 42 ++++ rector.php | 24 ++ src/.gitignore | 1 + src/Command/FlushTranslationCacheCommand.php | 28 +++ src/Command/PullSnippetsCommand.php | 219 ++++++++++++++++++ src/Command/PushSnippetsCommand.php | 187 +++++++++++++++ .../TranslationCacheInvalidation.php | 56 +++++ .../TranslationCacheInvalidationInterface.php | 20 ++ .../UpdateTranslationController.php | 61 +++++ src/Core/System/RelevantLocaleResolver.php | 51 ++++ .../RelevantLocaleResolverInterface.php | 12 + .../Listener/LoadTranslationsListener.php | 99 ++++++++ .../SalesChannelTranslationRefresher.php | 55 +++++ ...esChannelTranslationRefresherInterface.php | 10 + .../Snippet/TranslationProviderResolver.php | 73 ++++++ .../TranslationProviderResolverInterface.php | 18 ++ .../TranslationUpdateHandler.php | 25 ++ src/Message/TranslationUpdateMessage.php | 31 +++ src/Resources/app/administration/src/main.js | 23 ++ .../module/nlx-translation-update/index.js | 50 ++++ .../nlx-translation-update.html.twig | 27 +++ .../module/sw-settings-cache-index/index.js | 5 + .../sw-settings-cache-index.html.twig | 6 + .../src/service/translationApi.Service.js | 20 ++ .../app/administration/src/snippet/de-DE.json | 16 ++ .../app/administration/src/snippet/en-GB.json | 16 ++ src/Resources/config/routes.xml | 8 + src/ShopwareTranslationBridge.php | 115 +++++++++ tests/Unit/.gitkeep | 0 tests/bootstrap.php | 5 + 35 files changed, 1495 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 composer.json create mode 100644 mago.toml create mode 100644 phpunit.xml create mode 100644 rector.php create mode 100644 src/.gitignore create mode 100644 src/Command/FlushTranslationCacheCommand.php create mode 100644 src/Command/PullSnippetsCommand.php create mode 100644 src/Command/PushSnippetsCommand.php create mode 100644 src/Core/Framework/Adapter/Translator/TranslationCacheInvalidation.php create mode 100644 src/Core/Framework/Adapter/Translator/TranslationCacheInvalidationInterface.php create mode 100644 src/Core/Framework/Api/Controller/UpdateTranslationController.php create mode 100644 src/Core/System/RelevantLocaleResolver.php create mode 100644 src/Core/System/RelevantLocaleResolverInterface.php create mode 100644 src/Core/System/Snippet/Listener/LoadTranslationsListener.php create mode 100644 src/Core/System/Snippet/SalesChannelTranslationRefresher.php create mode 100644 src/Core/System/Snippet/SalesChannelTranslationRefresherInterface.php create mode 100644 src/Core/System/Snippet/TranslationProviderResolver.php create mode 100644 src/Core/System/Snippet/TranslationProviderResolverInterface.php create mode 100644 src/MesageHandler/TranslationUpdateHandler.php create mode 100644 src/Message/TranslationUpdateMessage.php create mode 100644 src/Resources/app/administration/src/main.js create mode 100644 src/Resources/app/administration/src/module/nlx-translation-update/index.js create mode 100644 src/Resources/app/administration/src/module/nlx-translation-update/nlx-translation-update.html.twig create mode 100644 src/Resources/app/administration/src/overrride/module/sw-settings-cache-index/index.js create mode 100644 src/Resources/app/administration/src/overrride/module/sw-settings-cache-index/sw-settings-cache-index.html.twig create mode 100644 src/Resources/app/administration/src/service/translationApi.Service.js create mode 100644 src/Resources/app/administration/src/snippet/de-DE.json create mode 100644 src/Resources/app/administration/src/snippet/en-GB.json create mode 100644 src/Resources/config/routes.xml create mode 100644 src/ShopwareTranslationBridge.php create mode 100644 tests/Unit/.gitkeep create mode 100644 tests/bootstrap.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8d136a --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +### Shopware +/src/Resources/app/storefront/dist/ +/src/Resources/app/storefront/node_modules/ +src/Resources/public/administration +src/Resources/public/storefront +src/Resources/public/static/css +src/Resources/public/static/js + +### PHPUnit +/.phpunit.cache +/.phpunit.result.cache +/.coverage + +### Composer +/vendor/ +composer.lock diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..16e931b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +## Do +- use this docker image to test ghcr.io/netlogix/docker/php-cli-dev:8.4 +- Run tests with `composer run test` +- Run `composer run apply-coding-standard` after code changes and before test +- Run `composer run lint` and fix issues after changes and after tests + +## Commands + +### composer run with php 8.4 +```bash +docker run --rm -it -v"$(pwd):/var/www" ghcr.io/netlogix/docker/php-cli-dev:8.4 composer run +``` + +### composer run with php 8.4 +```bash +docker run --rm -it -v"$(pwd):/var/www" ghcr.io/netlogix/docker/php-cli-dev:8.4 composer run +``` + \ No newline at end of file diff --git a/Readme.md b/Readme.md index ddfcf8f..b623258 100644 --- a/Readme.md +++ b/Readme.md @@ -1,3 +1,64 @@ # Symfony Translation Bridge With this plugin you can use the provider which can be defined in symfony/trnaslation to manage the storefront snippets. + +## Installation + +```bash +composer req netlogix/shopware-translation-bridge +``` + +## Configuration + +| 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 spesific providers. Like `default_provider` but individial for every salesChannel | + +### Example config + +```yaml +nlx_storefront_translation: + default_provider: 'providerServiceName' + respect_translation_files: true + sales_channel_providers: + 2b919afec10730f413cb5682bbed09fd: + provider: 'providerServiceName' + sales_channel_providers: + 2b919afec10730f413cb5682bbed09fd: + provider: 'fooPprovider' + e1582cd277454e988b8de2b878effc94: + provider: 'barProvider' +``` + +## Commands + +### Push snippets to translation provider + +To initial push the snippets to the translation provider you can use this command. +It provides several options to tailor the push to your needs. + +```bash +bin/console sw:snippets:push +``` + +### Flush translation cache + +With this command you can clear only the translation cache + +```bash +bin/console cache:translation:flush +``` + +### Fetch translations from providers + +Use this command to pull the remote storefront translations and store 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. + +```bash +bin/console sw:snippets:pull +``` + +## Components + +### Netlogix\ShopwareTranslationBridge\Core\System\RelevantLocaleResolverInterface \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..cc1106b --- /dev/null +++ b/composer.json @@ -0,0 +1,56 @@ +{ + "name": "netlogix/shopware-translation-bridge", + "description": "Load storefront translations from symfony/translation provider", + "license": "MIT", + "type": "shopware-platform-plugin", + "version": "v1.1.0", + "require": { + "php": ">8.3", + "shopware/storefront": ">=6.7.1.0", + "symfony/translation": ">7.0.0" + }, + "require-dev": { + "carthage-software/mago": "*", + "ergebnis/composer-normalize": "*", + "frosh/shopware-rector": "*", + "phpunit/phpunit": "^13.0.0" + }, + "autoload": { + "psr-4": { + "Netlogix\\ShopwareTranslationBridge\\": "src/" + } + }, + "config": { + "allow-plugins": { + "carthage-software/mago": true, + "ergebnis/composer-normalize": true, + "symfony/runtime": false + } + }, + "extra": { + "label": { + "de-DE": "Translation Bridge", + "en-GB": "Translation Bridge" + }, + "shopware-plugin-class": "Netlogix\\ShopwareTranslationBridge\\ShopwareTranslationBridge" + }, + "scripts": { + "apply-coding-standard": [ + "@composer:normalize:fix", + "@rector:fix", + "@format:fix" + ], + "composer:normalize": "@composer normalize --no-check-lock --dry-run", + "composer:normalize:fix": "@composer normalize --no-check-lock", + "format": "mago fmt --dry-run", + "format:fix": "mago fmt", + "lint": "mago lint", + "rector": "rector process --dry-run", + "rector:fix": "rector process", + "test": [ + "@test:unit" + ], + "test:coverage": "XDEBUG_MODE=coverage phpunit --testdox --coverage-html .coverage", + "test:unit": "phpunit --testdox --testsuite unit" + } +} diff --git a/mago.toml b/mago.toml new file mode 100644 index 0000000..5d18c0f --- /dev/null +++ b/mago.toml @@ -0,0 +1,41 @@ +# Welcome to Mago! +# For full documentation, see https://mago.carthage.software/tools/overview +php-version = "8.4.0" + +[source] +workspace = "." +paths = ["src/**/*.php", "tests/**/*.php"] +includes = ["vendor"] +excludes = [] + +[formatter] +preset = 'psr-12' +excludes = [] +end-of-line = "Lf" +single-quote = true +empty-line-before-return = true + +[linter] +integrations = ["symfony", "phpunit"] + +[linter.rules] +ambiguous-function-call = { enabled = false } +literal-named-argument = { enabled = false } +halstead = { effort-threshold = 7000 } +final-controller = { enabled = false } +no-boolean-flag-parameter = { enabled = false } + +[analyzer] +plugins = [] +find-unused-definitions = true +find-unused-expressions = false +analyze-dead-code = false +memoize-properties = true +allow-possibly-undefined-array-keys = true +check-throws = false +check-missing-override = false +find-unused-parameters = false +strict-list-index-checks = false +no-boolean-literal-comparison = false +check-missing-type-hints = false +register-super-globals = true diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..b253a9e --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + tests/Unit + + + + + + + + + src + + + diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..37b421a --- /dev/null +++ b/rector.php @@ -0,0 +1,24 @@ +withPaths([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + // uncomment to reach your current PHP version + ->withPhpVersion(PhpVersion::PHP_83) + ->withPhpSets(php83: true) + ->withImportNames(removeUnusedImports: true) + ->withTypeCoverageLevel(0) + ->withDeadCodeLevel(0) + ->withCodeQualityLevel(0) + ->withSets([ + ShopwareSetList::SHOPWARE_6_7_0, + ]); \ No newline at end of file diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..8fc2cf5 --- /dev/null +++ b/src/.gitignore @@ -0,0 +1 @@ +src/public \ No newline at end of file diff --git a/src/Command/FlushTranslationCacheCommand.php b/src/Command/FlushTranslationCacheCommand.php new file mode 100644 index 0000000..6f08107 --- /dev/null +++ b/src/Command/FlushTranslationCacheCommand.php @@ -0,0 +1,28 @@ +translationCacheInvalidation->invalidate(true); + + return Command::SUCCESS; + } +} diff --git a/src/Command/PullSnippetsCommand.php b/src/Command/PullSnippetsCommand.php new file mode 100644 index 0000000..6270e07 --- /dev/null +++ b/src/Command/PullSnippetsCommand.php @@ -0,0 +1,219 @@ +resolveTranslationPath(); + $this->ensureDirectoryExists($translationPath); + + $domainsFetched = 0; + + if (is_string($this->defaultProvider) && $this->defaultProvider !== '') { + $domainsFetched += $this->fetchTranslations( + $this->defaultProvider, + self::REMOTE_DOMAIN, + $this->getAllLocales(), + $translationPath, + $io + ); + } + + foreach ($this->salesChannelProviders as $salesChannelId => $providerName) { + if (!is_string($providerName)) { + continue; + } + + $domainsFetched += $this->fetchTranslations( + $providerName, + $salesChannelId, + $this->getLocalesForSalesChannel($salesChannelId), + $translationPath, + $io + ); + } + + if ($domainsFetched === 0) { + $io->warning( + 'No translations fetched. Configure a default provider or at least one sales channel provider.' + ); + + return Command::SUCCESS; + } + + $io->success(sprintf('Fetched translations for %d domain(s).', $domainsFetched)); + + return Command::SUCCESS; + } + + /** + * @param list $locales + */ + private function fetchTranslations( + string $providerName, + string $targetDomain, + array $locales, + string $translationPath, + SymfonyStyle $io + ): int { + 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; + + foreach ($translationBag->getCatalogues() as $catalogue) { + $messages = $catalogue->all(self::REMOTE_DOMAIN); + if ($messages === []) { + continue; + } + + $newCatalogue = new MessageCatalogue($catalogue->getLocale()); + $newCatalogue->add($messages, $targetDomain); + + $this->translationWriter->write($newCatalogue, 'json', ['path' => $translationPath]); + ++$written; + } + + if ($written === 0) { + $io->note(sprintf('Provider "%s" did not return messages for domain "%s".', $providerName, $targetDomain)); + + return 0; + } + + $io->writeln(sprintf( + 'Stored translations for domain "%s" from provider "%s" (%s).', + $targetDomain, + $providerName, + implode(', ', $locales) + )); + + return 1; + } + + /** + * @return list + */ + private function getAllLocales(): array + { + $criteria = (new Criteria()) + ->addAssociation('locale'); + $result = $this->languageRepository->search($criteria, Context::createCLIContext()); + $languages = $result->getEntities(); + assert($languages instanceof LanguageCollection); + + return $this->extractLocaleCodes($languages); + } + + /** + * @return list + */ + private function getLocalesForSalesChannel(string $salesChannelId): array + { + $criteria = (new Criteria([$salesChannelId]))->addAssociation('languages.locale'); + $salesChannel = $this->salesChannelRepository->search($criteria, Context::createCLIContext())->get( + $salesChannelId + ); + + if (!$salesChannel instanceof SalesChannelEntity) { + return []; + } + + $languages = $salesChannel->getLanguages(); + if (!$languages instanceof LanguageCollection) { + return []; + } + + return $this->extractLocaleCodes($languages); + } + + /** + * @return list + */ + private function extractLocaleCodes(LanguageCollection $languages): array + { + $codes = []; + foreach ($languages as $language) { + $code = $language->getLocale()?->getCode(); + if ($code !== null) { + $codes[] = $code; + } + } + + return array_values(array_unique($codes)); + } + + private function resolveTranslationPath(): string + { + $path = $this->parameterBag->has('framework.translator.default_path') + ? (string) $this->parameterBag->get('framework.translator.default_path') + : $this->projectDir . '/translations'; + + return rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . self::STORAGE_DIRECTORY; + } + + 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)); + } + } +} diff --git a/src/Command/PushSnippetsCommand.php b/src/Command/PushSnippetsCommand.php new file mode 100644 index 0000000..c9d913c --- /dev/null +++ b/src/Command/PushSnippetsCommand.php @@ -0,0 +1,187 @@ +mustSuggestArgumentValuesFor('provider')) { + $suggestions->suggestValues($this->getProviderKeys()); + } + } + + /** + * @return list + */ + private function getProviderKeys(): array + { + return $this->providers->keys(); + } + + protected function configure(): void + { + $this->setDefinition([ + new InputArgument( + 'salesChannelId', + InputArgument::IS_ARRAY, + 'The salesChannelId which should be updated. If "default" or empty it updates the default provider.', + [] + ), + new InputOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Override existing translations with local ones (it will delete not synchronized messages).' + ), + new InputOption( + 'delete-missing', + null, + InputOption::VALUE_NONE, + 'Delete translations available on provider but not locally.' + ), + new InputOption( + 'locales', + 'l', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Specify the locales to push.' + ) + ]); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $salesChannelIds = $input->getArgument('salesChannelId'); + + assert(is_array($salesChannelIds)); + $updateDefaultProvider = $salesChannelIds === [] || key_exists('default', $salesChannelIds); + + // @todo: ... + + /** @var string[] $locales */ + $locales = $input->getOption('locales'); + assert(is_array($locales)); + if ($locales === []) { + $locales = $this->relevantLocaleResolver->getAll(); + $io->info(sprintf('The following locales are going to be pushed: %s', implode(', ', $locales))); + } else { + $missingLocales = array_diff($locales, $this->relevantLocaleResolver->getAll()); + if ($missingLocales !== []) { + $io->error(sprintf('The following locales are not enabled: %s', implode(', ', $missingLocales))); + + return Command::FAILURE; + } + } + + $force = $input->getOption('force'); + assert(is_bool($force)); + $deleteMissing = $input->getOption('delete-missing'); + $localTranslations = $this->getTranslations($locales); + + if (!$deleteMissing && $force) { + $provider->write($localTranslations); + $io->success( + \sprintf( + 'All local translations has been sent to "%s" (for "%s" locale(s)).', + parse_url((string) $provider, \PHP_URL_SCHEME), + implode(', ', $locales) + ) + ); + + return Command::SUCCESS; + } + + $providerTranslations = $provider->read(['messages'], $locales); + + if ($deleteMissing) { + $provider->delete($providerTranslations->diff($localTranslations)); + + $io->success( + \sprintf( + 'Missing translations on "%s" has been deleted (for "%s" locale(s)).', + parse_url((string) $provider, \PHP_URL_SCHEME), + implode(', ', $locales) + ) + ); + + $providerTranslations = $provider->read(['messages'], $locales); + } + + $translationsToWrite = $localTranslations->diff($providerTranslations); + + if ($force) { + $translationsToWrite->addBag($localTranslations->intersect($providerTranslations)); + } + + $provider->write($translationsToWrite); + + $io->success( + \sprintf( + '%s local translations has been sent to "%s" (for "%s" locale(s)).', + $force ? 'All' : 'New', + parse_url((string) $provider, \PHP_URL_SCHEME), + implode(', ', $locales) + ) + ); + + return Command::SUCCESS; + } + + /** + * @param string[] $locales + */ + private function getTranslations(array $locales): TranslatorBag + { + $translationBag = new TranslatorBag(); + LoadTranslationsListener::skip(function () use ($locales, $translationBag): void { + foreach ($locales as $locale) { + $catalogue = new MessageCatalogue($locale); + $snippetSetId = $this->translator->getSnippetSetId($catalogue->getLocale()); + assert($snippetSetId !== null); + $translations = $this->snippetService->getStorefrontSnippets($catalogue, $snippetSetId); + $catalogue->add($translations, 'messages'); + $translationBag->addCatalogue($catalogue); + } + }); + + return $translationBag; + } +} diff --git a/src/Core/Framework/Adapter/Translator/TranslationCacheInvalidation.php b/src/Core/Framework/Adapter/Translator/TranslationCacheInvalidation.php new file mode 100644 index 0000000..0cc2c61 --- /dev/null +++ b/src/Core/Framework/Adapter/Translator/TranslationCacheInvalidation.php @@ -0,0 +1,56 @@ +cacheInvalidator->invalidate([Translator::ALL_CACHE_TAG], $force); + } + + public function invalidateBySnippetSet(string $snippetSetId, bool $force = false): void + { + $cacheTag = match (Feature::isActive('cache_rework')) { + true => Translator::tag($snippetSetId), + false => 'translation.catalog.' . $snippetSetId + }; + + $this->cacheInvalidator->invalidate([$cacheTag], $force); + } + + public function invalidateBySalesChannel(string $salesChannelId, bool $force = false): void + { + $cacheTag = match (Feature::isActive('cache_rework')) { + true => Translator::tag($salesChannelId), + false => 'translation.catalog.' . $salesChannelId + }; + + $this->cacheInvalidator->invalidate([$cacheTag], $force); + } + + public function invalidateBySalesChannelAndSnippetSet( + string $salesChannelId, + string $snippetSetId, + bool $force = false + ): void { + $key = \sprintf( + 'translation.catalog.%s.%s', + $salesChannelId === '' ? 'DEFAULT' : $salesChannelId, + $snippetSetId + ); + + $this->cacheInvalidator->invalidate([$key], $force); + } +} diff --git a/src/Core/Framework/Adapter/Translator/TranslationCacheInvalidationInterface.php b/src/Core/Framework/Adapter/Translator/TranslationCacheInvalidationInterface.php new file mode 100644 index 0000000..5d7822b --- /dev/null +++ b/src/Core/Framework/Adapter/Translator/TranslationCacheInvalidationInterface.php @@ -0,0 +1,20 @@ + ['api'], + '_acl' => ['system:cache:info'] + ], + methods: ['POST'] +)] +class UpdateTranslationController extends AbstractController +{ + function __construct( + private readonly EntityRepository $salesChannelRepository, + private readonly MessageBusInterface $messageBus, + private readonly TranslationProviderResolverInterface $translationProviderResolver, + private readonly int $batchSize = 5 + ) { + } + + function __invoke(): JsonApiResponse + { + $salesChannelIds = $this->salesChannelRepository + ->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->hasProvider(...)); + } + + if ($salesChannelIds === []) { + return new JsonApiResponse([ + 'success' => false, + 'error' => 'errorMissingTranslationProvider' + ], Response::HTTP_SERVICE_UNAVAILABLE); + } + + foreach (array_chunk($salesChannelIds, $this->batchSize) as $chunk) { + $this->messageBus->dispatch(new TranslationUpdateMessage(...$chunk)); + } + + return new JsonApiResponse(['success' => true]); + } +} diff --git a/src/Core/System/RelevantLocaleResolver.php b/src/Core/System/RelevantLocaleResolver.php new file mode 100644 index 0000000..18aa5fd --- /dev/null +++ b/src/Core/System/RelevantLocaleResolver.php @@ -0,0 +1,51 @@ +resolve(); + } + + public function getForSalesChannel(string $salesChannelId): array + { + return $this->resolve($salesChannelId); + } + + private function resolve(?string $salesChannelId = null): array + { + $criteria = new Criteria($salesChannelId ? [$salesChannelId] : null); + $criteria->addAssociation('languages.locale'); + $salesChannels = $this->salesChannelRepository->search($criteria, Context::createCLIContext()); + $languages = new LanguageCollection(); + foreach ($salesChannels as $salesChannel) { + assert($salesChannel instanceof SalesChannelEntity); + $salesChannelLanguages = $salesChannel->getLanguages(); + if ($salesChannelLanguages === null) { + continue; + } + assert($salesChannelLanguages instanceof LanguageCollection); + + $languages->merge($salesChannelLanguages); + } + + 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 new file mode 100644 index 0000000..68188e6 --- /dev/null +++ b/src/Core/System/RelevantLocaleResolverInterface.php @@ -0,0 +1,12 @@ +stopPropagation(); + + // Force usage of translation files + if ($this->respectTranslationFiles) { + $this->respectTranslationFiles($extension); + } + + $this->applyProviderTranslations($extension); + } + + public static function skip(callable $callback): void + { + self::$skip = true; + try { + $callback(); + } finally { + self::$skip = false; + } + } + + private function respectTranslationFiles(StorefrontSnippetsExtension $extension): void + { + $salesChannelId = $extension->salesChannelId; + $catalog = $extension->catalog; + + foreach ($extension->snippets as $key => $value) { + if ($catalog->has($key, $salesChannelId)) { + $extension->result[$key] = $catalog->get($key, $salesChannelId); + } elseif (!$catalog->has($key, self::TRANSLATION_DOMAIN)) { + $extension->result[$key] = $value; + } + } + } + + private function applyProviderTranslations(StorefrontSnippetsExtension $extension): void + { + $provider = match (true) { + $this->providerResolver->hasProvider($extension->salesChannelId) => + $this->providerResolver->getProvider($extension->salesChannelId), + $this->providerResolver->hasDefaultProvider() => $this->providerResolver->getDefaultProvider(), + default => null + }; + + if ($provider == null) { + return; + } + + $locales = array_unique(array_filter([ + $extension->locale, + $extension->fallbackLocale + ])); + + $translationBag = $provider->read([self::TRANSLATION_DOMAIN], $locales); + + $catalogue = $translationBag->getCatalogue($extension->locale); + $fallbackCatalogue = is_string($extension->fallbackLocale) ? + $translationBag->getCatalogue($extension->fallbackLocale) : null; + + foreach ($extension->result as $key => $value) { + if ($catalogue->has($key, self::TRANSLATION_DOMAIN)) { + $extension->result[$key] = $catalogue->get($key, self::TRANSLATION_DOMAIN); + } + if ($fallbackCatalogue?->has($key, self::TRANSLATION_DOMAIN)) { + $extension->result[$key] = $fallbackCatalogue->get($key, self::TRANSLATION_DOMAIN); + } + } + } +} diff --git a/src/Core/System/Snippet/SalesChannelTranslationRefresher.php b/src/Core/System/Snippet/SalesChannelTranslationRefresher.php new file mode 100644 index 0000000..49179a9 --- /dev/null +++ b/src/Core/System/Snippet/SalesChannelTranslationRefresher.php @@ -0,0 +1,55 @@ +translationCacheInvalidation->invalidateBySalesChannel($salesChannelId, true); + } + + $criteria = new Criteria(); + $criteria->addFilter(new EqualsAnyFilter('salesChannelId', $salesChannelIds)); + $criteria->addAssociation('language.locale'); + try { + $this->salesChannelDomainRepository + ->search($criteria, Context::createCLIContext()) + ->map($this->warmUpTranslation(...)); + } finally { + $this->translator->resetInjection(); + } + } + + private function warmUpTranslation(SalesChannelDomainEntity $domain): void + { + $this->translator->injectSettings( + $domain->getSalesChannelId(), + $domain->getLanguageId(), + $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 new file mode 100644 index 0000000..5ad68c8 --- /dev/null +++ b/src/Core/System/Snippet/SalesChannelTranslationRefresherInterface.php @@ -0,0 +1,10 @@ +defaultProvider !== null && $this->providerCollection->has($this->defaultProvider); + } + + public function getDefaultProvider(): ProviderInterface + { + if (!$this->hasDefaultProvider()) { + throw new RuntimeException(\sprintf('Provider "%s" not found.', $this->defaultProvider)); + } + + return $this->providers['default'] = $this->providerCollection->get($this->defaultProvider); + } + + public function hasProvider(string $salesChannelId): bool + { + if (!Uuid::isValid($salesChannelId)) { + throw new \InvalidArgumentException(\sprintf('Provider "%s" is not a valid UUID.', $salesChannelId)); + } + + $providerName = $this->providerMap[$salesChannelId] ?? null; + + return $providerName !== null && $this->providerCollection->has($providerName); + } + + public function getProvider(string $salesChannelId): ProviderInterface + { + if (isset($this->providers[$salesChannelId])) { + return $this->providers[$salesChannelId]; + } + + if (!$this->hasProvider($salesChannelId)) { + throw new RuntimeException(\sprintf('No provider for salesChannel "%s" not found.', $salesChannelId)); + } + + $providerName = $this->providerMap[$salesChannelId]; + assert(is_string($providerName)); + + return $this->providers[$salesChannelId] = $this->providerCollection->get($providerName); + } + + public function reset(): void + { + unset($this->providerss); + } +} diff --git a/src/Core/System/Snippet/TranslationProviderResolverInterface.php b/src/Core/System/Snippet/TranslationProviderResolverInterface.php new file mode 100644 index 0000000..816100d --- /dev/null +++ b/src/Core/System/Snippet/TranslationProviderResolverInterface.php @@ -0,0 +1,18 @@ +salesChannelIds as $salesChannelId) { + $this->salesChannelTranslationRefresher->refresh($salesChannelId); + } + } +} diff --git a/src/Message/TranslationUpdateMessage.php b/src/Message/TranslationUpdateMessage.php new file mode 100644 index 0000000..0bf395c --- /dev/null +++ b/src/Message/TranslationUpdateMessage.php @@ -0,0 +1,31 @@ +salesChannelIds = $salesChannelIds; + } +} diff --git a/src/Resources/app/administration/src/main.js b/src/Resources/app/administration/src/main.js new file mode 100644 index 0000000..0faaa95 --- /dev/null +++ b/src/Resources/app/administration/src/main.js @@ -0,0 +1,23 @@ +import './module/nlx-translation-update' +import './overrride/module/sw-settings-cache-index' +import TranslationApiService from "./service/translationApi.Service"; + +const {Application} = Shopware; + +Shopware.Component.register( + 'nlx-translation-update', + () => import('./module/nlx-translation-update') +); + +Shopware.Component.override( + 'sw-settings-cache-index', + () => import('./overrride/module/sw-settings-cache-index') +); + +Application.addServiceProvider( + 'nlxTranslationApiService', + (container) => new TranslationApiService( + Application.getContainer('init').httpClient, + container.loginService + ) +); diff --git a/src/Resources/app/administration/src/module/nlx-translation-update/index.js b/src/Resources/app/administration/src/module/nlx-translation-update/index.js new file mode 100644 index 0000000..643a966 --- /dev/null +++ b/src/Resources/app/administration/src/module/nlx-translation-update/index.js @@ -0,0 +1,50 @@ +import template from './nlx-translation-update.html.twig' + +const {Mixin} = Shopware; + +export default { + template, + + mixins: [ + Mixin.getByName('notification') + ], + + inject: [ + 'nlxTranslationApiService' + ], + + data() { + return { + componentIsBuilding: true, + processes: false, + processSuccess: false + }; + }, + + methods: { + updateTranslation() { + this.processes = true; + + this.nlxTranslationApiService + .updateTranslation() + .then(() => { + this.createNotificationSuccess({ + message: this.$tc('nlx-storefront-translation.nlx-translation-update.updateTranslation.success'), + }); + }) + .catch((e) => { + const error = e.response?.data?.error ?? 'error'; + this.createNotificationError({ + message: this.$tc('nlx-storefront-translation.nlx-translation-update.updateTranslation.' + error), + }); + this.processSuccess = false; + }) + .finally(() => { + this.processes = false; + }) + }, + resetButton() { + this.processSuccess = true; + } + } +} diff --git a/src/Resources/app/administration/src/module/nlx-translation-update/nlx-translation-update.html.twig b/src/Resources/app/administration/src/module/nlx-translation-update/nlx-translation-update.html.twig new file mode 100644 index 0000000..6177b68 --- /dev/null +++ b/src/Resources/app/administration/src/module/nlx-translation-update/nlx-translation-update.html.twig @@ -0,0 +1,27 @@ + + +
+ {% block nlx_translation_update_row_heading %} +

+ {{ $tc('nlx-storefront-translation.nlx-translation-update.section.translationHeadline') }} +

+ {% endblock %} + {% block nlx_translation_update_row_text %} +

{{ $tc('nlx-storefront-translation.nlx-translation-update.section.translationText') }}

+ {% endblock %} +
+ + + {{ $tc('nlx-storefront-translation.nlx-translation-update.updateTranslations') }} + +
+
diff --git a/src/Resources/app/administration/src/overrride/module/sw-settings-cache-index/index.js b/src/Resources/app/administration/src/overrride/module/sw-settings-cache-index/index.js new file mode 100644 index 0000000..c67c869 --- /dev/null +++ b/src/Resources/app/administration/src/overrride/module/sw-settings-cache-index/index.js @@ -0,0 +1,5 @@ +import template from './sw-settings-cache-index.html.twig' + +export default { + template +} diff --git a/src/Resources/app/administration/src/overrride/module/sw-settings-cache-index/sw-settings-cache-index.html.twig b/src/Resources/app/administration/src/overrride/module/sw-settings-cache-index/sw-settings-cache-index.html.twig new file mode 100644 index 0000000..f508a02 --- /dev/null +++ b/src/Resources/app/administration/src/overrride/module/sw-settings-cache-index/sw-settings-cache-index.html.twig @@ -0,0 +1,6 @@ +{% block sw_settings_cache_content_indexes_row %} + {% parent %} + {% block sw_settings_cache_content_translation_row %} + + {% endblock %} +{% endblock %} diff --git a/src/Resources/app/administration/src/service/translationApi.Service.js b/src/Resources/app/administration/src/service/translationApi.Service.js new file mode 100644 index 0000000..dfbd583 --- /dev/null +++ b/src/Resources/app/administration/src/service/translationApi.Service.js @@ -0,0 +1,20 @@ +const {ApiService} = Shopware.Classes; + +export default class NlxTranslationApiService extends ApiService { + constructor(httpClient, loginService, apiEndpoint = '_action/nlx-translation') { + super(httpClient, loginService, apiEndpoint); + this.name = 'nlxNeosContentApiService'; + } + + updateTranslation() { + return this.httpClient + .post( + `${this.getApiBasePath()}/update`, + {}, + { + headers: this.getBasicHeaders(), + } + ) + .then((response) => ApiService.handleResponse(response)); + } +} diff --git a/src/Resources/app/administration/src/snippet/de-DE.json b/src/Resources/app/administration/src/snippet/de-DE.json new file mode 100644 index 0000000..445915d --- /dev/null +++ b/src/Resources/app/administration/src/snippet/de-DE.json @@ -0,0 +1,16 @@ +{ + "nlx-storefront-translation": { + "nlx-translation-update": { + "updateTranslations": "Übersetungen Aktualisieren", + "section": { + "translationHeadline": "Storefront-Übersetzung", + "translationText": "Löscht den Übersetzungs-Cache und führt ein Warm-up durch. Dies wird asynchron über eine Message ausgeführt." + }, + "updateTranslation": { + "success": "Übersetzung erfolgreich aktualisiert", + "error": "Fehler beim Aktualisieren der Übersetzung", + "errorMissingTranslationProvider": "Es konnte kein TranslationProvider gefunen werden." + } + } + } +} diff --git a/src/Resources/app/administration/src/snippet/en-GB.json b/src/Resources/app/administration/src/snippet/en-GB.json new file mode 100644 index 0000000..02f4583 --- /dev/null +++ b/src/Resources/app/administration/src/snippet/en-GB.json @@ -0,0 +1,16 @@ +{ + "nlx-storefront-translation": { + "nlx-translation-update": { + "updateTranslations": "Update translations", + "section": { + "translationHeadline": "Storefront Translation", + "translationText": "Clears the translation cache and performs a warm-up. This is executed asynchronously via a message." + }, + "updateTranslation": { + "success": "Translation successfully updated", + "error": "Error while updating the translation", + "errorMissingTranslationProvider": "There is no TranslationProvider." + } + } + } +} diff --git a/src/Resources/config/routes.xml b/src/Resources/config/routes.xml new file mode 100644 index 0000000..d13d0f3 --- /dev/null +++ b/src/Resources/config/routes.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/src/ShopwareTranslationBridge.php b/src/ShopwareTranslationBridge.php new file mode 100644 index 0000000..1a94b9c --- /dev/null +++ b/src/ShopwareTranslationBridge.php @@ -0,0 +1,115 @@ +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/tests/Unit/.gitkeep b/tests/Unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..1a6e421 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,5 @@ + Date: Mon, 16 Feb 2026 09:31:48 +0100 Subject: [PATCH 02/12] feat: skip snippet loading from db if a provider ist configured --- .../Snippet/Listener/LoadTranslationsListener.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Core/System/Snippet/Listener/LoadTranslationsListener.php b/src/Core/System/Snippet/Listener/LoadTranslationsListener.php index 29b1f04..a75069b 100644 --- a/src/Core/System/Snippet/Listener/LoadTranslationsListener.php +++ b/src/Core/System/Snippet/Listener/LoadTranslationsListener.php @@ -29,14 +29,15 @@ public function __invoke(StorefrontSnippetsExtension $extension): void return; } - $extension->stopPropagation(); // Force usage of translation files if ($this->respectTranslationFiles) { $this->respectTranslationFiles($extension); } - $this->applyProviderTranslations($extension); + if ($this->applyProviderTranslations($extension)) { + $extension->stopPropagation(); + } } public static function skip(callable $callback): void @@ -63,7 +64,7 @@ private function respectTranslationFiles(StorefrontSnippetsExtension $extension) } } - private function applyProviderTranslations(StorefrontSnippetsExtension $extension): void + private function applyProviderTranslations(StorefrontSnippetsExtension $extension): bool { $provider = match (true) { $this->providerResolver->hasProvider($extension->salesChannelId) => @@ -73,7 +74,7 @@ private function applyProviderTranslations(StorefrontSnippetsExtension $extensio }; if ($provider == null) { - return; + return false; } $locales = array_unique(array_filter([ @@ -95,5 +96,7 @@ private function applyProviderTranslations(StorefrontSnippetsExtension $extensio $extension->result[$key] = $fallbackCatalogue->get($key, self::TRANSLATION_DOMAIN); } } + + return true; } } From 0317e787a66bf705f30b894f3d80c6623a4aa0cf Mon Sep 17 00:00:00 2001 From: Sascha Date: Mon, 16 Feb 2026 09:36:27 +0100 Subject: [PATCH 03/12] feat: refresh multiple salesChannels at once --- src/MesageHandler/TranslationUpdateHandler.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/MesageHandler/TranslationUpdateHandler.php b/src/MesageHandler/TranslationUpdateHandler.php index f7e6b09..c0725a5 100644 --- a/src/MesageHandler/TranslationUpdateHandler.php +++ b/src/MesageHandler/TranslationUpdateHandler.php @@ -1,6 +1,6 @@ salesChannelIds as $salesChannelId) { - $this->salesChannelTranslationRefresher->refresh($salesChannelId); - } + $this->salesChannelTranslationRefresher->refresh(...$message->salesChannelIds); } } From 89d7bb3d24cf83abde09f72a575e1bd3e5f9953d Mon Sep 17 00:00:00 2001 From: Sascha Date: Mon, 16 Feb 2026 09:45:13 +0100 Subject: [PATCH 04/12] feat: optimize TranslationProviderResolver --- .../UpdateTranslationController.php | 2 +- .../Listener/LoadTranslationsListener.php | 11 +++------- .../Snippet/TranslationProviderResolver.php | 21 +++++++++++++++++-- .../TranslationProviderResolverInterface.php | 4 ++++ 4 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/Core/Framework/Api/Controller/UpdateTranslationController.php b/src/Core/Framework/Api/Controller/UpdateTranslationController.php index b25a6e4..eed611d 100644 --- a/src/Core/Framework/Api/Controller/UpdateTranslationController.php +++ b/src/Core/Framework/Api/Controller/UpdateTranslationController.php @@ -42,7 +42,7 @@ function __invoke(): JsonApiResponse # 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->hasProvider(...)); + $salesChannelIds = array_filter($salesChannelIds, $this->translationProviderResolver->hasSalesChannelProvider(...)); } if ($salesChannelIds === []) { diff --git a/src/Core/System/Snippet/Listener/LoadTranslationsListener.php b/src/Core/System/Snippet/Listener/LoadTranslationsListener.php index a75069b..a0db360 100644 --- a/src/Core/System/Snippet/Listener/LoadTranslationsListener.php +++ b/src/Core/System/Snippet/Listener/LoadTranslationsListener.php @@ -66,17 +66,12 @@ private function respectTranslationFiles(StorefrontSnippetsExtension $extension) private function applyProviderTranslations(StorefrontSnippetsExtension $extension): bool { - $provider = match (true) { - $this->providerResolver->hasProvider($extension->salesChannelId) => - $this->providerResolver->getProvider($extension->salesChannelId), - $this->providerResolver->hasDefaultProvider() => $this->providerResolver->getDefaultProvider(), - default => null - }; - - if ($provider == null) { + if (!$this->providerResolver->hasProvider($extension->salesChannelId)) { return false; } + $provider = $this->providerResolver->getProvider($extension->salesChannelId); + $locales = array_unique(array_filter([ $extension->locale, $extension->fallbackLocale diff --git a/src/Core/System/Snippet/TranslationProviderResolver.php b/src/Core/System/Snippet/TranslationProviderResolver.php index 74b5b28..185f0fc 100644 --- a/src/Core/System/Snippet/TranslationProviderResolver.php +++ b/src/Core/System/Snippet/TranslationProviderResolver.php @@ -39,7 +39,7 @@ public function getDefaultProvider(): ProviderInterface return $this->providers['default'] = $this->providerCollection->get($this->defaultProvider); } - public function hasProvider(string $salesChannelId): bool + public function hasSalesChannelProvider(string $salesChannelId): bool { if (!Uuid::isValid($salesChannelId)) { throw new \InvalidArgumentException(\sprintf('Provider "%s" is not a valid UUID.', $salesChannelId)); @@ -50,7 +50,7 @@ public function hasProvider(string $salesChannelId): bool return $providerName !== null && $this->providerCollection->has($providerName); } - public function getProvider(string $salesChannelId): ProviderInterface + public function getSalesChannelProvider(string $salesChannelId): ProviderInterface { if (isset($this->providers[$salesChannelId])) { return $this->providers[$salesChannelId]; @@ -66,6 +66,23 @@ public function getProvider(string $salesChannelId): ProviderInterface return $this->providers[$salesChannelId] = $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('No provider for salesChannel "%s" not found.', $salesChannelId)); + } + public function reset(): void { unset($this->providerss); diff --git a/src/Core/System/Snippet/TranslationProviderResolverInterface.php b/src/Core/System/Snippet/TranslationProviderResolverInterface.php index 816100d..c1f6564 100644 --- a/src/Core/System/Snippet/TranslationProviderResolverInterface.php +++ b/src/Core/System/Snippet/TranslationProviderResolverInterface.php @@ -12,6 +12,10 @@ public function hasDefaultProvider(): 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; From 6e123858187075c03d5b519c871ca8dc91ca421b Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Tue, 17 Feb 2026 08:57:56 +0100 Subject: [PATCH 05/12] refactor: fixed text typos --- Readme.md | 8 ++++---- src/Resources/app/administration/src/snippet/de-DE.json | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Readme.md b/Readme.md index b623258..30a3480 100644 --- a/Readme.md +++ b/Readme.md @@ -13,13 +13,13 @@ composer req netlogix/shopware-translation-bridge | 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` | +| respect_translation_files | `bool` | `true` | should it overlay the snippet files with the translation files `framework.translator.default_path` | | sales_channel_providers | `array` | `[]` | SalesChannel spesific providers. Like `default_provider` but individial for every salesChannel | ### Example config ```yaml -nlx_storefront_translation: +shopware_translation_bridge: default_provider: 'providerServiceName' respect_translation_files: true sales_channel_providers: @@ -27,7 +27,7 @@ nlx_storefront_translation: provider: 'providerServiceName' sales_channel_providers: 2b919afec10730f413cb5682bbed09fd: - provider: 'fooPprovider' + provider: 'fooProvider' e1582cd277454e988b8de2b878effc94: provider: 'barProvider' ``` @@ -61,4 +61,4 @@ bin/console sw:snippets:pull ## Components -### Netlogix\ShopwareTranslationBridge\Core\System\RelevantLocaleResolverInterface \ No newline at end of file +### Netlogix\ShopwareTranslationBridge\Core\System\RelevantLocaleResolverInterface diff --git a/src/Resources/app/administration/src/snippet/de-DE.json b/src/Resources/app/administration/src/snippet/de-DE.json index 445915d..b17b28d 100644 --- a/src/Resources/app/administration/src/snippet/de-DE.json +++ b/src/Resources/app/administration/src/snippet/de-DE.json @@ -1,7 +1,7 @@ { "nlx-storefront-translation": { "nlx-translation-update": { - "updateTranslations": "Übersetungen Aktualisieren", + "updateTranslations": "Übersetzungen aktualisieren", "section": { "translationHeadline": "Storefront-Übersetzung", "translationText": "Löscht den Übersetzungs-Cache und führt ein Warm-up durch. Dies wird asynchron über eine Message ausgeführt." @@ -9,7 +9,7 @@ "updateTranslation": { "success": "Übersetzung erfolgreich aktualisiert", "error": "Fehler beim Aktualisieren der Übersetzung", - "errorMissingTranslationProvider": "Es konnte kein TranslationProvider gefunen werden." + "errorMissingTranslationProvider": "Es konnte kein TranslationProvider gefunden werden." } } } From 789bd263e59281d7b482f094e82030db35743357 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Tue, 17 Feb 2026 10:06:29 +0100 Subject: [PATCH 06/12] feat: made push snippets command work with sales channel providers --- src/Command/PushSnippetsCommand.php | 119 +++++++++++++++++++--------- 1 file changed, 81 insertions(+), 38 deletions(-) diff --git a/src/Command/PushSnippetsCommand.php b/src/Command/PushSnippetsCommand.php index c9d913c..3a0eb27 100644 --- a/src/Command/PushSnippetsCommand.php +++ b/src/Command/PushSnippetsCommand.php @@ -5,7 +5,7 @@ namespace Netlogix\ShopwareTranslationBridge\Command; use Netlogix\ShopwareTranslationBridge\Core\System\RelevantLocaleResolverInterface; -use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\LoadTranslationsListener; +use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\Listener\LoadTranslationsListener; use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\TranslationProviderResolverInterface; use Override; use Shopware\Core\Framework\Adapter\Translation\AbstractTranslator; @@ -22,6 +22,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Component\Translation\Provider\ProviderInterface; use Symfony\Component\Translation\Provider\TranslationProviderCollection; use Symfony\Component\Translation\TranslatorBag; @@ -44,18 +45,10 @@ function __construct( public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void { if ($input->mustSuggestArgumentValuesFor('provider')) { - $suggestions->suggestValues($this->getProviderKeys()); + $suggestions->suggestValues($this->providers->keys()); } } - /** - * @return list - */ - private function getProviderKeys(): array - { - return $this->providers->keys(); - } - protected function configure(): void { $this->setDefinition([ @@ -89,55 +82,103 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $salesChannelIds = $input->getArgument('salesChannelId'); - assert(is_array($salesChannelIds)); - $updateDefaultProvider = $salesChannelIds === [] || key_exists('default', $salesChannelIds); + $locales = $this->resolveLocales($input, $io); + if ($locales === null) { + return Command::FAILURE; + } + + $force = $input->getOption('force'); + assert(is_bool($force)); + $deleteMissing = $input->getOption('delete-missing'); + assert(is_bool($deleteMissing)); + + $localTranslations = $this->getTranslations($locales); + $providers = $this->resolveProviders($input); - // @todo: ... + foreach ($providers as $provider) { + $this->processProvider($provider, $localTranslations, $locales, $force, $deleteMissing, $io); + } + return Command::SUCCESS; + } + + /** + * @return list|null + */ + private function resolveLocales(InputInterface $input, SymfonyStyle $io): ?array + { /** @var string[] $locales */ $locales = $input->getOption('locales'); assert(is_array($locales)); - if ($locales === []) { - $locales = $this->relevantLocaleResolver->getAll(); - $io->info(sprintf('The following locales are going to be pushed: %s', implode(', ', $locales))); - } else { + + if ($locales !== []) { $missingLocales = array_diff($locales, $this->relevantLocaleResolver->getAll()); if ($missingLocales !== []) { $io->error(sprintf('The following locales are not enabled: %s', implode(', ', $missingLocales))); - return Command::FAILURE; + return null; } + return $locales; } - $force = $input->getOption('force'); - assert(is_bool($force)); - $deleteMissing = $input->getOption('delete-missing'); - $localTranslations = $this->getTranslations($locales); + $locales = $this->relevantLocaleResolver->getAll(); + $io->info(sprintf('The following locales are going to be pushed: %s', implode(', ', $locales))); + return $locales; + } + + /** + * @return list + */ + private function resolveProviders(InputInterface $input): array + { + $salesChannelIds = $input->getArgument('salesChannelId'); + assert(is_array($salesChannelIds)); + + if ($salesChannelIds === [] || in_array('default', $salesChannelIds, true)) { + return [$this->translationProviderResolver->getDefaultProvider()]; + } + + $providers = []; + foreach ($salesChannelIds as $salesChannelId) { + $providers[] = $this->translationProviderResolver->getSalesChannelProvider($salesChannelId); + } + + return $providers; + } + + /** + * @param string[] $locales + */ + private function processProvider( + ProviderInterface $provider, + TranslatorBag $localTranslations, + array $locales, + bool $force, + bool $deleteMissing, + SymfonyStyle $io + ): void { if (!$deleteMissing && $force) { $provider->write($localTranslations); $io->success( - \sprintf( - 'All local translations has been sent to "%s" (for "%s" locale(s)).', - parse_url((string) $provider, \PHP_URL_SCHEME), + sprintf( + 'All local translations have been sent to "%s" (for "%s" locale(s)).', + $this->getProviderName($provider), implode(', ', $locales) ) ); - - return Command::SUCCESS; + return; } $providerTranslations = $provider->read(['messages'], $locales); if ($deleteMissing) { $provider->delete($providerTranslations->diff($localTranslations)); - $io->success( - \sprintf( - 'Missing translations on "%s" has been deleted (for "%s" locale(s)).', - parse_url((string) $provider, \PHP_URL_SCHEME), + sprintf( + 'Missing translations on "%s" have been deleted (for "%s" locale(s)).', + $this->getProviderName($provider), implode(', ', $locales) ) ); @@ -146,7 +187,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $translationsToWrite = $localTranslations->diff($providerTranslations); - if ($force) { $translationsToWrite->addBag($localTranslations->intersect($providerTranslations)); } @@ -154,15 +194,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $provider->write($translationsToWrite); $io->success( - \sprintf( - '%s local translations has been sent to "%s" (for "%s" locale(s)).', + sprintf( + '%s local translations have been sent to "%s" (for "%s" locale(s)).', $force ? 'All' : 'New', - parse_url((string) $provider, \PHP_URL_SCHEME), + $this->getProviderName($provider), implode(', ', $locales) ) ); - - return Command::SUCCESS; } /** @@ -184,4 +222,9 @@ private function getTranslations(array $locales): TranslatorBag return $translationBag; } + + private function getProviderName(ProviderInterface $provider): string + { + return parse_url((string) $provider, \PHP_URL_SCHEME) ?: 'unknown'; + } } From 2cb52e77e810212d991b6bceb716741aa941dd89 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Tue, 17 Feb 2026 10:31:40 +0100 Subject: [PATCH 07/12] feat: improved readme file --- Readme.md | 84 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 63 insertions(+), 21 deletions(-) diff --git a/Readme.md b/Readme.md index 30a3480..2825a68 100644 --- a/Readme.md +++ b/Readme.md @@ -1,64 +1,106 @@ -# Symfony Translation Bridge +# Shopware Translation Bridge -With this plugin you can use the provider which can be defined in symfony/trnaslation to manage the storefront snippets. +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. ## Installation ```bash -composer req netlogix/shopware-translation-bridge +composer require netlogix/shopware-translation-bridge +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 spesific providers. Like `default_provider` but individial for every salesChannel | -### Example config +### 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' - sales_channel_providers: - 2b919afec10730f413cb5682bbed09fd: - provider: 'fooProvider' - e1582cd277454e988b8de2b878effc94: - provider: 'barProvider' ``` ## Commands -### Push snippets to translation provider +This plugin provides three commands to manage translations. -To initial push the snippets to the translation provider you can use this command. -It provides several options to tailor the push to your needs. +### Push Snippets + +Pushes all local snippets to the configured translation provider. ```bash -bin/console sw:snippets:push +bin/console sw:snippets:push [salesChannelId1] [salesChannelId2] ``` -### Flush translation cache +**Arguments:** + +* `salesChannelId` (optional, multiple): The sales channel ID(s) to push translations for. If "default" or empty, the default provider is used. -With this command you can clear only the translation cache +**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. + +### 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. ```bash -bin/console cache:translation:flush +bin/console sw:snippets:pull [salesChannelId1] ``` -### Fetch translations from providers +**Arguments:** + +* `salesChannelId` (optional, multiple): The sales channel ID(s) to pull translations for. If "default" or empty, the default provider is used. -Use this command to pull the remote storefront translations and store 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. +**Options:** + +* `--locales` / `-l` (multiple): Specify the locales to pull. If not provided, all relevant locales are pulled. + +### Flush Translation Cache + +Flushes the translation cache. This is useful after pulling new translations to make them visible in the storefront. ```bash -bin/console sw:snippets:pull +bin/console sw:cache:flush:translation ``` -## Components +## 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` +* **Body (JSON):** + ```json + { + "salesChannelIds": ["SALES_CHANNEL_ID_1", "SALES_CHANNEL_ID_2"] + } + ``` -### Netlogix\ShopwareTranslationBridge\Core\System\RelevantLocaleResolverInterface +### Asynchronous Processing + +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. + +Make sure your message queue workers are running to process these updates: +```bash +bin/console messenger:consume +``` From 345c92aa41930074005f71c37557d648b5a22b61 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Tue, 17 Feb 2026 11:13:52 +0100 Subject: [PATCH 08/12] refactor: code linting --- src/Command/PullSnippetsCommand.php | 5 +-- src/Command/PushSnippetsCommand.php | 44 +++++++++---------- .../UpdateTranslationController.php | 9 ++-- src/Core/System/RelevantLocaleResolver.php | 3 +- .../RelevantLocaleResolverInterface.php | 2 +- .../Listener/LoadTranslationsListener.php | 8 ++-- .../SalesChannelTranslationRefresher.php | 10 ++--- .../TranslationProviderResolverInterface.php | 2 +- .../TranslationUpdateHandler.php | 2 +- src/Message/TranslationUpdateMessage.php | 7 ++- 10 files changed, 44 insertions(+), 48 deletions(-) diff --git a/src/Command/PullSnippetsCommand.php b/src/Command/PullSnippetsCommand.php index 6270e07..609c867 100644 --- a/src/Command/PullSnippetsCommand.php +++ b/src/Command/PullSnippetsCommand.php @@ -150,8 +150,7 @@ private function fetchTranslations( */ 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); @@ -164,7 +163,7 @@ private function getAllLocales(): array */ private function getLocalesForSalesChannel(string $salesChannelId): array { - $criteria = (new Criteria([$salesChannelId]))->addAssociation('languages.locale'); + $criteria = new Criteria([$salesChannelId])->addAssociation('languages.locale'); $salesChannel = $this->salesChannelRepository->search($criteria, Context::createCLIContext())->get( $salesChannelId ); diff --git a/src/Command/PushSnippetsCommand.php b/src/Command/PushSnippetsCommand.php index 3a0eb27..8bbadea 100644 --- a/src/Command/PushSnippetsCommand.php +++ b/src/Command/PushSnippetsCommand.php @@ -1,6 +1,6 @@ write($localTranslations); - $io->success( - sprintf( - 'All local translations have been sent to "%s" (for "%s" locale(s)).', - $this->getProviderName($provider), - implode(', ', $locales) - ) - ); + $io->success(sprintf( + 'All local translations have been sent to "%s" (for "%s" locale(s)).', + $this->getProviderName($provider), + implode(', ', $locales) + )); + return; } @@ -175,13 +175,11 @@ private function processProvider( if ($deleteMissing) { $provider->delete($providerTranslations->diff($localTranslations)); - $io->success( - sprintf( - 'Missing translations on "%s" have been deleted (for "%s" locale(s)).', - $this->getProviderName($provider), - implode(', ', $locales) - ) - ); + $io->success(sprintf( + 'Missing translations on "%s" have been deleted (for "%s" locale(s)).', + $this->getProviderName($provider), + implode(', ', $locales) + )); $providerTranslations = $provider->read(['messages'], $locales); } @@ -193,14 +191,12 @@ private function processProvider( $provider->write($translationsToWrite); - $io->success( - sprintf( - '%s local translations have been sent to "%s" (for "%s" locale(s)).', - $force ? 'All' : 'New', - $this->getProviderName($provider), - implode(', ', $locales) - ) - ); + $io->success(sprintf( + '%s local translations have been sent to "%s" (for "%s" locale(s)).', + $force ? 'All' : 'New', + $this->getProviderName($provider), + implode(', ', $locales) + )); } /** diff --git a/src/Core/Framework/Api/Controller/UpdateTranslationController.php b/src/Core/Framework/Api/Controller/UpdateTranslationController.php index eed611d..c8db77b 100644 --- a/src/Core/Framework/Api/Controller/UpdateTranslationController.php +++ b/src/Core/Framework/Api/Controller/UpdateTranslationController.php @@ -1,6 +1,6 @@ searchIds(new Criteria(), Context::createCLIContext()) ->getIds(); - # If there is no default provider we only have to update the salesChannels which have translation provider + // 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(...)); + $salesChannelIds = array_filter( + $salesChannelIds, + $this->translationProviderResolver->hasSalesChannelProvider(...) + ); } if ($salesChannelIds === []) { diff --git a/src/Core/System/RelevantLocaleResolver.php b/src/Core/System/RelevantLocaleResolver.php index 18aa5fd..7930570 100644 --- a/src/Core/System/RelevantLocaleResolver.php +++ b/src/Core/System/RelevantLocaleResolver.php @@ -4,7 +4,6 @@ namespace Netlogix\ShopwareTranslationBridge\Core\System; - use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; @@ -46,6 +45,6 @@ private function resolve(?string $salesChannelId = null): array $languages->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 68188e6..d4c1de3 100644 --- a/src/Core/System/RelevantLocaleResolverInterface.php +++ b/src/Core/System/RelevantLocaleResolverInterface.php @@ -9,4 +9,4 @@ interface RelevantLocaleResolverInterface public function getAll(): array; public function getForSalesChannel(string $salesChannelId): array; -} \ No newline at end of file +} diff --git a/src/Core/System/Snippet/Listener/LoadTranslationsListener.php b/src/Core/System/Snippet/Listener/LoadTranslationsListener.php index a0db360..4c2b3d6 100644 --- a/src/Core/System/Snippet/Listener/LoadTranslationsListener.php +++ b/src/Core/System/Snippet/Listener/LoadTranslationsListener.php @@ -1,6 +1,6 @@ respectTranslationFiles) { $this->respectTranslationFiles($extension); @@ -80,8 +79,9 @@ private function applyProviderTranslations(StorefrontSnippetsExtension $extensio $translationBag = $provider->read([self::TRANSLATION_DOMAIN], $locales); $catalogue = $translationBag->getCatalogue($extension->locale); - $fallbackCatalogue = is_string($extension->fallbackLocale) ? - $translationBag->getCatalogue($extension->fallbackLocale) : null; + $fallbackCatalogue = is_string($extension->fallbackLocale) + ? $translationBag->getCatalogue($extension->fallbackLocale) + : null; foreach ($extension->result as $key => $value) { if ($catalogue->has($key, self::TRANSLATION_DOMAIN)) { diff --git a/src/Core/System/Snippet/SalesChannelTranslationRefresher.php b/src/Core/System/Snippet/SalesChannelTranslationRefresher.php index 49179a9..47b2e64 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(...)); + $this->salesChannelDomainRepository->search($criteria, Context::createCLIContext())->map( + $this->warmUpTranslation(...) + ); } finally { $this->translator->resetInjection(); } diff --git a/src/Core/System/Snippet/TranslationProviderResolverInterface.php b/src/Core/System/Snippet/TranslationProviderResolverInterface.php index c1f6564..c288d15 100644 --- a/src/Core/System/Snippet/TranslationProviderResolverInterface.php +++ b/src/Core/System/Snippet/TranslationProviderResolverInterface.php @@ -1,6 +1,6 @@ Date: Tue, 17 Feb 2026 11:56:56 +0100 Subject: [PATCH 09/12] refactor: code linting --- .../System/Snippet/TranslationProviderResolver.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Core/System/Snippet/TranslationProviderResolver.php b/src/Core/System/Snippet/TranslationProviderResolver.php index 185f0fc..9902e44 100644 --- a/src/Core/System/Snippet/TranslationProviderResolver.php +++ b/src/Core/System/Snippet/TranslationProviderResolver.php @@ -1,9 +1,10 @@ providerMap[$salesChannelId] ?? null; @@ -52,7 +53,7 @@ public function hasSalesChannelProvider(string $salesChannelId): bool public function getSalesChannelProvider(string $salesChannelId): ProviderInterface { - if (isset($this->providers[$salesChannelId])) { + if (array_key_exists($salesChannelId, $this->providers)) { return $this->providers[$salesChannelId]; } @@ -61,7 +62,7 @@ public function getSalesChannelProvider(string $salesChannelId): ProviderInterfa } $providerName = $this->providerMap[$salesChannelId]; - assert(is_string($providerName)); + assert(is_string($providerName), 'Provider map value must be string'); return $this->providers[$salesChannelId] = $this->providerCollection->get($providerName); } From 66f4dd29d710d58a7cdfa0cda85ce2a8fdb49091 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Tue, 17 Feb 2026 11:57:26 +0100 Subject: [PATCH 10/12] feat: added unit tests --- .../Support/CreatesLanguageEntitiesTrait.php | 38 ++++ tests/Support/CreatesTranslationBagTrait.php | 22 ++ .../Unit/Command/PullSnippetsCommandTest.php | 193 ++++++++++++++++++ .../Unit/Command/PushSnippetsCommandTest.php | 156 ++++++++++++++ .../UpdateTranslationControllerTest.php | 108 ++++++++++ .../System/RelevantLocaleResolverTest.php | 72 +++++++ .../Listener/LoadTranslationsListenerTest.php | 132 ++++++++++++ .../SalesChannelTranslationRefresherTest.php | 145 +++++++++++++ .../TranslationProviderResolverTest.php | 102 +++++++++ tests/bootstrap.php | 5 + 10 files changed, 973 insertions(+) create mode 100644 tests/Support/CreatesLanguageEntitiesTrait.php create mode 100644 tests/Support/CreatesTranslationBagTrait.php create mode 100644 tests/Unit/Command/PullSnippetsCommandTest.php create mode 100644 tests/Unit/Command/PushSnippetsCommandTest.php create mode 100644 tests/Unit/Core/Framework/Api/Controller/UpdateTranslationControllerTest.php create mode 100644 tests/Unit/Core/System/RelevantLocaleResolverTest.php create mode 100644 tests/Unit/Core/System/Snippet/Listener/LoadTranslationsListenerTest.php create mode 100644 tests/Unit/Core/System/Snippet/SalesChannelTranslationRefresherTest.php create mode 100644 tests/Unit/Core/System/Snippet/TranslationProviderResolverTest.php diff --git a/tests/Support/CreatesLanguageEntitiesTrait.php b/tests/Support/CreatesLanguageEntitiesTrait.php new file mode 100644 index 0000000..a9f3aae --- /dev/null +++ b/tests/Support/CreatesLanguageEntitiesTrait.php @@ -0,0 +1,38 @@ + $localeCodes + */ + protected function createLanguageCollection(array $localeCodes): LanguageCollection + { + return new LanguageCollection(array_map($this->createLanguageEntity(...), $localeCodes)); + } + + protected function createLanguageEntity(string $localeCode, ?string $languageId = null): LanguageEntity + { + $language = new LanguageEntity(); + $language->setUniqueIdentifier($languageId ?? md5('language-' . $localeCode)); + $language->setLocale($this->createLocaleEntity($localeCode)); + + return $language; + } + + protected function createLocaleEntity(string $localeCode): LocaleEntity + { + $locale = new LocaleEntity(); + $locale->setUniqueIdentifier(md5('locale-' . $localeCode)); + $locale->setCode($localeCode); + + return $locale; + } +} diff --git a/tests/Support/CreatesTranslationBagTrait.php b/tests/Support/CreatesTranslationBagTrait.php new file mode 100644 index 0000000..a777a46 --- /dev/null +++ b/tests/Support/CreatesTranslationBagTrait.php @@ -0,0 +1,22 @@ + $messages + */ + protected function createBag(string $locale, array $messages, string $domain = 'messages'): TranslatorBag + { + $bag = new TranslatorBag(); + $bag->addCatalogue(new MessageCatalogue($locale, [$domain => $messages])); + + return $bag; + } +} diff --git a/tests/Unit/Command/PullSnippetsCommandTest.php b/tests/Unit/Command/PullSnippetsCommandTest.php new file mode 100644 index 0000000..b05dc12 --- /dev/null +++ b/tests/Unit/Command/PullSnippetsCommandTest.php @@ -0,0 +1,193 @@ +createStub(EntityRepository::class); + $salesChannelRepository = $this->createStub(EntityRepository::class); + $writer = $this->createMock(TranslationWriterInterface::class); + $writer->expects(static::never())->method('write'); + + $command = new PullSnippetsCommand( + new TranslationProviderCollection([]), + $languageRepository, + $salesChannelRepository, + $writer, + $this->createParameterBagWithoutDefaultPath(), + $this->createProjectDir(), + null, + [] + ); + + $commandTester = new CommandTester($command); + $status = $commandTester->execute([]); + + static::assertSame(Command::SUCCESS, $status); + static::assertStringContainsString('No translations fetched', $commandTester->getDisplay()); + } + + public function testExecuteFetchesFromDefaultProviderAndWritesTranslations(): void + { + $provider = $this->createMock(ProviderInterface::class); + $provider + ->expects(static::once()) + ->method('read') + ->with(['messages'], ['de-DE']) + ->willReturn($this->createBag('de-DE', ['welcome' => 'Willkommen'])); + + $writer = $this->createMock(TranslationWriterInterface::class); + $writer + ->expects(static::once()) + ->method('write') + ->with( + static::callback( + static fn(MessageCatalogue $catalogue): bool => $catalogue->has('welcome', 'messages') + ), + 'json', + static::callback( + static fn(array $options): bool => array_key_exists('path', $options) && is_string($options['path']) + ) + ); + + $languageRepository = $this->createMock(EntityRepository::class); + $languageRepository + ->expects(static::once()) + ->method('search') + ->willReturn($this->createLanguageSearchResult(['de-DE'])); + + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository->expects(static::never())->method('search'); + + $command = new PullSnippetsCommand( + new TranslationProviderCollection(['default-provider' => $provider]), + $languageRepository, + $salesChannelRepository, + $writer, + $this->createParameterBagWithoutDefaultPath(), + $this->createProjectDir(), + 'default-provider', + [] + ); + + $commandTester = new CommandTester($command); + $status = $commandTester->execute([]); + + static::assertSame(Command::SUCCESS, $status); + static::assertStringContainsString('Fetched translations for 1 domain', $commandTester->getDisplay()); + } + + public function testExecuteSkipsSalesChannelProviderWhenNoLocalesWereFound(): void + { + $provider = $this->createMock(ProviderInterface::class); + $provider->expects(static::never())->method('read'); + + $languageRepository = $this->createMock(EntityRepository::class); + $languageRepository->expects(static::never())->method('search'); + + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->expects(static::once()) + ->method('search') + ->willReturn($this->createSalesChannelSearchResult()); + + $writer = $this->createMock(TranslationWriterInterface::class); + $writer->expects(static::never())->method('write'); + + $command = new PullSnippetsCommand( + new TranslationProviderCollection(['sales-provider' => $provider]), + $languageRepository, + $salesChannelRepository, + $writer, + $this->createParameterBagWithoutDefaultPath(), + $this->createProjectDir(), + null, + [self::SALES_CHANNEL_ID => 'sales-provider'] + ); + + $commandTester = new CommandTester($command); + $status = $commandTester->execute([]); + + static::assertSame(Command::SUCCESS, $status); + static::assertStringContainsString('No translations fetched', $commandTester->getDisplay()); + } + + private function createParameterBagWithoutDefaultPath(): ParameterBagInterface + { + $parameterBag = $this->createMock(ParameterBagInterface::class); + $parameterBag + ->expects(static::once()) + ->method('has') + ->with('framework.translator.default_path') + ->willReturn(false); + $parameterBag->expects(static::never())->method('get'); + + return $parameterBag; + } + + private function createProjectDir(): string + { + $projectDir = sys_get_temp_dir() . '/shopware-translation-bridge-tests-' . uniqid('', true); + mkdir($projectDir, 0o777, true); + + return $projectDir; + } + + /** + * @param list $localeCodes + */ + private function createLanguageSearchResult(array $localeCodes): EntitySearchResult + { + return new EntitySearchResult( + 'language', + count($localeCodes), + $this->createLanguageCollection($localeCodes), + null, + new Criteria(), + Context::createDefaultContext() + ); + } + + private function createSalesChannelSearchResult(?SalesChannelEntity $salesChannel = null): EntitySearchResult + { + $collection = new SalesChannelCollection($salesChannel === null ? [] : [$salesChannel]); + + return new EntitySearchResult( + 'sales_channel', + $salesChannel === null ? 0 : 1, + $collection, + null, + new Criteria(), + Context::createDefaultContext() + ); + } +} diff --git a/tests/Unit/Command/PushSnippetsCommandTest.php b/tests/Unit/Command/PushSnippetsCommandTest.php new file mode 100644 index 0000000..160b061 --- /dev/null +++ b/tests/Unit/Command/PushSnippetsCommandTest.php @@ -0,0 +1,156 @@ +createStub(SnippetService::class), + $this->createStub(AbstractTranslator::class), + $this->createStub(TranslationProviderResolverInterface::class), + $this->createLocaleResolver(['de-DE']) + ); + + $commandTester = new CommandTester($command); + $status = $commandTester->execute(['--locales' => ['fr-FR']]); + + static::assertSame(Command::FAILURE, $status); + static::assertStringContainsString('not enabled', $commandTester->getDisplay()); + } + + public function testExecutePushesAllTranslationsToDefaultProviderWithForce(): void + { + $provider = $this->createMock(ProviderInterface::class); + $provider + ->expects(static::once()) + ->method('write') + ->with(static::callback( + static fn(TranslatorBag $bag): bool => $bag->getCatalogue('de-DE')->has('greeting', 'messages') + )); + $provider->method('__toString')->willReturn('mock://default'); + + $providerResolver = $this->createMock(TranslationProviderResolverInterface::class); + $providerResolver->expects(static::once())->method('getDefaultProvider')->willReturn($provider); + $providerResolver->expects(static::never())->method('getSalesChannelProvider'); + + $translator = $this->createMock(AbstractTranslator::class); + $translator->expects(static::once())->method('getSnippetSetId')->with('de-DE')->willReturn('snippet-set-id'); + + $snippetService = $this->createMock(SnippetService::class); + $snippetService + ->expects(static::once()) + ->method('getStorefrontSnippets') + ->with(static::isInstanceOf(MessageCatalogue::class), 'snippet-set-id') + ->willReturn(['greeting' => 'Hallo']); + + $command = new PushSnippetsCommand( + new TranslationProviderCollection([]), + $snippetService, + $translator, + $providerResolver, + $this->createLocaleResolver(['de-DE']) + ); + + $commandTester = new CommandTester($command); + $status = $commandTester->execute(['--force' => true]); + + static::assertSame(Command::SUCCESS, $status); + static::assertStringContainsString('All local translations have been sent', $commandTester->getDisplay()); + } + + public function testExecuteDeletesMissingAndWritesDiffForSalesChannelProvider(): void + { + $firstProviderTranslations = $this->createBag('de-DE', ['obsolete' => 'to-delete']); + $secondProviderTranslations = $this->createBag('de-DE', []); + + $provider = $this->createMock(ProviderInterface::class); + $provider + ->expects(static::exactly(2)) + ->method('read') + ->with(['messages'], ['de-DE']) + ->willReturnOnConsecutiveCalls($firstProviderTranslations, $secondProviderTranslations); + $provider + ->expects(static::once()) + ->method('delete') + ->with(static::callback( + static fn(TranslatorBag $bag): bool => $bag->getCatalogue('de-DE')->has('obsolete', 'messages') + )); + $provider + ->expects(static::once()) + ->method('write') + ->with(static::callback( + static fn(TranslatorBag $bag): bool => $bag->getCatalogue('de-DE')->has('greeting', 'messages') + )); + $provider->method('__toString')->willReturn('mock://sales-channel'); + + $providerResolver = $this->createMock(TranslationProviderResolverInterface::class); + $providerResolver + ->expects(static::once()) + ->method('getSalesChannelProvider') + ->with(self::SALES_CHANNEL_ID) + ->willReturn($provider); + $providerResolver->expects(static::never())->method('getDefaultProvider'); + + $translator = $this->createMock(AbstractTranslator::class); + $translator->expects(static::once())->method('getSnippetSetId')->with('de-DE')->willReturn('snippet-set-id'); + + $snippetService = $this->createMock(SnippetService::class); + $snippetService + ->expects(static::once()) + ->method('getStorefrontSnippets') + ->with(static::isInstanceOf(MessageCatalogue::class), 'snippet-set-id') + ->willReturn(['greeting' => 'Hallo']); + + $command = new PushSnippetsCommand( + new TranslationProviderCollection([]), + $snippetService, + $translator, + $providerResolver, + $this->createLocaleResolver(['de-DE']) + ); + + $commandTester = new CommandTester($command); + $status = $commandTester->execute([ + 'salesChannelId' => [self::SALES_CHANNEL_ID], + '--locales' => ['de-DE'], + '--delete-missing' => true + ]); + + static::assertSame(Command::SUCCESS, $status); + static::assertStringContainsString('Missing translations', $commandTester->getDisplay()); + static::assertStringContainsString('New local translations have been sent', $commandTester->getDisplay()); + } + + private function createLocaleResolver(array $allLocales): RelevantLocaleResolverInterface + { + $localeResolver = $this->createStub(RelevantLocaleResolverInterface::class); + $localeResolver->method('getAll')->willReturn($allLocales); + + return $localeResolver; + } +} diff --git a/tests/Unit/Core/Framework/Api/Controller/UpdateTranslationControllerTest.php b/tests/Unit/Core/Framework/Api/Controller/UpdateTranslationControllerTest.php new file mode 100644 index 0000000..28c46a6 --- /dev/null +++ b/tests/Unit/Core/Framework/Api/Controller/UpdateTranslationControllerTest.php @@ -0,0 +1,108 @@ +createMock(EntityRepository::class); + $messageBus = $this->createMock(MessageBusInterface::class); + $providerResolver = $this->createMock(TranslationProviderResolverInterface::class); + + $salesChannelRepository + ->expects(self::once()) + ->method('searchIds') + ->willReturn($this->createIdSearchResult([self::SALES_CHANNEL_ID_1])); + + $providerResolver->expects(self::once())->method('hasDefaultProvider')->willReturn(false); + $providerResolver + ->expects(self::once()) + ->method('hasSalesChannelProvider') + ->with(self::SALES_CHANNEL_ID_1) + ->willReturn(false); + + $messageBus->expects(self::never())->method('dispatch'); + + $controller = new UpdateTranslationController($salesChannelRepository, $messageBus, $providerResolver); + $response = $controller->__invoke(); + + static::assertSame(503, $response->getStatusCode()); + static::assertStringContainsString('errorMissingTranslationProvider', (string) $response->getContent()); + } + + public function testInvokeDispatchesBatchedMessages(): void + { + $salesChannelRepository = $this->createMock(EntityRepository::class); + $messageBus = $this->createMock(MessageBusInterface::class); + $providerResolver = $this->createMock(TranslationProviderResolverInterface::class); + + $salesChannelRepository + ->expects(self::once()) + ->method('searchIds') + ->willReturn($this->createIdSearchResult([ + self::SALES_CHANNEL_ID_1, + self::SALES_CHANNEL_ID_2, + self::SALES_CHANNEL_ID_3 + ])); + + $providerResolver->expects(self::once())->method('hasDefaultProvider')->willReturn(true); + $providerResolver->expects(self::never())->method('hasSalesChannelProvider'); + + $messageBus + ->expects(self::exactly(2)) + ->method('dispatch') + ->with(static::callback(static function (object $message): bool { + static $chunks = [ + [self::SALES_CHANNEL_ID_1, self::SALES_CHANNEL_ID_2], + [self::SALES_CHANNEL_ID_3] + ]; + + if (!$message instanceof TranslationUpdateMessage) { + return false; + } + + $expectedChunk = array_shift($chunks); + if ($expectedChunk === null) { + return false; + } + + return $message->salesChannelIds === $expectedChunk; + })) + ->willReturnCallback(static fn(object $message): Envelope => new Envelope($message)); + + $controller = new UpdateTranslationController($salesChannelRepository, $messageBus, $providerResolver, 2); + $response = $controller->__invoke(); + + static::assertSame(200, $response->getStatusCode()); + static::assertStringContainsString('"success":true', (string) $response->getContent()); + } + + /** + * @param list $ids + */ + private function createIdSearchResult(array $ids): IdSearchResult + { + $idSearchResult = $this->createStub(IdSearchResult::class); + $idSearchResult->method('getIds')->willReturn($ids); + + return $idSearchResult; + } +} diff --git a/tests/Unit/Core/System/RelevantLocaleResolverTest.php b/tests/Unit/Core/System/RelevantLocaleResolverTest.php new file mode 100644 index 0000000..3e38cc1 --- /dev/null +++ b/tests/Unit/Core/System/RelevantLocaleResolverTest.php @@ -0,0 +1,72 @@ +createMock(EntityRepository::class); + $salesChannelRepository + ->expects(self::once()) + ->method('search') + ->willReturn($this->createSalesChannelSearchResult( + $this->createSalesChannel('sc-1', ['de-DE', 'en-GB']), + $this->createSalesChannel('sc-2', ['fr-FR']) + )); + + $resolver = new RelevantLocaleResolver($salesChannelRepository); + + static::assertSame(['de-DE', 'en-GB', 'fr-FR'], array_values($resolver->getAll())); + } + + public function testGetForSalesChannelReturnsLocalesForGivenSalesChannel(): void + { + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository + ->expects(self::once()) + ->method('search') + ->willReturn($this->createSalesChannelSearchResult($this->createSalesChannel('sc-1', ['de-DE', 'en-GB']))); + + $resolver = new RelevantLocaleResolver($salesChannelRepository); + + static::assertSame(['de-DE', 'en-GB'], array_values($resolver->getForSalesChannel('sc-1'))); + } + + private function createSalesChannel(string $id, array $localeCodes): SalesChannelEntity + { + $salesChannel = new SalesChannelEntity(); + $salesChannel->setUniqueIdentifier($id); + $salesChannel->setLanguages($this->createLanguageCollection($localeCodes)); + + return $salesChannel; + } + + private function createSalesChannelSearchResult(SalesChannelEntity ...$salesChannels): EntitySearchResult + { + return new EntitySearchResult( + 'sales_channel', + count($salesChannels), + new SalesChannelCollection($salesChannels), + null, + new Criteria(), + Context::createDefaultContext() + ); + } +} diff --git a/tests/Unit/Core/System/Snippet/Listener/LoadTranslationsListenerTest.php b/tests/Unit/Core/System/Snippet/Listener/LoadTranslationsListenerTest.php new file mode 100644 index 0000000..6f05896 --- /dev/null +++ b/tests/Unit/Core/System/Snippet/Listener/LoadTranslationsListenerTest.php @@ -0,0 +1,132 @@ +createMock(TranslationProviderResolverInterface::class); + $providerResolver + ->expects(static::once()) + ->method('hasProvider') + ->with(self::SALES_CHANNEL_ID) + ->willReturn(false); + $providerResolver->expects(static::never())->method('getProvider'); + + $catalog = new MessageCatalogue('de-DE'); + $catalog->add(['headline' => 'sales-channel-value'], self::SALES_CHANNEL_ID); + $catalog->add(['fileOnly' => 'from-file'], LoadTranslationsListener::TRANSLATION_DOMAIN); + + $extension = new StorefrontSnippetsExtension( + ['headline' => 'snippet-value', 'missing' => 'fallback-value', 'fileOnly' => 'snippet-file'], + 'de-DE', + $catalog, + 'snippet-set', + null, + self::SALES_CHANNEL_ID, + [] + ); + + $listener = new LoadTranslationsListener($providerResolver, true); + $listener($extension); + + static::assertFalse($extension->isPropagationStopped()); + static::assertSame('sales-channel-value', $extension->result['headline']); + static::assertSame('fallback-value', $extension->result['missing']); + static::assertArrayNotHasKey('fileOnly', $extension->result); + } + + public function testInvokeAppliesProviderTranslationsAndStopsPropagation(): void + { + $providerResolver = $this->createMock(TranslationProviderResolverInterface::class); + $provider = $this->createMock(ProviderInterface::class); + + $providerResolver + ->expects(static::once()) + ->method('hasProvider') + ->with(self::SALES_CHANNEL_ID) + ->willReturn(true); + $providerResolver + ->expects(static::once()) + ->method('getProvider') + ->with(self::SALES_CHANNEL_ID) + ->willReturn($provider); + + $localeCatalogue = new MessageCatalogue('de-DE'); + $localeCatalogue->add([ + 'locOnly' => 'from-locale', + 'both' => 'locale-priority' + ], LoadTranslationsListener::TRANSLATION_DOMAIN); + + $fallbackCatalogue = new MessageCatalogue('en-GB'); + $fallbackCatalogue->add([ + 'fbOnly' => 'from-fallback', + 'both' => 'fallback-wins' + ], LoadTranslationsListener::TRANSLATION_DOMAIN); + + $translationBag = new TranslatorBag(); + $translationBag->addCatalogue($localeCatalogue); + $translationBag->addCatalogue($fallbackCatalogue); + + $provider + ->expects(static::once()) + ->method('read') + ->with([LoadTranslationsListener::TRANSLATION_DOMAIN], ['de-DE', 'en-GB']) + ->willReturn($translationBag); + + $extension = new StorefrontSnippetsExtension( + ['locOnly' => 'snippet', 'fbOnly' => 'snippet', 'both' => 'snippet'], + 'de-DE', + new MessageCatalogue('de-DE'), + 'snippet-set', + 'en-GB', + self::SALES_CHANNEL_ID, + [] + ); + $extension->result = $extension->snippets; + + $listener = new LoadTranslationsListener($providerResolver, false); + $listener($extension); + + static::assertTrue($extension->isPropagationStopped()); + static::assertSame('from-locale', $extension->result['locOnly']); + static::assertSame('from-fallback', $extension->result['fbOnly']); + static::assertSame('fallback-wins', $extension->result['both']); + } + + public function testSkipSuppressesInvocationOnlyInsideCallback(): void + { + $providerResolver = $this->createMock(TranslationProviderResolverInterface::class); + $providerResolver->expects(static::once())->method('hasProvider')->willReturn(false); + + $listener = new LoadTranslationsListener($providerResolver, false); + $extension = new StorefrontSnippetsExtension( + ['headline' => 'snippet-value'], + 'de-DE', + new MessageCatalogue('de-DE'), + 'snippet-set', + null, + self::SALES_CHANNEL_ID, + [] + ); + $extension->result = $extension->snippets; + + LoadTranslationsListener::skip(static fn() => $listener($extension)); + $listener($extension); + } +} diff --git a/tests/Unit/Core/System/Snippet/SalesChannelTranslationRefresherTest.php b/tests/Unit/Core/System/Snippet/SalesChannelTranslationRefresherTest.php new file mode 100644 index 0000000..4f3790e --- /dev/null +++ b/tests/Unit/Core/System/Snippet/SalesChannelTranslationRefresherTest.php @@ -0,0 +1,145 @@ +createMock(TranslationCacheInvalidationInterface::class); + $repository = $this->createMock(EntityRepository::class); + $translator = $this->createMock(AbstractTranslator::class); + + $cacheInvalidation + ->expects(static::exactly(2)) + ->method('invalidateBySalesChannel') + ->willReturnCallback(static function (string $salesChannelId, bool $force): void { + static $calls = 0; + ++$calls; + + static::assertTrue($force); + static::assertContains($salesChannelId, [self::SALES_CHANNEL_ID_1, self::SALES_CHANNEL_ID_2]); + static::assertLessThanOrEqual(2, $calls); + }); + + $searchResult = $this->createDomainSearchResult( + $this->createDomain(self::SALES_CHANNEL_ID_1, self::LANGUAGE_ID_1, 'de-DE'), + $this->createDomain(self::SALES_CHANNEL_ID_2, self::LANGUAGE_ID_2, 'en-GB') + ); + + $repository + ->expects(static::once()) + ->method('search') + ->with(static::callback(static function (Criteria $criteria): bool { + $filters = $criteria->getFilters(); + static::assertCount(1, $filters); + static::assertSame('salesChannelId', $filters[0]->getField()); + static::assertSame([self::SALES_CHANNEL_ID_1, self::SALES_CHANNEL_ID_2], $filters[0]->getValue()); + $associations = $criteria->getAssociations(); + static::assertArrayHasKey('language', $associations); + static::assertArrayHasKey('locale', $associations['language']->getAssociations()); + + return true; + }), static::isInstanceOf(Context::class)) + ->willReturn($searchResult); + + $translator + ->expects(static::exactly(2)) + ->method('injectSettings') + ->willReturnCallback(static function ( + string $salesChannelId, + string $languageId, + string $localeCode, + Context $context + ): void { + static $calls = []; + $calls[] = [$salesChannelId, $languageId, $localeCode, $context]; + + static::assertContains([$salesChannelId, $languageId, $localeCode], [ + [self::SALES_CHANNEL_ID_1, self::LANGUAGE_ID_1, 'de-DE'], + [self::SALES_CHANNEL_ID_2, self::LANGUAGE_ID_2, 'en-GB'] + ]); + static::assertInstanceOf(Context::class, $context); + }); + $translator->expects(static::exactly(2))->method('getCatalogue')->willReturn(new MessageCatalogue('de-DE')); + $translator->expects(static::once())->method('resetInjection'); + + $refresher = new SalesChannelTranslationRefresher($cacheInvalidation, $repository, $translator); + $refresher->refresh(self::SALES_CHANNEL_ID_1, self::SALES_CHANNEL_ID_2); + } + + public function testRefreshAlwaysResetsInjectionOnFailure(): void + { + $cacheInvalidation = $this->createMock(TranslationCacheInvalidationInterface::class); + $repository = $this->createMock(EntityRepository::class); + $translator = $this->createMock(AbstractTranslator::class); + + $cacheInvalidation + ->expects(static::once()) + ->method('invalidateBySalesChannel') + ->with(self::SALES_CHANNEL_ID_1, true); + $repository + ->expects(static::once()) + ->method('search') + ->willThrowException(new RuntimeException('search failed')); + $translator->expects(static::once())->method('resetInjection'); + + $refresher = new SalesChannelTranslationRefresher($cacheInvalidation, $repository, $translator); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('search failed'); + + $refresher->refresh(self::SALES_CHANNEL_ID_1); + } + + private function createDomain( + string $salesChannelId, + string $languageId, + string $localeCode + ): SalesChannelDomainEntity { + $domain = new SalesChannelDomainEntity(); + $domain->setUniqueIdentifier(md5($salesChannelId . '-' . $languageId . '-' . $localeCode)); + $domain->setSalesChannelId($salesChannelId); + $domain->setLanguageId($languageId); + $domain->setLanguage($this->createLanguageEntity($localeCode, $languageId)); + + return $domain; + } + + private function createDomainSearchResult(SalesChannelDomainEntity ...$domains): EntitySearchResult + { + return new EntitySearchResult( + 'sales_channel_domain', + count($domains), + new SalesChannelDomainCollection($domains), + null, + new Criteria(), + Context::createDefaultContext() + ); + } +} diff --git a/tests/Unit/Core/System/Snippet/TranslationProviderResolverTest.php b/tests/Unit/Core/System/Snippet/TranslationProviderResolverTest.php new file mode 100644 index 0000000..c723e37 --- /dev/null +++ b/tests/Unit/Core/System/Snippet/TranslationProviderResolverTest.php @@ -0,0 +1,102 @@ + $this->createStub(ProviderInterface::class) + ]); + + $resolver = new TranslationProviderResolver($providerCollection, 'default-provider', []); + + static::assertTrue($resolver->hasDefaultProvider()); + } + + public function testHasSalesChannelProviderThrowsOnInvalidUuid(): void + { + $resolver = new TranslationProviderResolver(new TranslationProviderCollection([]), null, []); + + $this->expectException(InvalidArgumentException::class); + + $resolver->hasSalesChannelProvider('not-a-uuid'); + } + + public function testGetProviderPrefersSalesChannelProviderOverDefaultProvider(): void + { + $salesChannelProvider = $this->createStub(ProviderInterface::class); + $defaultProvider = $this->createStub(ProviderInterface::class); + + $resolver = new TranslationProviderResolver( + new TranslationProviderCollection([ + 'default-provider' => $defaultProvider, + 'sales-channel-provider' => $salesChannelProvider + ]), + 'default-provider', + [self::SALES_CHANNEL_ID => 'sales-channel-provider'] + ); + + static::assertSame($salesChannelProvider, $resolver->getProvider(self::SALES_CHANNEL_ID)); + } + + public function testGetProviderFallsBackToDefaultProvider(): void + { + $defaultProvider = $this->createStub(ProviderInterface::class); + $resolver = new TranslationProviderResolver( + new TranslationProviderCollection(['default-provider' => $defaultProvider]), + 'default-provider', + [] + ); + + static::assertSame($defaultProvider, $resolver->getProvider(self::SALES_CHANNEL_ID)); + } + + public function testGetProviderThrowsIfNoProviderExists(): void + { + $resolver = new TranslationProviderResolver(new TranslationProviderCollection([]), null, []); + + $this->expectException(RuntimeException::class); + + $resolver->getProvider(self::SALES_CHANNEL_ID); + } + + public function testResetClearsSalesChannelProviderCache(): void + { + $provider = $this->createStub(ProviderInterface::class); + $resolver = new TranslationProviderResolver( + new TranslationProviderCollection(['sales-channel-provider' => $provider]), + null, + [self::SALES_CHANNEL_ID => 'sales-channel-provider'] + ); + + static::assertSame($provider, $resolver->getSalesChannelProvider(self::SALES_CHANNEL_ID)); + static::assertNotSame([], $this->getProviderCache($resolver)); + + $resolver->reset(); + + static::assertSame([], $this->getProviderCache($resolver)); + } + + private function getProviderCache(TranslationProviderResolver $resolver): array + { + $reflectionProperty = new ReflectionProperty($resolver, 'providers'); + + return $reflectionProperty->isInitialized($resolver) ? $reflectionProperty->getValue($resolver) : []; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 1a6e421..6f43147 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -3,3 +3,8 @@ declare(strict_types = 1); require __DIR__ . '/../vendor/autoload.php'; + +$supportFiles = glob(__DIR__ . '/Support/*.php'); +foreach ($supportFiles === false ? [] : $supportFiles as $supportFile) { + require_once $supportFile; +} From cc7f44f9a175cd5ce526089961442d480554ed91 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Wed, 18 Feb 2026 10:09:30 +0100 Subject: [PATCH 11/12] feat: updated readme --- Readme.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Readme.md b/Readme.md index 2825a68..7008c21 100644 --- a/Readme.md +++ b/Readme.md @@ -96,11 +96,6 @@ This plugin provides an API endpoint to trigger a translation update for specifi } ``` -### Asynchronous Processing +## Asynchronous Processing 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. - -Make sure your message queue workers are running to process these updates: -```bash -bin/console messenger:consume -``` From 1c04aaa0da087dec57660b255e1423dde927a9a5 Mon Sep 17 00:00:00 2001 From: "markus.uderhardt" Date: Wed, 18 Feb 2026 10:12:08 +0100 Subject: [PATCH 12/12] refactor: fixed typos in readme --- Readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Readme.md b/Readme.md index 7008c21..40a8947 100644 --- a/Readme.md +++ b/Readme.md @@ -19,7 +19,7 @@ The plugin uses the DSN from the `ShopwareTranslationBridge.config.providerDsn` |---------------------------|----------------|---------|----------------------------------------------------------------------------------------------------| | 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 spesific providers. Like `default_provider` but individial for every salesChannel | +| sales_channel_providers | `array` | `[]` | SalesChannel specific providers. Like `default_provider` but individiual for every salesChannel | ### Example Configuration