Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
42 changes: 13 additions & 29 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,16 @@ bin/console plugin:install --activate ShopwareTranslationBridge

## Configuration

The connection to the translation provider is configured via a DSN (Data Source Name). You need to create a configuration file, for example `config/packages/shopware_translation_bridge.yaml`, to set up the providers.

The plugin uses the DSN from the `ShopwareTranslationBridge.config.providerDsn` system config key as a default. You can also configure a specific DSN for each sales channel.

| option | type | default | info |
|---------------------------|----------------|---------|----------------------------------------------------------------------------------------------------|
| default_provider | `null\|string` | `null` | Service name from `framework.translator.providers`. If `null` there is no fallback provider. |
| respect_translation_files | `bool` | `true` | should it overlay the snippet files with the translation files `framework.translator.default_path` |
| sales_channel_providers | `array` | `[]` | SalesChannel specific providers. Like `default_provider` but individiual for every salesChannel |

### Example Configuration

Here is an example of how to configure different providers for different sales channels.

```yaml
# config/packages/shopware_translation_bridge.yaml
shopware_translation_bridge:
# Define a default provider for all sales channels
default_provider: 'providerServiceName'
respect_translation_files: true
sales_channel_providers:
# Assign a specific provider for a sales channel by its ID
2b919afec10730f413cb5682bbed09fd:
provider: 'providerServiceName'
```
The Symfony Translation providers themselves (the DSNs) are still configured the usual Symfony way, e.g. in `config/packages/translation.yaml` under `framework.translator.providers`.

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

| field | type | default | info |
|---------------------------------|----------|---------|----------------------------------------------------------------------------------------------------------|
| Default translation provider | `string` | empty | Service name from `framework.translator.providers` (e.g. `tolgee`). Empty means no fallback provider. |
| Respect local translation files | `bool` | `true` | Whether to overlay the snippet files with the translation files from `framework.translator.default_path` |

Both fields can be overridden per sales channel using the sales channel selector at the top of that config screen - pick a sales channel, set a different "Default translation provider" (or "Respect local translation files"), and save. Leaving a sales channel's field empty falls back to the global default.

## Commands

Expand All @@ -61,7 +46,7 @@ bin/console sw:snippets:push [salesChannelId1] [salesChannelId2]

### Pull Snippets

Pulls all snippets from the configured translation provider and saves them locally inside the translation directory defined by `framework.translator.default_path`. The default provider (if configured) is written to the `messages` translation domain, while every entry of `sales_channel_providers` is persisted to a domain that matches the configured sales channel id.
Pulls all snippets from the configured translation provider and saves them locally inside the translation directory defined by `framework.translator.default_path`. The default provider (if configured) is written to the `messages` translation domain, while every sales channel with an explicit provider override is persisted to a domain that matches the sales channel id.

