Skip to content

Commit f2c9ff2

Browse files
author
roadiz-ci
committed
feat: Add Deepl translator to markdown fields (#185)
1 parent 0c62433 commit f2c9ff2

18 files changed

Lines changed: 601 additions & 113 deletions

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"php": ">=8.3",
2222
"roadiz/core-bundle": "2.6.*",
2323
"roadiz/openid": "2.6.*",
24-
"symfony/framework-bundle": "7.3.*"
24+
"symfony/framework-bundle": "7.3.*",
25+
"deeplcom/deepl-php": "^1.12"
2526
},
2627
"require-dev": {
2728
"php-coveralls/php-coveralls": "^2.4",

config/routing.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,10 @@ webhooksRoutes:
206206
resource: "routing/webhooks.yml"
207207
prefix: /rz-admin/webhooks
208208

209+
# Translate assistant
210+
translateAssistantRoutes:
211+
resource: "routing/translate-assistant.yml"
212+
prefix: /rz-admin/translate-assistant
209213

210214
#
211215
# CSS to style with main color
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
translateAssistantTranslate:
2+
path: /translate
3+
defaults:
4+
_controller: RZ\Roadiz\RozierBundle\TranslateAssistant\TranslateAssistantController::translateAction
5+
translateAssistantRephrase:
6+
path: /rephrase
7+
defaults:
8+
_controller: RZ\Roadiz\RozierBundle\TranslateAssistant\TranslateAssistantController::rephraseAction

public/shared-DU5E6YPu.css

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace RZ\Roadiz\RozierBundle\Command;
6+
7+
use RZ\Roadiz\RozierBundle\TranslateAssistant\TranslateAssistantInput;
8+
use RZ\Roadiz\RozierBundle\TranslateAssistant\TranslateAssistantInterface;
9+
use Symfony\Component\Console\Command\Command;
10+
use Symfony\Component\Console\Input\InputArgument;
11+
use Symfony\Component\Console\Input\InputInterface;
12+
use Symfony\Component\Console\Output\OutputInterface;
13+
use Symfony\Component\Console\Style\SymfonyStyle;
14+
15+
final class TranslateAssistantRephraseCommand extends Command
16+
{
17+
public function __construct(
18+
private readonly TranslateAssistantInterface $translateAssistant,
19+
?string $name = null,
20+
) {
21+
parent::__construct($name);
22+
}
23+
24+
#[\Override]
25+
protected function configure(): void
26+
{
27+
$this->setName('translate-assistant:rephrase')
28+
->setDescription('Use translator assistant to rephrase a string.')
29+
->addArgument(
30+
'text',
31+
InputArgument::REQUIRED,
32+
'Text to rephrase.'
33+
)
34+
->addArgument(
35+
'lang',
36+
InputArgument::REQUIRED,
37+
'Set locale used to rephrase.'
38+
)
39+
;
40+
}
41+
42+
#[\Override]
43+
protected function execute(InputInterface $input, OutputInterface $output): int
44+
{
45+
$io = new SymfonyStyle($input, $output);
46+
$text = $input->getArgument('text');
47+
$lang = $input->getArgument('lang');
48+
49+
if (!\is_string($text) || empty($text)) {
50+
throw new \InvalidArgumentException('Text argument is required.');
51+
}
52+
53+
if (!\is_string($lang) || empty($lang)) {
54+
throw new \InvalidArgumentException('Lang option is required.');
55+
}
56+
57+
$dto = new TranslateAssistantInput(
58+
$text,
59+
$lang,
60+
$lang
61+
);
62+
63+
$result = $this->translateAssistant->rephrase($dto);
64+
65+
$io->success($result->translatedText);
66+
67+
return 0;
68+
}
69+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace RZ\Roadiz\RozierBundle\Command;
6+
7+
use RZ\Roadiz\RozierBundle\TranslateAssistant\TranslateAssistantInput;
8+
use RZ\Roadiz\RozierBundle\TranslateAssistant\TranslateAssistantInterface;
9+
use Symfony\Component\Console\Command\Command;
10+
use Symfony\Component\Console\Input\InputArgument;
11+
use Symfony\Component\Console\Input\InputInterface;
12+
use Symfony\Component\Console\Output\OutputInterface;
13+
use Symfony\Component\Console\Style\SymfonyStyle;
14+
15+
final class TranslateAssistantTranslateCommand extends Command
16+
{
17+
public function __construct(
18+
private readonly TranslateAssistantInterface $translateAssistant,
19+
?string $name = null,
20+
) {
21+
parent::__construct($name);
22+
}
23+
24+
#[\Override]
25+
protected function configure(): void
26+
{
27+
$this->setName('translate-assistant:translate')
28+
->setDescription('Use translator assistant to translate a string.')
29+
->addArgument(
30+
'text',
31+
InputArgument::REQUIRED,
32+
'Text to translate.'
33+
)
34+
->addArgument(
35+
'lang',
36+
InputArgument::REQUIRED,
37+
'Set locale to translate to.'
38+
)
39+
;
40+
}
41+
42+
#[\Override]
43+
protected function execute(InputInterface $input, OutputInterface $output): int
44+
{
45+
$io = new SymfonyStyle($input, $output);
46+
$text = $input->getArgument('text');
47+
$lang = $input->getArgument('lang');
48+
49+
if (!\is_string($text) || empty($text)) {
50+
throw new \InvalidArgumentException('Text argument is required.');
51+
}
52+
53+
if (!\is_string($lang) || empty($lang)) {
54+
throw new \InvalidArgumentException('Lang option is required.');
55+
}
56+
57+
$dto = new TranslateAssistantInput(
58+
$text,
59+
$lang
60+
);
61+
62+
$result = $this->translateAssistant->translate($dto);
63+
64+
$io->success($result->translatedText);
65+
66+
return 0;
67+
}
68+
}

src/DependencyInjection/Configuration.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ public function getConfigTreeBuilder(): TreeBuilder
6767
->end()
6868
->append($this->addOpenIdNode())
6969
->append($this->addCsvNode())
70+
->append($this->addTranslateAssistantNode())
7071
;
7172

7273
return $builder;
@@ -186,4 +187,18 @@ protected function addCsvNode(): NodeDefinition
186187

187188
return $node;
188189
}
190+
191+
protected function addTranslateAssistantNode(): NodeDefinition
192+
{
193+
$builder = new TreeBuilder('translate_assistant');
194+
$node = $builder->getRootNode();
195+
$builder->getRootNode()
196+
->children()
197+
->scalarNode('deepl_api_key')
198+
->cannotBeEmpty()
199+
->end()
200+
->end();
201+
202+
return $node;
203+
}
189204
}

