299 lines
9.8 KiB
PHP
299 lines
9.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Esun\MultiLocaleTranslation\Model\Provider;
|
|
|
|
use Esun\MultiLocaleTranslation\Api\TranslationProviderInterface;
|
|
use Esun\MultiLocaleTranslation\Model\Html\TranslatableTextExtractor;
|
|
use Magento\Framework\App\Config\ScopeConfigInterface;
|
|
use Magento\Framework\Encryption\EncryptorInterface;
|
|
use Magento\Framework\Exception\LocalizedException;
|
|
use Magento\Framework\HTTP\Client\CurlFactory;
|
|
|
|
class DeepL implements TranslationProviderInterface
|
|
{
|
|
private const FREE_API_URL = 'https://api-free.deepl.com/v2/translate';
|
|
private const PRO_API_URL = 'https://api.deepl.com/v2/translate';
|
|
private const XML_PATH_ENABLED = 'esun_multilocale_translation/deepl/enabled';
|
|
private const XML_PATH_API_URL = 'esun_multilocale_translation/deepl/api_url';
|
|
private const XML_PATH_AUTH_KEY = 'esun_multilocale_translation/deepl/auth_key';
|
|
private const XML_PATH_FORMALITY = 'esun_multilocale_translation/deepl/formality';
|
|
private const XML_PATH_TIMEOUT = 'esun_multilocale_translation/deepl/timeout';
|
|
private const MAX_BATCH_BYTES = 90000;
|
|
private const MAX_BATCH_ITEMS = 50;
|
|
private const MAX_HTML_FRAGMENT_BYTES = 30000;
|
|
|
|
public function __construct(
|
|
private readonly ScopeConfigInterface $scopeConfig,
|
|
private readonly EncryptorInterface $encryptor,
|
|
private readonly CurlFactory $curlFactory,
|
|
private readonly TranslatableTextExtractor $textExtractor
|
|
) {
|
|
}
|
|
|
|
public function translate(array $fields, string $sourceLanguage, string $targetLanguage): array
|
|
{
|
|
if (!$this->scopeConfig->isSetFlag(self::XML_PATH_ENABLED)) {
|
|
throw new LocalizedException(__('DeepL automatic translation is disabled in configuration.'));
|
|
}
|
|
|
|
$authKey = $this->getAuthKey();
|
|
if ($authKey === '') {
|
|
throw new LocalizedException(__('DeepL Auth Key is not configured.'));
|
|
}
|
|
|
|
$textFields = [];
|
|
$htmlFields = [];
|
|
$placeholderMaps = [];
|
|
foreach ($fields as $code => $field) {
|
|
if (!is_array($field)) {
|
|
continue;
|
|
}
|
|
$value = (string)($field['value'] ?? '');
|
|
if ($value === '') {
|
|
continue;
|
|
}
|
|
|
|
$format = !empty($field['is_html']) ? 'html' : 'text';
|
|
[$preparedValue, $placeholderMaps[(string)$code]] = $this->protectMagentoDirectives($value);
|
|
if ($format === 'html') {
|
|
$htmlFields[(string)$code] = $preparedValue;
|
|
} else {
|
|
$textFields[(string)$code] = $preparedValue;
|
|
}
|
|
}
|
|
|
|
$translated = [];
|
|
foreach ($this->createBatches($textFields) as $batch) {
|
|
$translated += $this->translateBatch(
|
|
$batch,
|
|
$sourceLanguage,
|
|
$targetLanguage,
|
|
'text',
|
|
$authKey
|
|
);
|
|
}
|
|
|
|
foreach ($htmlFields as $code => $value) {
|
|
$translated[$code] = $this->translateHtml(
|
|
$value,
|
|
$sourceLanguage,
|
|
$targetLanguage,
|
|
$authKey
|
|
);
|
|
}
|
|
|
|
foreach ($translated as $code => $value) {
|
|
$translated[$code] = $this->restoreMagentoDirectives(
|
|
$value,
|
|
$placeholderMaps[$code] ?? [],
|
|
$code
|
|
);
|
|
}
|
|
|
|
return $translated;
|
|
}
|
|
|
|
private function translateHtml(
|
|
string $html,
|
|
string $sourceLanguage,
|
|
string $targetLanguage,
|
|
string $authKey
|
|
): string {
|
|
$fragments = $this->textExtractor->extract($html, self::MAX_HTML_FRAGMENT_BYTES);
|
|
if ($fragments === []) {
|
|
return $html;
|
|
}
|
|
|
|
$values = [];
|
|
foreach ($fragments as $index => $fragment) {
|
|
$values[(string)$index] = $fragment['text'];
|
|
}
|
|
|
|
$translations = [];
|
|
foreach ($this->createBatches($values) as $batch) {
|
|
$translations += $this->translateBatch(
|
|
$batch,
|
|
$sourceLanguage,
|
|
$targetLanguage,
|
|
'html',
|
|
$authKey
|
|
);
|
|
}
|
|
|
|
for ($index = count($fragments) - 1; $index >= 0; $index--) {
|
|
$fragment = $fragments[$index];
|
|
$translation = $translations[(string)$index] ?? null;
|
|
if ($translation === null) {
|
|
throw new LocalizedException(__('DeepL did not return every HTML text fragment. Nothing was saved.'));
|
|
}
|
|
$html = substr($html, 0, $fragment['offset'])
|
|
. $translation
|
|
. substr($html, $fragment['offset'] + $fragment['length']);
|
|
}
|
|
|
|
return $html;
|
|
}
|
|
|
|
private function getAuthKey(): string
|
|
{
|
|
$encrypted = trim((string)$this->scopeConfig->getValue(self::XML_PATH_AUTH_KEY));
|
|
if ($encrypted === '') {
|
|
return '';
|
|
}
|
|
|
|
try {
|
|
return trim((string)$this->encryptor->decrypt($encrypted));
|
|
} catch (\Throwable) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $values
|
|
* @return array<int, array<string, string>>
|
|
*/
|
|
private function createBatches(array $values): array
|
|
{
|
|
$batches = [];
|
|
$batch = [];
|
|
$bytes = 0;
|
|
|
|
foreach ($values as $code => $value) {
|
|
$valueBytes = strlen($value);
|
|
if ($valueBytes > self::MAX_BATCH_BYTES) {
|
|
throw new LocalizedException(__('Field "%1" is too large for DeepL translation.', $code));
|
|
}
|
|
if ($batch !== [] && ($bytes + $valueBytes > self::MAX_BATCH_BYTES
|
|
|| count($batch) >= self::MAX_BATCH_ITEMS)
|
|
) {
|
|
$batches[] = $batch;
|
|
$batch = [];
|
|
$bytes = 0;
|
|
}
|
|
$batch[$code] = $value;
|
|
$bytes += $valueBytes;
|
|
}
|
|
|
|
if ($batch !== []) {
|
|
$batches[] = $batch;
|
|
}
|
|
|
|
return $batches;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $batch
|
|
* @return array<string, string>
|
|
*/
|
|
private function translateBatch(
|
|
array $batch,
|
|
string $sourceLanguage,
|
|
string $targetLanguage,
|
|
string $format,
|
|
string $authKey
|
|
): array {
|
|
$requestData = [
|
|
'text' => array_values($batch),
|
|
'source_lang' => $sourceLanguage,
|
|
'target_lang' => $targetLanguage,
|
|
'preserve_formatting' => true,
|
|
'show_billed_characters' => true,
|
|
'model_type' => 'prefer_quality_optimized',
|
|
];
|
|
if ($format === 'html') {
|
|
$requestData['tag_handling'] = 'html';
|
|
$requestData['tag_handling_version'] = 'v2';
|
|
}
|
|
|
|
$formality = (string)$this->scopeConfig->getValue(self::XML_PATH_FORMALITY);
|
|
if (in_array($formality, ['prefer_more', 'prefer_less'], true)) {
|
|
$requestData['formality'] = $formality;
|
|
}
|
|
|
|
$client = $this->curlFactory->create();
|
|
$client->setTimeout(max(5, min(120, (int)$this->scopeConfig->getValue(self::XML_PATH_TIMEOUT))));
|
|
$client->setHeaders([
|
|
'Authorization' => 'DeepL-Auth-Key ' . $authKey,
|
|
'Content-Type' => 'application/json; charset=utf-8',
|
|
]);
|
|
$client->post(
|
|
$this->getApiUrl(),
|
|
json_encode(
|
|
$requestData,
|
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
|
|
)
|
|
);
|
|
|
|
$response = json_decode((string)$client->getBody(), true);
|
|
if ($client->getStatus() < 200 || $client->getStatus() >= 300) {
|
|
$message = is_array($response) ? (string)($response['message'] ?? '') : '';
|
|
throw new LocalizedException(
|
|
__('DeepL translation failed (HTTP %1): %2',
|
|
$client->getStatus(),
|
|
$message ?: 'Unknown error')
|
|
);
|
|
}
|
|
|
|
$items = is_array($response) ? ($response['translations'] ?? null) : null;
|
|
if (!is_array($items) || count($items) !== count($batch)) {
|
|
throw new LocalizedException(__('DeepL returned an invalid translation response.'));
|
|
}
|
|
|
|
$result = [];
|
|
$codes = array_keys($batch);
|
|
foreach ($items as $index => $item) {
|
|
$result[$codes[$index]] = is_array($item) ? (string)($item['text'] ?? '') : '';
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
private function getApiUrl(): string
|
|
{
|
|
$url = (string)$this->scopeConfig->getValue(self::XML_PATH_API_URL);
|
|
if ($url === self::PRO_API_URL) {
|
|
return self::PRO_API_URL;
|
|
}
|
|
|
|
return self::FREE_API_URL;
|
|
}
|
|
|
|
/**
|
|
* @return array{0:string,1:array<string,string>}
|
|
*/
|
|
private function protectMagentoDirectives(string $value): array
|
|
{
|
|
$placeholders = [];
|
|
$prepared = preg_replace_callback(
|
|
'/\{\{.*?\}\}/s',
|
|
static function (array $matches) use (&$placeholders): string {
|
|
$token = 'ESUNMLT' . strtoupper(bin2hex(random_bytes(10)));
|
|
$placeholders[$token] = $matches[0];
|
|
return $token;
|
|
},
|
|
$value
|
|
);
|
|
|
|
return [$prepared ?? $value, $placeholders];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $placeholders
|
|
*/
|
|
private function restoreMagentoDirectives(string $value, array $placeholders, string $fieldCode): string
|
|
{
|
|
foreach ($placeholders as $token => $directive) {
|
|
if (!str_contains($value, $token)) {
|
|
throw new LocalizedException(
|
|
__('DeepL changed a Magento directive placeholder in field "%1". Nothing was saved.', $fieldCode)
|
|
);
|
|
}
|
|
$value = str_replace($token, $directive, $value);
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
}
|