313 lines
11 KiB
PHP
313 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Esun\MultiLocaleTranslation\Model\Provider;
|
|
|
|
use Esun\MultiLocaleTranslation\Api\TranslationProviderInterface;
|
|
use Esun\MultiLocaleTranslation\Model\Google\AccessToken;
|
|
use Esun\MultiLocaleTranslation\Model\Google\Credentials;
|
|
use Esun\MultiLocaleTranslation\Model\Html\TranslatableTextExtractor;
|
|
use Magento\Framework\App\Config\ScopeConfigInterface;
|
|
use Magento\Framework\Exception\LocalizedException;
|
|
use Magento\Framework\HTTP\Client\CurlFactory;
|
|
|
|
class Google implements TranslationProviderInterface
|
|
{
|
|
private const API_BASE_URL = 'https://translation.googleapis.com/v3/projects/';
|
|
private const XML_PATH_ENABLED = 'esun_multilocale_translation/google/enabled';
|
|
private const XML_PATH_LOCATION = 'esun_multilocale_translation/google/location';
|
|
private const XML_PATH_GLOSSARY_ID = 'esun_multilocale_translation/google/glossary_id';
|
|
private const XML_PATH_GLOSSARY_IGNORE_CASE = 'esun_multilocale_translation/google/glossary_ignore_case';
|
|
private const XML_PATH_TIMEOUT = 'esun_multilocale_translation/google/timeout';
|
|
private const MAX_BATCH_CODEPOINTS = 25000;
|
|
private const MAX_BATCH_ITEMS = 100;
|
|
private const MAX_HTML_FRAGMENT_BYTES = 25000;
|
|
|
|
public function __construct(
|
|
private readonly ScopeConfigInterface $scopeConfig,
|
|
private readonly Credentials $credentials,
|
|
private readonly AccessToken $accessToken,
|
|
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(__('Google automatic translation is disabled in configuration.'));
|
|
}
|
|
if ($sourceLanguage === '' || $targetLanguage === '') {
|
|
throw new LocalizedException(__('Google V3 requires both source and target languages.'));
|
|
}
|
|
|
|
$location = $this->getLocation();
|
|
$glossaryId = $this->getGlossaryId();
|
|
if ($glossaryId !== '' && $location !== 'us-central1') {
|
|
throw new LocalizedException(__('Google glossaries require the us-central1 location.'));
|
|
}
|
|
|
|
$textFields = [];
|
|
$htmlFields = [];
|
|
$placeholderMaps = [];
|
|
foreach ($fields as $code => $field) {
|
|
if (!is_array($field)) {
|
|
continue;
|
|
}
|
|
|
|
$value = (string)($field['value'] ?? '');
|
|
if ($value === '') {
|
|
continue;
|
|
}
|
|
|
|
[$preparedValue, $placeholderMaps[(string)$code]] = $this->protectMagentoDirectives($value);
|
|
if (!empty($field['is_html'])) {
|
|
$htmlFields[(string)$code] = $preparedValue;
|
|
} else {
|
|
$textFields[(string)$code] = $preparedValue;
|
|
}
|
|
}
|
|
|
|
$translated = [];
|
|
foreach ($this->createBatches($textFields) as $batch) {
|
|
$translated += $this->translateBatch(
|
|
$batch,
|
|
$sourceLanguage,
|
|
$targetLanguage,
|
|
'text/plain',
|
|
$location,
|
|
$glossaryId
|
|
);
|
|
}
|
|
|
|
foreach ($htmlFields as $code => $value) {
|
|
$translated[$code] = $this->translateHtml(
|
|
$value,
|
|
$sourceLanguage,
|
|
$targetLanguage,
|
|
$location,
|
|
$glossaryId
|
|
);
|
|
}
|
|
|
|
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 $location,
|
|
string $glossaryId
|
|
): 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,
|
|
'text/html',
|
|
$location,
|
|
$glossaryId
|
|
);
|
|
}
|
|
|
|
for ($index = count($fragments) - 1; $index >= 0; $index--) {
|
|
$fragment = $fragments[$index];
|
|
$translation = $translations[(string)$index] ?? null;
|
|
if ($translation === null) {
|
|
throw new LocalizedException(__('Google 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;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $values
|
|
* @return array<int, array<string, string>>
|
|
*/
|
|
private function createBatches(array $values): array
|
|
{
|
|
$batches = [];
|
|
$batch = [];
|
|
$codepoints = 0;
|
|
|
|
foreach ($values as $code => $value) {
|
|
$valueCodepoints = mb_strlen($value, 'UTF-8');
|
|
if ($valueCodepoints > self::MAX_BATCH_CODEPOINTS) {
|
|
throw new LocalizedException(
|
|
__('Field "%1" is too large for synchronous Google V3 translation.', $code)
|
|
);
|
|
}
|
|
|
|
if ($batch !== [] && ($codepoints + $valueCodepoints > self::MAX_BATCH_CODEPOINTS
|
|
|| count($batch) >= self::MAX_BATCH_ITEMS)
|
|
) {
|
|
$batches[] = $batch;
|
|
$batch = [];
|
|
$codepoints = 0;
|
|
}
|
|
|
|
$batch[$code] = $value;
|
|
$codepoints += $valueCodepoints;
|
|
}
|
|
|
|
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 $mimeType,
|
|
string $location,
|
|
string $glossaryId
|
|
): array {
|
|
$credentials = $this->credentials->get();
|
|
$projectId = $credentials['project_id'];
|
|
$parent = 'projects/' . $projectId . '/locations/' . $location;
|
|
$requestData = [
|
|
'contents' => array_values($batch),
|
|
'sourceLanguageCode' => $sourceLanguage,
|
|
'targetLanguageCode' => $targetLanguage,
|
|
'mimeType' => $mimeType,
|
|
'model' => $parent . '/models/general/nmt',
|
|
];
|
|
if ($glossaryId !== '') {
|
|
$requestData['glossaryConfig'] = [
|
|
'glossary' => $parent . '/glossaries/' . $glossaryId,
|
|
'ignoreCase' => $this->scopeConfig->isSetFlag(self::XML_PATH_GLOSSARY_IGNORE_CASE),
|
|
];
|
|
}
|
|
|
|
$payload = json_encode(
|
|
$requestData,
|
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
|
|
);
|
|
$client = $this->curlFactory->create();
|
|
$client->setTimeout(max(5, min(120, (int)$this->scopeConfig->getValue(self::XML_PATH_TIMEOUT))));
|
|
$client->setHeaders([
|
|
'Authorization' => 'Bearer ' . $this->accessToken->get(),
|
|
'Content-Type' => 'application/json; charset=utf-8',
|
|
'x-goog-user-project' => $projectId,
|
|
]);
|
|
$client->post(
|
|
self::API_BASE_URL . rawurlencode($projectId)
|
|
. '/locations/' . rawurlencode($location) . ':translateText',
|
|
$payload
|
|
);
|
|
|
|
$response = json_decode((string)$client->getBody(), true);
|
|
if ($client->getStatus() < 200 || $client->getStatus() >= 300) {
|
|
$message = is_array($response) ? (string)($response['error']['message'] ?? '') : '';
|
|
throw new LocalizedException(
|
|
__('Google V3 translation failed (HTTP %1): %2',
|
|
$client->getStatus(),
|
|
$message ?: 'Unknown error')
|
|
);
|
|
}
|
|
|
|
$responseKey = $glossaryId !== '' ? 'glossaryTranslations' : 'translations';
|
|
$items = is_array($response) ? ($response[$responseKey] ?? null) : null;
|
|
if (!is_array($items) || count($items) !== count($batch)) {
|
|
throw new LocalizedException(__('Google V3 returned an invalid translation response.'));
|
|
}
|
|
|
|
$result = [];
|
|
$codes = array_keys($batch);
|
|
foreach ($items as $index => $item) {
|
|
$value = is_array($item) ? (string)($item['translatedText'] ?? '') : '';
|
|
$result[$codes[$index]] = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
private function getLocation(): string
|
|
{
|
|
$location = trim((string)$this->scopeConfig->getValue(self::XML_PATH_LOCATION)) ?: 'global';
|
|
if (!preg_match('/^[a-z0-9-]+$/', $location)) {
|
|
throw new LocalizedException(__('The configured Google location is invalid.'));
|
|
}
|
|
|
|
return $location;
|
|
}
|
|
|
|
private function getGlossaryId(): string
|
|
{
|
|
$glossaryId = trim((string)$this->scopeConfig->getValue(self::XML_PATH_GLOSSARY_ID));
|
|
if ($glossaryId !== '' && !preg_match('/^[a-zA-Z0-9_-]+$/', $glossaryId)) {
|
|
throw new LocalizedException(__('The configured Google Glossary ID is invalid.'));
|
|
}
|
|
|
|
return $glossaryId;
|
|
}
|
|
|
|
/**
|
|
* @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(
|
|
__('Google changed a Magento directive placeholder in field "%1". Nothing was saved.', $fieldCode)
|
|
);
|
|
}
|
|
$value = str_replace($token, $directive, $value);
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
}
|