src/DependencyInjection/RoadizRozierExtension.php

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44

55
namespace RZ\Roadiz\RozierBundle\DependencyInjection;
66

7+
use Psr\Cache\CacheItemPoolInterface;
78
use RZ\Roadiz\OpenId\Discovery;
9+
use RZ\Roadiz\RozierBundle\TranslateAssistant\DeeplTranslateAssistant;
10+
use RZ\Roadiz\RozierBundle\TranslateAssistant\NullTranslateAssistant;
11+
use RZ\Roadiz\RozierBundle\TranslateAssistant\TranslateAssistantInterface;
812
use Symfony\Component\Config\FileLocator;
913
use Symfony\Component\DependencyInjection\ContainerBuilder;
1014
use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -47,6 +51,7 @@ public function load(array $configs, ContainerBuilder $container): void
4751
$loader->load('services.yaml');
4852

4953
$this->registerOpenId($config, $container);
54+
$this->registerTranslateAssistant($config, $container);
5055
}
5156

5257
private function registerOpenId(array $config, ContainerBuilder $container): void
@@ -74,7 +79,7 @@ private function registerOpenId(array $config, ContainerBuilder $container): voi
7479
->setPublic(true)
7580
->setArguments([
7681
$config['open_id']['discovery_url'],
77-
new Reference(\Psr\Cache\CacheItemPoolInterface::class),
82+
new Reference(CacheItemPoolInterface::class),
7883
new Reference(HttpClientInterface::class),
7984
new Reference(\Psr\Log\LoggerInterface::class),
8085
])
@@ -122,4 +127,28 @@ private function registerOpenId(array $config, ContainerBuilder $container): voi
122127
])
123128
);
124129
}
130+
131+
private function registerTranslateAssistant(array $config, ContainerBuilder $container): void
132+
{
133+
if (!empty($config['translate_assistant']['deepl_api_key'])) {
134+
$container->setParameter('roadiz_rozier.translate_assistant.deepl_api_key', $config['translate_assistant']['deepl_api_key']);
135+
$container->setDefinition(
136+
TranslateAssistantInterface::class,
137+
(new Definition())
138+
->setClass(DeeplTranslateAssistant::class)
139+
->setArguments([
140+
new Reference(CacheItemPoolInterface::class),
141+
'%roadiz_rozier.translate_assistant.deepl_api_key%',
142+
])
143+
);
144+
145+
return;
146+
}
147+
148+
$container->setDefinition(
149+
TranslateAssistantInterface::class,
150+
(new Definition())
151+
->setClass(NullTranslateAssistant::class)
152+
);
153+
}
125154
}

