init
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user