Files
2026-09-15 10:35:56 +08:00

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;
}
}