```bash
bin/console sw:snippets:pull [salesChannelId1]
Expand All @@ -80,15 +65,14 @@ bin/console sw:snippets:pull [salesChannelId1]
Flushes the translation cache. This is useful after pulling new translations to make them visible in the storefront.

```bash
bin/console sw:cache:flush:translation
bin/console sw:cache:translation:flush
```

## API Endpoint

This plugin provides an API endpoint to trigger a translation update for specific sales channels. This is useful for integrating with webhooks from translation providers (e.g., when translations are completed).

* **URL:** `/api/_action/nlx/translation/update`
* **Method:** `POST`
* **URL:** `/api/_action/nlx-translation/update`
* **Body (JSON):**
```json
{
Expand Down
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
65 changes: 39 additions & 26 deletions src/Command/PullSnippetsCommand.php
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<?php

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

namespace Netlogix\ShopwareTranslationBridge\Command;

use Netlogix\ShopwareTranslationBridge\Core\System\Snippet\TranslationProviderResolverInterface;
use RuntimeException;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
Expand All @@ -17,28 +18,24 @@
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Provider\TranslationProviderCollection;
use Symfony\Component\Translation\Provider\ProviderInterface;
use Symfony\Component\Translation\Writer\TranslationWriterInterface;

#[AsCommand('sw:snippets:pull')]
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 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 @@ -52,23 +49,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int

$domainsFetched = 0;

if (is_string($this->defaultProvider) && $this->defaultProvider !== '') {
if ($this->translationProviderResolver->hasDefaultProvider()) {
$domainsFetched += $this->fetchTranslations(
$this->defaultProvider,
$this->translationProviderResolver->getDefaultProvider(),
self::REMOTE_DOMAIN,
$this->getAllLocales(),
$translationPath,
$io
);
}

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

foreach ($this->getSalesChannelIdsWithProviderOverride() as $salesChannelId) {
$domainsFetched += $this->fetchTranslations(
$providerName,
$this->translationProviderResolver->getSalesChannelProvider($salesChannelId),
$salesChannelId,
$this->getLocalesForSalesChannel($salesChannelId),
$translationPath,
Expand All @@ -93,23 +86,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 +113,9 @@ private function fetchTranslations(
$newCatalogue = new MessageCatalogue($catalogue->getLocale());
$newCatalogue->add($messages, $targetDomain);

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

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

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

/**
* @return list<string>
*/
private function getSalesChannelIdsWithProviderOverride(): array
{
$result = $this->salesChannelRepository->searchIds(new Criteria(), Context::createCLIContext());

return array_values(array_filter(
$result->getIds(),
fn (string $salesChannelId): bool => $this->translationProviderResolver->hasSalesChannelProvider(
$salesChannelId
)
));
}

/**
* @return list<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
6 changes: 3 additions & 3 deletions src/Command/PushSnippetsCommand.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 Down Expand Up @@ -29,7 +29,7 @@
#[AsCommand('sw:snippets:push')]
class PushSnippetsCommand extends Command
{
function __construct(
public function __construct(
#[Autowire(service: 'translation.provider_collection')]
private readonly TranslationProviderCollection $providers,
private readonly SnippetService $snippetService,
Expand Down Expand Up @@ -75,7 +75,7 @@ protected function configure(): void
'l',
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'Specify the locales to push.'
)
),
]);
}

Expand Down
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\Core\Framework\Adapter\Translator;

Expand Down
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\Core\Framework\Adapter\Translator;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Netlogix\ShopwareTranslationBridge\Core\Framework\Api\Controller;

use Shopware\Core\Framework\Api\Response\JsonApiResponse;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Translation\Provider\TranslationProviderCollection;

#[Route(
path: '/api/_action/nlx-translation/providers',
name: 'api.action.nlx.translation_providers',
defaults: [
'_routeScope' => ['api'],
'_acl' => ['system_config:read'],
],
methods: ['GET']
)]
class TranslationProviderOptionsController extends AbstractController
{
public function __construct(
#[Autowire(service: 'translation.provider_collection')]
private readonly TranslationProviderCollection $providers,
) {
}

public function __invoke(): JsonApiResponse
{
$options = array_map(
static fn (string $name): array => [
'value' => $name,
'label' => $name,
],
$this->providers->keys()
);

return new JsonApiResponse([
'options' => $options,
]);
}
}
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\Core\Framework\Api\Controller;

Expand All @@ -20,21 +20,21 @@
name: 'api.action.nlx.translation_update',
defaults: [
'_routeScope' => ['api'],
'_acl' => ['system:cache:info']
'_acl' => ['system:cache:info'],
],
methods: ['POST']
)]
class UpdateTranslationController extends AbstractController
{
function __construct(
public function __construct(
private readonly EntityRepository $salesChannelRepository,
private readonly MessageBusInterface $messageBus,
private readonly TranslationProviderResolverInterface $translationProviderResolver,
private readonly int $batchSize = 5
) {
}

function __invoke(): JsonApiResponse
public function __invoke(): JsonApiResponse
{
$salesChannelIds = $this->salesChannelRepository
->searchIds(new Criteria(), Context::createCLIContext())
Expand All @@ -51,14 +51,16 @@ function __invoke(): JsonApiResponse
if ($salesChannelIds === []) {
return new JsonApiResponse([
'success' => false,
'error' => 'errorMissingTranslationProvider'
'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]);
return new JsonApiResponse([
'success' => true,
]);
}
}
Loading