src/Form/NodeSource/NodeSourceType.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ public function getFormOptionsFromFieldType(NodesSources $nodeSource, NodeTypeFi
284284
'attr' => [
285285
'class' => 'markdown_textarea',
286286
],
287+
'locale' => $nodeSource->getTranslation()->getLocale(),
287288
], $additionalOptions);
288289
break;
289290
case FieldType::CHILDREN_T:
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace RZ\Roadiz\RozierBundle\TranslateAssistant;
6+
7+
use DeepL\DeepLClient;
8+
use DeepL\DeepLException;
9+
use DeepL\Language;
10+
use Psr\Cache\CacheItemPoolInterface;
11+
use Psr\Cache\InvalidArgumentException;
12+
13+
final readonly class DeeplTranslateAssistant implements TranslateAssistantInterface
14+
{
15+
public function __construct(
16+
private CacheItemPoolInterface $cache,
17+
private string $apiKey,
18+
) {
19+
}
20+
21+
/**
22+
* @throws DeepLException
23+
* @throws InvalidArgumentException
24+
*/
25+
#[\Override]
26+
public function translate(TranslateAssistantInput $translatorDto): TranslateAssistantOutput
27+
{
28+
if (empty($this->apiKey)) {
29+
throw new DeepLException('DeepL api key is required.');
30+
}
31+
32+
$deeplClient = new DeepLClient($this->apiKey);
33+
34+
$this->denyNotAvailableLanguages($this->transformTargetLang($translatorDto->targetLang), $deeplClient, 'translate');
35+
36+
$result = $deeplClient->translateText($translatorDto->text, $translatorDto->sourceLang, $this->transformTargetLang($translatorDto->targetLang), $translatorDto->options);
37+
38+
if (is_array($result)) {
39+
$result = $result[0];
40+
}
41+
42+
return new TranslateAssistantOutput(
43+
originalText: $translatorDto->text,
44+
translatedText: $result->text,
45+
sourceLang: $result->detectedSourceLang,
46+
targetLang: $translatorDto->targetLang,
47+
);
48+
}
49+
50+
/**
51+
* This feature requires a PRO Deepl Api-token.
52+
* https://developers.deepl.com/api-reference/improve-text/deepl-write-api-service-specification-updates.
53+
*
54+
* @throws DeepLException|InvalidArgumentException
55+
*/
56+
#[\Override]
57+
public function rephrase(TranslateAssistantInput $translatorDto): TranslateAssistantOutput
58+
{
59+
if (empty($this->apiKey)) {
60+
throw new DeepLException('DeepL api key is required.');
61+
}
62+
63+
$deeplClient = new DeepLClient($this->apiKey);
64+
65+
$this->denyNotAvailableLanguages($translatorDto->targetLang, $deeplClient, 'rephrase');
66+
67+
$result = $deeplClient->rephraseText($translatorDto->text, $this->transformTargetLang($translatorDto->targetLang), $translatorDto->options);
68+
69+
return new TranslateAssistantOutput(
70+
originalText: $translatorDto->text,
71+
translatedText: is_array($result) ? $result[0]->text : $result->text,
72+
sourceLang: $translatorDto->sourceLang,
73+
targetLang: $translatorDto->targetLang,
74+
);
75+
}
76+
77+
private function transformTargetLang(string $targetLang): string
78+
{
79+
return match ($targetLang) {
80+
'en' => 'en-GB',
81+
'pt' => 'pt-PT',
82+
default => $targetLang,
83+
};
84+
}
85+
86+
/**
87+
* @throws DeepLException
88+
* @throws InvalidArgumentException
89+
*/
90+
private function denyNotAvailableLanguages(string $targetLanguage, DeepLClient $deeplClient, string $method): void
91+
{
92+
$languageAvailableCacheItem = $this->cache->getItem('DeeplTranslateAssistant_targetLanguages_'.$method.'_'.$targetLanguage);
93+
94+
if (!$languageAvailableCacheItem->isHit()) {
95+
$languageAvailableCacheItem->set(
96+
in_array(
97+
mb_strtoupper($this->transformTargetLang($targetLanguage)),
98+
array_map(fn (Language $language) => $language->code, $deeplClient->getTargetLanguages())
99+
)
100+
);
101+
$languageAvailableCacheItem->expiresAfter(3600);
102+
$this->cache->save($languageAvailableCacheItem);
103+
}
104+
105+
if (!$languageAvailableCacheItem->get()) {
106+
throw new DeepLException('Invalid target language.');
107+
}
108+
}
109+
110+
#[\Override]
111+
public function supportRephrase(): bool
112+
{
113+
return !str_ends_with($this->apiKey, ':fx');
114+
}
115+
}

0 commit comments

Comments
 (0)