Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 63 additions & 53 deletions Readme.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# Shopware Translation Bridge

This plugin provides a bridge to connect Shopware with any Translation Provider that is supported by the [Symfony Translation Component](https://symfony.com/doc/current/translation.html#translation-providers). It allows you to manage your storefront snippets via a third-party translation service.
This plugin provides a bridge to connect Shopware with any translation provider supported by the [Symfony Translation Component](https://symfony.com/doc/current/translation.html#translation-providers). It lets you manage your storefront snippets through a third-party translation service (e.g. Tolgee, Crowdin, Lokalise) instead of maintaining them by hand in the administration.

At its core the plugin does two things: it applies provider translations to the storefront at runtime, and it provides CLI commands and an API to synchronise snippets between Shopware and the provider in both directions.

## Requirements

* PHP 8.3+
* Shopware 6.7.1 or higher (`shopware/core` and `shopware/storefront`)
* `symfony/translation` 7.x

## Installation

Expand All @@ -11,91 +19,93 @@ 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 Symfony translation providers themselves (their DSNs) are configured the usual Symfony way, e.g. in `config/packages/translation.yaml` under `framework.translator.providers`. The service name you give a provider there (for example `tolgee`) is the value you reference in the plugin settings.

Everything specific to this plugin is configured as regular **Shopware plugin settings** — no extra config file needed. Open Administration → Extensions → My extensions → Translation Bridge → Config:

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.
| Field | Type | Default | Description |
|---------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------|
| Default translation provider | `string` | empty | Service name from `framework.translator.providers` (e.g. `tolgee`). Empty means no provider is used. |
| Respect local translation files | `bool` | `true` | Whether to overlay the snippets with the translation files from `framework.translator.default_path`. |

| option | type | default | info |
|---------------------------|----------------|---------|----------------------------------------------------------------------------------------------------|
| default_provider | `null\|string` | `null` | Service name from `framework.translator.providers`. If `null` there is no fallback provider. |
| respect_translation_files | `bool` | `true` | should it overlay the snippet files with the translation files `framework.translator.default_path` |
| sales_channel_providers | `array` | `[]` | SalesChannel specific providers. Like `default_provider` but individiual for every salesChannel |
Both fields can be overridden per sales channel using the sales channel selector at the top of the config screen — pick a sales channel, set a different value, and save. This uses Shopware's standard system-config inheritance: a sales channel without its own value automatically falls back to the global default. Leaving everything empty is a valid, safe state — the plugin simply stays inactive and does not alter Shopware's default translation behaviour.

### Example Configuration
## Commands

### Pull snippets

Here is an example of how to configure different providers for different sales channels.
Pulls snippets from the configured provider(s) and writes them locally into the directory defined by `framework.translator.default_path`.

```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'
```bash
bin/console sw:snippets:pull
```

## Commands
The command takes no arguments or options; it resolves everything from the plugin configuration:

This plugin provides three commands to manage translations.
* The global default provider (if configured) is pulled for all system locales and written to the `messages` translation domain.
* Every sales channel whose configured provider **differs** from the global default is pulled for that channel's locales and written to a domain named after the sales channel id. Sales channels that merely inherit the default are not pulled again — the `messages` domain already covers them.

### Push Snippets
If nothing is configured, the command prints a warning and exits without writing anything.

Pushes all local snippets to the configured translation provider.
### Push snippets

Pushes local snippets to the configured provider.

```bash
bin/console sw:snippets:push [salesChannelId1] [salesChannelId2]
bin/console sw:snippets:push [salesChannelId ...]
```

**Arguments:**

* `salesChannelId` (optional, multiple): The sales channel ID(s) to push translations for. If "default" or empty, the default provider is used.
* `salesChannelId` (optional, multiple): the sales channel id(s) to push for. If omitted or set to `default`, the global default provider is used; otherwise the effective provider of each given sales channel is used.

**Options:**

* `--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.
* `--force` / `-f`: overwrite translations that already exist on the provider (removes messages that are not synchronised).
* `--delete-missing`: delete translations on the provider that no longer exist locally.
* `--locales` / `-l` (multiple): restrict to specific locales (e.g. `de-DE`, `en-GB`). If omitted, all relevant locales are pushed. Locales that are not enabled cause the command to fail without touching the provider.

### Pull Snippets
### Flush translation cache

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.
Invalidates the translation cache. Useful after a pull to make new translations visible in the storefront.

```bash
bin/console sw:snippets:pull [salesChannelId1]
bin/console sw:cache:translation:flush
```

**Arguments:**
## API endpoints

* `salesChannelId` (optional, multiple): The sales channel ID(s) to pull translations for. If "default" or empty, the default provider is used.
Both endpoints live under the `/api` scope and require an authenticated admin API token.

**Options:**
### Trigger a translation update

* `--locales` / `-l` (multiple): Specify the locales to pull. If not provided, all relevant locales are pulled.
Dispatches an asynchronous translation refresh for the given sales channels. This is intended for webhooks from translation providers (e.g. fired when a translation job completes).

### Flush Translation Cache
* **URL:** `POST /api/_action/nlx-translation/update`
* **Body (JSON):**
```json
{
"salesChannelIds": ["SALES_CHANNEL_ID_1", "SALES_CHANNEL_ID_2"]
}
```

Flushes the translation cache. This is useful after pulling new translations to make them visible in the storefront.
Sales channels without a resolvable provider are filtered out. If none remain, the endpoint responds with HTTP 503 and `errorMissingTranslationProvider`.

```bash
bin/console sw:cache:flush:translation
```
### List available providers

Returns the translation providers registered in `framework.translator.providers`. This backs the provider select field in the administration.

## API Endpoint
* **URL:** `GET /api/_action/nlx-translation/providers`

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).
## Asynchronous processing

* **URL:** `/api/_action/nlx/translation/update`
* **Method:** `POST`
* **Body (JSON):**
```json
{
"salesChannelIds": ["SALES_CHANNEL_ID_1", "SALES_CHANNEL_ID_2"]
}
```
When the update endpoint is called, the sales channel ids are dispatched to the Shopware message queue in batches. A message handler processes the queue and, per sales channel, invalidates the translation cache and warms up the catalogue for each of the channel's domains — so the refresh happens in the background without blocking the request.

## Asynchronous Processing
## Development

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.
```bash
composer test # run the unit test suite (PHPUnit)
composer phpstan # static analysis
composer lint # mago lint
composer format:fix # mago formatter
```
6 changes: 3 additions & 3 deletions src/Command/FlushTranslationCacheCommand.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

declare(strict_types = 1);
declare(strict_types=1);

namespace Netlogix\ShopwareTranslationBridge\Command;

Expand All @@ -10,10 +10,10 @@
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand('cache:translation:flush')]
#[AsCommand('sw:cache:translation:flush')]
class FlushTranslationCacheCommand extends Command
{
function __construct(
public function __construct(
private readonly TranslationCacheInvalidationInterface $translationCacheInvalidation
) {
parent::__construct();
Expand Down
107 changes: 68 additions & 39 deletions src/Command/PullSnippetsCommand.php
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
<?php

declare(strict_types = 1);
declare(strict_types=1);

namespace Netlogix\ShopwareTranslationBridge\Command;

use RuntimeException;
use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\TranslationProviderResolverInterface;
use Netlogix\ShopwareTranslationBridge\Resolver\ConfigurationResolver;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
Expand All @@ -16,29 +17,27 @@
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Provider\TranslationProviderCollection;
use Symfony\Component\Translation\Provider\ProviderInterface;
use Symfony\Component\Translation\Writer\TranslationWriterInterface;

#[AsCommand('sw:snippets:pull')]
class PullSnippetsCommand extends Command
{
private const string REMOTE_DOMAIN = 'messages';

private const string STORAGE_DIRECTORY = 'nlx-storefront-translation';

function __construct(
#[Autowire(service: 'translation.provider_collection')]
private readonly TranslationProviderCollection $providers,
public function __construct(
private readonly TranslationProviderResolverInterface $translationProviderResolver,
private readonly ConfigurationResolver $configurationResolver,
private readonly EntityRepository $languageRepository,
private readonly EntityRepository $salesChannelRepository,
#[Autowire(service: 'translation.writer')]
private readonly TranslationWriterInterface $translationWriter,
#[Autowire(param: 'translator.default_path')]
private readonly string $translatorDefaultPath,
#[Autowire(param: 'nlx_storefront_translation.default_provider')]
private readonly ?string $defaultProvider,
#[Autowire(param: 'nlx_storefront_translation.sales_channel_provider')]
private readonly array $salesChannelProviders
private readonly string $translatorDefaultPath
) {
parent::__construct();
}
Expand All @@ -48,27 +47,37 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$io = new SymfonyStyle($input, $output);

$translationPath = $this->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
if (!$this->translationProviderResolver->hasProvider()) {
$io->warning(
'No translation provider configured. Configure a default provider or at least one sales channel provider.'
);

return Command::SUCCESS;
}

foreach ($this->salesChannelProviders as $salesChannelId => $providerName) {
if (!is_string($providerName)) {
continue;
}
$defaultProviderName = $this->configurationResolver->getProviderName();

$io->note('Fetching translations for keyProvider');
$domainsFetched += $this->fetchTranslations(
$this->translationProviderResolver->getProvider(),
self::REMOTE_DOMAIN,
$this->getAllLocales(),
$translationPath,
$io
);

$salesChannelIdsWithProvider = $this->getSalesChannelIdsWithProviderOverride($defaultProviderName);
if (empty($salesChannelIdsWithProvider)) {
$io->note('No own sales-channels providers found.');
}

foreach ($salesChannelIdsWithProvider as $salesChannelId) {
$io->note(sprintf('Fetching translations for Sales-Channel-ID: %s.', $salesChannelId));
$domainsFetched += $this->fetchTranslations(
$providerName,
$this->translationProviderResolver->getProvider($salesChannelId),
$salesChannelId,
$this->getLocalesForSalesChannel($salesChannelId),
$translationPath,
Expand All @@ -93,23 +102,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int
* @param list<string> $locales
*/
private function fetchTranslations(
string $providerName,
ProviderInterface $provider,
string $targetDomain,
array $locales,
string $translationPath,
SymfonyStyle $io
): int {
$providerName = $this->getProviderName($provider);

if ($locales === []) {
$io->note(sprintf('Skipping "%s" because no locales were found.', $targetDomain));

return 0;
}

if (!$this->providers->has($providerName)) {
throw new RuntimeException(sprintf('Provider "%s" not found.', $providerName));
}

$provider = $this->providers->get($providerName);
$translationBag = $provider->read([self::REMOTE_DOMAIN], $locales);

$written = 0;
Expand All @@ -123,7 +129,9 @@ private function fetchTranslations(
$newCatalogue = new MessageCatalogue($catalogue->getLocale());
$newCatalogue->add($messages, $targetDomain);

$this->translationWriter->write($newCatalogue, 'json', ['path' => $translationPath]);
$this->translationWriter->write($newCatalogue, 'json', [
'path' => $translationPath,
]);
++$written;
}

Expand All @@ -143,12 +151,39 @@ private function fetchTranslations(
return 1;
}

private function getProviderName(ProviderInterface $provider): string
{
return parse_url((string) $provider, \PHP_URL_SCHEME) ?: 'unknown';
}

/**
* Returns sales channels whose configured provider differs from the global default,
* i.e. a real per-channel override. Channels that merely inherit the default are
* excluded so their translations are not pulled redundantly.
*
* @return list<string>
*/
private function getSalesChannelIdsWithProviderOverride(string $defaultProviderName): array
{
$result = $this->salesChannelRepository->searchIds(new Criteria(), Context::createCLIContext());

return array_values(array_filter(
$result->getIds(),
function (string $salesChannelId) use ($defaultProviderName): bool {
$providerName = $this->configurationResolver->getProviderName($salesChannelId);

return $providerName !== null && $providerName !== $defaultProviderName;
}
));
}

/**
* @return list<string>
*/
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);
Expand Down Expand Up @@ -201,12 +236,6 @@ private function resolveTranslationPath(): string

private function ensureDirectoryExists(string $path): void
{
if (is_dir($path)) {
return;
}

if (!mkdir($path, 0777, true) && !is_dir($path)) {
throw new RuntimeException(sprintf('Unable to create translation directory "%s".', $path));
}
(new Filesystem())->mkdir($path);
}
}
Loading