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 $values * @return array> */ 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 $batch * @return array */ 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} */ 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 $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; } }