From 313c0e4133063e063eae1e116a2a8c458a05826b Mon Sep 17 00:00:00 2001 From: vsolovei-smartling Date: Thu, 12 Feb 2026 20:09:03 +0100 Subject: [PATCH 1/6] add instant mt (WP-985) --- composer.json | 4 +- composer.lock | 100 +++- .../FTS/FileTranslationsApiExtended.php | 82 ++++ inc/Smartling/FTS/FtsApiWrapper.php | 239 ++++++++++ inc/Smartling/FTS/FtsService.php | 446 ++++++++++++++++++ .../Controller/ContentEditJobController.php | 1 + .../InstantTranslationController.php | 200 ++++++++ inc/Smartling/WP/View/ContentEditJob.php | 40 +- inc/config/register-on-startup.yml | 1 + inc/config/services.yml | 25 + js/app.js | 196 +++++++- js/instant-translation.js | 355 ++++++++++++++ readme.txt | 3 + smartling-connector.php | 2 +- tests/Smartling/FTS/FtsApiWrapperTest.php | 110 +++++ tests/Smartling/FTS/FtsServiceTest.php | 130 +++++ 16 files changed, 1910 insertions(+), 24 deletions(-) create mode 100644 inc/Smartling/FTS/FileTranslationsApiExtended.php create mode 100644 inc/Smartling/FTS/FtsApiWrapper.php create mode 100644 inc/Smartling/FTS/FtsService.php create mode 100644 inc/Smartling/WP/Controller/InstantTranslationController.php create mode 100644 js/instant-translation.js create mode 100644 tests/Smartling/FTS/FtsApiWrapperTest.php create mode 100644 tests/Smartling/FTS/FtsServiceTest.php diff --git a/composer.json b/composer.json index cba239b6..ae287b9a 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "smartling/wordpress-connector", "license": "GPL-2.0-or-later", - "version": "5.2.0", + "version": "5.3.0", "description": "", "type": "wordpress-plugin", "repositories": [ @@ -19,7 +19,7 @@ "symfony/expression-language": "~5.4", "symfony/config": "~5.4", "symfony/yaml": "~5.4", - "smartling/api-sdk-php": "3.9.2", + "smartling/api-sdk-php": "5.0.5", "ext-dom": "*", "ext-libxml": "*", "ext-json": "*" diff --git a/composer.lock b/composer.lock index 42a8b3e3..dd1e93a1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,85 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ee7a6a4da8518625450d130a38feb3d1", + "content-hash": "5504d9f0a6e6ec4fb046431bf5d8ed86", "packages": [ + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "galbar/jsonpath", "version": "2.1", @@ -734,26 +811,27 @@ }, { "name": "smartling/api-sdk-php", - "version": "3.9.2", + "version": "5.0.5", "source": { "type": "git", "url": "https://github.com/Smartling/api-sdk-php.git", - "reference": "0065a8b5b534b0b1578992a46d3d5f85bda86a3d" + "reference": "ebf56d4039c002d34fd7358c51ca40bc37e1d168" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Smartling/api-sdk-php/zipball/0065a8b5b534b0b1578992a46d3d5f85bda86a3d", - "reference": "0065a8b5b534b0b1578992a46d3d5f85bda86a3d", + "url": "https://api.github.com/repos/Smartling/api-sdk-php/zipball/ebf56d4039c002d34fd7358c51ca40bc37e1d168", + "reference": "ebf56d4039c002d34fd7358c51ca40bc37e1d168", "shasum": "" }, "require": { + "composer/semver": "^3.4.2", "ext-json": "*", - "guzzlehttp/guzzle": "~6", - "php": ">=5.5", - "psr/log": "~1.0" + "guzzlehttp/guzzle": "^6.0.0 || ^7.0.0", + "php": "^7.3 || ^8.0", + "psr/log": "^1.0.0 || ^3.0.0" }, "require-dev": { - "phpunit/phpunit": "~4" + "phpunit/phpunit": "~8" }, "type": "library", "autoload": { @@ -775,9 +853,9 @@ ], "support": { "issues": "https://github.com/Smartling/api-sdk-php/issues", - "source": "https://github.com/Smartling/api-sdk-php/tree/3.9.2" + "source": "https://github.com/Smartling/api-sdk-php/tree/5.0.5" }, - "time": "2022-01-20T14:42:36+00:00" + "time": "2026-02-03T16:00:08+00:00" }, { "name": "symfony/cache", diff --git a/inc/Smartling/FTS/FileTranslationsApiExtended.php b/inc/Smartling/FTS/FileTranslationsApiExtended.php new file mode 100644 index 00000000..c34cf77c --- /dev/null +++ b/inc/Smartling/FTS/FileTranslationsApiExtended.php @@ -0,0 +1,82 @@ +additionalHeaders[self::SERVICE_ORIGIN_HEADER] = self::SERVICE_ORIGIN_VALUE; + } + + /** + * Factory method to create FileTranslationsApiExtended instance. + * + * @param AuthApiInterface $authProvider + * Authentication provider + * @param string $accountUid + * Account UID in Smartling dashboard + * @param LoggerInterface|null $logger + * Logger instance + * + * @return FileTranslationsApiExtended + */ + public static function create($authProvider, $accountUid, $logger = null) + { + $client = self::initializeHttpClient(self::ENDPOINT_URL); + + $instance = new self($accountUid, $client, $logger, self::ENDPOINT_URL); + $instance->setAuth($authProvider); + + return $instance; + } + + /** + * {@inheritdoc} + * + * Overrides to inject additional headers into all requests + */ + protected function getDefaultRequestData($parametersType, $parameters, $auth = true, $httpErrors = false): array + { + $data = parent::getDefaultRequestData($parametersType, $parameters, $auth, $httpErrors); + + // Merge additional headers into request + if (!isset($data[RequestOptions::HEADERS])) { + $data[RequestOptions::HEADERS] = []; + } + + $data[RequestOptions::HEADERS] = array_merge( + $data[RequestOptions::HEADERS], + $this->additionalHeaders + ); + + return $data; + } +} diff --git a/inc/Smartling/FTS/FtsApiWrapper.php b/inc/Smartling/FTS/FtsApiWrapper.php new file mode 100644 index 00000000..b32917e8 --- /dev/null +++ b/inc/Smartling/FTS/FtsApiWrapper.php @@ -0,0 +1,239 @@ +settingsManager = $settingsManager; + $this->pluginName = $pluginName; + $this->pluginVersion = $pluginVersion; + } + + /** + * Get configuration profile for submission + * + * @throws SmartlingDbException + */ + private function getConfigurationProfile(SubmissionEntity $submission): ConfigurationProfileEntity + { + return $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); + } + + /** + * Get account UID for a project + * + * Uses ProjectApi to fetch account UID and caches it for subsequent calls. + * + * @throws SmartlingApiException + * @throws SmartlingDbException + */ + private function getAccountUid(ConfigurationProfileEntity $profile): string + { + $projectId = $profile->getProjectId(); + + // Return cached value if available + if (isset($this->accountUidCache[$projectId])) { + return $this->accountUidCache[$projectId]; + } + + AuthTokenProvider::setCurrentClientId($this->pluginName); + AuthTokenProvider::setCurrentClientVersion($this->pluginVersion); + + $authProvider = AuthTokenProvider::create( + $profile->getUserIdentifier(), + $profile->getSecretKey(), + $this->getLogger() + ); + + $projectApi = ProjectApi::create($authProvider, $projectId, $this->getLogger()); + $projectDetails = $projectApi->getProjectDetails(); + + $accountUid = $projectDetails['accountUid'] ?? null; + + if (empty($accountUid)) { + throw new \RuntimeException('Failed to get account UID from project details'); + } + + // Cache for future use + $this->accountUidCache[$projectId] = $accountUid; + + $this->getLogger()->info('Retrieved account UID', [ + 'projectId' => $projectId, + 'accountUid' => $accountUid, + ]); + + return $accountUid; + } + + /** + * Create File Translations API client with required headers + * + * @throws SmartlingApiException + * @throws SmartlingDbException + */ + private function getFileTranslationsApi(ConfigurationProfileEntity $profile): FileTranslationsApiExtended + { + AuthTokenProvider::setCurrentClientId($this->pluginName); + AuthTokenProvider::setCurrentClientVersion($this->pluginVersion); + + $authProvider = AuthTokenProvider::create( + $profile->getUserIdentifier(), + $profile->getSecretKey(), + $this->getLogger() + ); + + $accountUid = $this->getAccountUid($profile); + + return FileTranslationsApiExtended::create($authProvider, $accountUid, $this->getLogger()); + } + + /** + * Upload file for instant translation + * + * @param SubmissionEntity $submission + * @param string $filePath Path to temporary file containing XML content + * @param string $fileName Logical file name + * @param string $fileType File type (xml, json, etc.) + * @return array Response containing fileUid + * @throws SmartlingApiException + * @throws SmartlingDbException + */ + public function uploadFile( + SubmissionEntity $submission, + string $filePath, + string $fileName, + string $fileType = 'xml' + ): array { + $profile = $this->getConfigurationProfile($submission); + $api = $this->getFileTranslationsApi($profile); + + $this->getLogger()->info('Uploading file for instant translation', [ + 'submissionId' => $submission->getId(), + 'fileName' => $fileName, + 'fileType' => $fileType, + ]); + + return $api->uploadFile($filePath, $fileName, $fileType); + } + + /** + * Submit file for instant translation + * + * @param SubmissionEntity $submission + * @param string $fileUid File UID returned from uploadFile + * @param string $sourceLocaleId Source locale ID + * @param array $targetLocaleIds Array of target locale IDs + * @return array Response containing mtUid (machine translation UID) + * @throws SmartlingApiException + * @throws SmartlingDbException + */ + public function submitForInstantTranslation( + SubmissionEntity $submission, + string $fileUid, + string $sourceLocaleId, + array $targetLocaleIds + ): array { + $profile = $this->getConfigurationProfile($submission); + $api = $this->getFileTranslationsApi($profile); + + // Create translation parameters + $params = new TranslateFileParameters(); + $params->setSourceLocaleId($sourceLocaleId); + $params->setTargetLocaleIds($targetLocaleIds); + + $this->getLogger()->info('Submitting file for instant translation', [ + 'submissionId' => $submission->getId(), + 'fileUid' => $fileUid, + 'sourceLocale' => $sourceLocaleId, + 'targetLocales' => $targetLocaleIds, + ]); + + return $api->translateFile($fileUid, $params); + } + + /** + * Poll translation status + * + * @param SubmissionEntity $submission + * @param string $fileUid File UID + * @param string $mtUid Machine translation UID returned from submitForInstantTranslation + * @return array Response containing translation progress and state + * @throws SmartlingApiException + * @throws SmartlingDbException + */ + public function pollTranslationStatus( + SubmissionEntity $submission, + string $fileUid, + string $mtUid + ): array { + $profile = $this->getConfigurationProfile($submission); + $api = $this->getFileTranslationsApi($profile); + + return $api->getTranslationProgress($fileUid, $mtUid); + } + + /** + * Download translated file + * + * @param SubmissionEntity $submission + * @param string $fileUid File UID + * @param string $mtUid Machine translation UID + * @param string $localeId Target locale ID + * @return string Raw translated file content + * @throws SmartlingApiException + * @throws SmartlingDbException + */ + public function downloadTranslatedFile( + SubmissionEntity $submission, + string $fileUid, + string $mtUid, + string $localeId + ): string { + $profile = $this->getConfigurationProfile($submission); + $api = $this->getFileTranslationsApi($profile); + + $this->getLogger()->info('Downloading translated file', [ + 'submissionId' => $submission->getId(), + 'fileUid' => $fileUid, + 'mtUid' => $mtUid, + 'localeId' => $localeId, + ]); + + return $api->downloadTranslatedFile($fileUid, $mtUid, $localeId); + } +} diff --git a/inc/Smartling/FTS/FtsService.php b/inc/Smartling/FTS/FtsService.php new file mode 100644 index 00000000..50eb360a --- /dev/null +++ b/inc/Smartling/FTS/FtsService.php @@ -0,0 +1,446 @@ +ftsApiWrapper = $ftsApiWrapper; + $this->apiWrapper = $apiWrapper; + $this->submissionManager = $submissionManager; + $this->contentHelper = $contentHelper; + $this->core = $core; + $this->settingsManager = $settingsManager; + $this->xmlHelper = $xmlHelper; + $this->postContentHelper = $postContentHelper; + } + + /** + * Request instant translation for a submission + * + * This is the main entry point for instant translation. It: + * 1. Generates XML and uploads the file to Smartling FTS + * 2. Submits it for instant translation + * 3. Polls for completion with exponential backoff + * 4. Downloads and applies the translation + * + * @param SubmissionEntity $submission Submission to translate + * @return array Result with status and details + * @throws SmartlingApiException + * @throws SmartlingDbException + */ + public function requestInstantTranslation(SubmissionEntity $submission): array + { + $startTime = microtime(true); + + $this->getLogger()->info( + 'Starting instant translation request', + [ + 'submissionId' => $submission->getId(), + 'contentType' => $submission->getContentType(), + 'sourceBlogId' => $submission->getSourceBlogId(), + 'targetBlogId' => $submission->getTargetBlogId(), + ] + ); + + try { + // Step 1: Generate XML and upload file to FTS + $fileUid = $this->uploadFile($submission); + + // Step 2: Submit for instant translation + $mtUid = $this->submitFile($submission, $fileUid); + + // Step 3: Poll until complete or timeout + $pollResult = $this->pollUntilComplete($submission, $fileUid, $mtUid); + + if ($pollResult['status'] === 'completed') { + // Step 4: Download and apply translation + $this->downloadAndApply($submission, $fileUid, $mtUid); + + $elapsedTime = round((microtime(true) - $startTime) * 1000); + + $this->getLogger()->info( + 'Instant translation completed successfully', + [ + 'submissionId' => $submission->getId(), + 'fileUid' => $fileUid, + 'mtUid' => $mtUid, + 'elapsedTimeMs' => $elapsedTime, + ] + ); + + return [ + 'success' => true, + 'status' => 'completed', + 'fileUid' => $fileUid, + 'mtUid' => $mtUid, + 'elapsedTimeMs' => $elapsedTime, + ]; + } + + // Timeout or failure + return [ + 'success' => false, + 'status' => $pollResult['status'], + 'message' => $pollResult['message'], + 'fileUid' => $fileUid, + 'mtUid' => $mtUid, + ]; + + } catch (\Exception $e) { + $this->getLogger()->error( + 'Instant translation failed with exception', + [ + 'submissionId' => $submission->getId(), + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ] + ); + + return [ + 'success' => false, + 'status' => 'error', + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Generate XML and upload file to FTS + * + * @return string File UID from FTS + * @throws SmartlingApiException + * @throws SmartlingDbException + */ + private function uploadFile(SubmissionEntity $submission): string + { + $this->getLogger()->debug('Preparing file for instant translation', [ + 'submissionId' => $submission->getId(), + ]); + + // Prepare content for upload using SmartlingCore + $submission = $this->core->prepareUpload($submission); + + // Get XML content + $xml = $this->core->getXMLFiltered($submission); + + // Create temporary file for upload + $tempFile = tempnam(sys_get_temp_dir(), 'smartling_fts_'); + file_put_contents($tempFile, $xml); + + try { + // Generate logical file name + $fileName = sprintf( + 'instant-translation-%s-%d-%d.xml', + $submission->getContentType(), + $submission->getSourceId(), + time() + ); + + // Upload to FTS + $response = $this->ftsApiWrapper->uploadFile( + $submission, + $tempFile, + $fileName, + 'xml' + ); + + $fileUid = $response['fileUid'] ?? null; + + if (empty($fileUid)) { + throw new \RuntimeException('Failed to get file UID from upload response'); + } + + $this->getLogger()->info('File uploaded to FTS', [ + 'submissionId' => $submission->getId(), + 'fileUid' => $fileUid, + 'fileName' => $fileName, + ]); + + return $fileUid; + + } finally { + // Clean up temporary file + if (file_exists($tempFile)) { + unlink($tempFile); + } + } + } + + /** + * Submit file for instant translation + * + * @param string $fileUid File UID from upload + * @return string Machine translation UID (mtUid) + * @throws SmartlingApiException + * @throws SmartlingDbException + */ + private function submitFile(SubmissionEntity $submission, string $fileUid): string + { + $this->getLogger()->debug('Submitting file for instant translation', [ + 'submissionId' => $submission->getId(), + 'fileUid' => $fileUid, + ]); + + // Get source locale from WordPress for the source blog + $sourceBlogId = $submission->getSourceBlogId(); + + // Switch to source blog to get its locale + switch_to_blog($sourceBlogId); + $wpLocale = get_locale(); // e.g., "en_US" + restore_current_blog(); + + // Convert WordPress locale (en_US) to Smartling locale (en-US) + $sourceLocaleId = str_replace('_', '-', $wpLocale); + + // Get target locale using the profile + $profile = $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); + $targetLocale = $profile->getSmartlingLocale($submission->getTargetBlogId()); + + if (empty($targetLocale)) { + throw new \RuntimeException('Failed to determine target locale for submission'); + } + + $targetLocaleIds = [$targetLocale]; + + $response = $this->ftsApiWrapper->submitForInstantTranslation( + $submission, + $fileUid, + $sourceLocaleId, + $targetLocaleIds + ); + + $mtUid = $response['mtUid'] ?? null; + + if (empty($mtUid)) { + throw new \RuntimeException('Failed to get MT UID from response'); + } + + $this->getLogger()->info('Translation request created', [ + 'submissionId' => $submission->getId(), + 'fileUid' => $fileUid, + 'mtUid' => $mtUid, + 'sourceLocale' => $sourceLocaleId, + 'targetLocale' => $targetLocale, + ]); + + return $mtUid; + } + + /** + * Poll for translation completion with exponential backoff + * + * Polling intervals: 1s -> 2s -> 4s -> 8s -> 16s -> 30s -> 30s -> ... + * Timeout: 2 minutes + * + * @return array Status result + * @throws SmartlingApiException + * @throws SmartlingDbException + */ + private function pollUntilComplete(SubmissionEntity $submission, string $fileUid, string $mtUid): array + { + $startTime = microtime(true); + $intervalIndex = 0; + + $this->getLogger()->debug('Starting polling for instant translation', [ + 'submissionId' => $submission->getId(), + 'fileUid' => $fileUid, + 'mtUid' => $mtUid, + 'timeoutMs' => self::TIMEOUT_MS, + ]); + + while (true) { + $elapsedMs = (microtime(true) - $startTime) * 1000; + + // Check timeout + if ($elapsedMs >= self::TIMEOUT_MS) { + $this->getLogger()->warning('Instant translation polling timed out', [ + 'submissionId' => $submission->getId(), + 'fileUid' => $fileUid, + 'mtUid' => $mtUid, + 'elapsedMs' => round($elapsedMs), + ]); + + return [ + 'status' => 'timeout', + 'message' => 'Translation request timed out after 2 minutes', + ]; + } + + // Poll status + $response = $this->ftsApiWrapper->pollTranslationStatus($submission, $fileUid, $mtUid); + $state = $response['state'] ?? ''; + + $this->getLogger()->debug('Polled translation status', [ + 'submissionId' => $submission->getId(), + 'state' => $state, + 'elapsedMs' => round($elapsedMs), + ]); + + // Check if completed + if ($state === self::STATE_COMPLETED) { + return [ + 'status' => 'completed', + 'data' => $response, + ]; + } + + // Check if failed + if ($state === self::STATE_FAILED) { + $error = $data['error'] ?? 'Translation request failed'; + return [ + 'status' => 'failed', + 'message' => is_array($error) ? ($error['message'] ?? 'Unknown error') : $error, + 'data' => $response, + ]; + } + + // Check if cancelled + if ($state === self::STATE_CANCELLED) { + return [ + 'status' => 'cancelled', + 'message' => 'Translation request was cancelled', + 'data' => $response, + ]; + } + + // Calculate next wait interval with exponential backoff + $waitMs = $this->getNextPollInterval($intervalIndex); + $intervalIndex++; + + $this->getLogger()->debug('Waiting before next poll', [ + 'waitMs' => $waitMs, + 'nextIntervalIndex' => $intervalIndex, + ]); + + // Sleep for the calculated interval (convert to microseconds) + usleep($waitMs * 1000); + } + } + + /** + * Get next polling interval using exponential backoff + * + * @param int $intervalIndex Current interval index + * @return int Wait time in milliseconds + */ + private function getNextPollInterval(int $intervalIndex): int + { + // Use predefined intervals, or max interval if we've exceeded the array + if ($intervalIndex < count(self::POLL_INTERVALS)) { + return self::POLL_INTERVALS[$intervalIndex]; + } + + // All subsequent polls use 30s + return self::MAX_BACKOFF_INTERVAL_MS; + } + + /** + * Download and apply translated content + * + * @throws SmartlingApiException + * @throws SmartlingDbException + */ + private function downloadAndApply(SubmissionEntity $submission, string $fileUid, string $mtUid): void + { + $this->getLogger()->debug('Downloading and applying translation', [ + 'submissionId' => $submission->getId(), + 'fileUid' => $fileUid, + 'mtUid' => $mtUid, + ]); + + // Get target locale using the profile + $profile = $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); + $targetLocale = $profile->getSmartlingLocale($submission->getTargetBlogId()); + + if (empty($targetLocale)) { + throw new \RuntimeException('Failed to determine target locale for download'); + } + + // Download translated file + $translatedXml = $this->ftsApiWrapper->downloadTranslatedFile( + $submission, + $fileUid, + $mtUid, + $targetLocale + ); + + // Apply translated content using SmartlingCoreUploadTrait + $this->core->applyXML($submission, $translatedXml, $this->xmlHelper, $this->postContentHelper); + + // Update submission status + $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_COMPLETED); + $submission->setCompletedStringCount($submission->getWordCount()); + $submission->setAppliedDate(date('c')); + $this->submissionManager->storeEntity($submission); + + $this->getLogger()->info('Translation applied successfully', [ + 'submissionId' => $submission->getId(), + 'fileUid' => $fileUid, + 'mtUid' => $mtUid, + ]); + } +} diff --git a/inc/Smartling/WP/Controller/ContentEditJobController.php b/inc/Smartling/WP/Controller/ContentEditJobController.php index c97b7381..9b40e2c8 100644 --- a/inc/Smartling/WP/Controller/ContentEditJobController.php +++ b/inc/Smartling/WP/Controller/ContentEditJobController.php @@ -209,6 +209,7 @@ public function wp_enqueue() 'smartling.dtpicker.conflict.resolver.js', 'moment.js', 'moment-timezone-with-data.js', + 'instant-translation.js', ]; foreach ($jsFiles as $jFile) { $jFile = $jsPath . $jFile; diff --git a/inc/Smartling/WP/Controller/InstantTranslationController.php b/inc/Smartling/WP/Controller/InstantTranslationController.php new file mode 100644 index 00000000..015f5d9e --- /dev/null +++ b/inc/Smartling/WP/Controller/InstantTranslationController.php @@ -0,0 +1,200 @@ +ftsService = $ftsService; + $this->submissionManager = $submissionManager; + } + + public function register(): void + { + // Register AJAX handlers for both logged-in users + add_action('wp_ajax_' . self::ACTION_REQUEST_TRANSLATION, [$this, 'handleRequestTranslation']); + add_action('wp_ajax_' . self::ACTION_POLL_STATUS, [$this, 'handlePollStatus']); + } + + /** + * Handle instant translation request + */ + public function handleRequestTranslation(): void + { + try { + // Verify nonce if needed + // check_ajax_referer('smartling_instant_translation_nonce'); + + // Get parameters + $contentType = sanitize_text_field($_POST['contentType'] ?? ''); + $contentId = intval($_POST['contentId'] ?? 0); + $targetBlogId = intval($_POST['targetBlogId'] ?? 0); + + if (empty($contentType) || empty($contentId) || empty($targetBlogId)) { + wp_send_json_error([ + 'message' => 'Missing required parameters: contentType, contentId, or targetBlogId' + ], 400); + return; + } + + $this->getLogger()->info('Instant translation requested', [ + 'contentType' => $contentType, + 'contentId' => $contentId, + 'targetBlogId' => $targetBlogId, + ]); + + // Find or create submission + $sourceBlogId = get_current_blog_id(); + $submissions = $this->submissionManager->find([ + SubmissionEntity::FIELD_SOURCE_BLOG_ID => $sourceBlogId, + SubmissionEntity::FIELD_SOURCE_ID => $contentId, + SubmissionEntity::FIELD_CONTENT_TYPE => $contentType, + SubmissionEntity::FIELD_TARGET_BLOG_ID => $targetBlogId, + ]); + + if (empty($submissions)) { + // Create new submission + $submission = $this->submissionManager->getSubmissionEntity( + $contentType, + $sourceBlogId, + $contentId, + $targetBlogId + ); + $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_NEW); + $submission = $this->submissionManager->storeEntity($submission); + } else { + $submission = reset($submissions); + } + + // Validate submission + if (!$submission || !$submission->getId()) { + wp_send_json_error([ + 'message' => 'Failed to create or retrieve submission' + ], 500); + return; + } + + // Set status to in progress + $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_IN_PROGRESS); + $this->submissionManager->storeEntity($submission); + + // Start instant translation asynchronously + // For now, we'll return success and let polling handle the progress + // In a production environment, you might want to queue this operation + + wp_send_json_success([ + 'submissionId' => $submission->getId(), + 'message' => 'Instant translation started' + ]); + + } catch (\Exception $e) { + $this->getLogger()->error('Instant translation request failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + wp_send_json_error([ + 'message' => 'Failed to start instant translation: ' . $e->getMessage() + ], 500); + } + } + + /** + * Handle status polling request + */ + public function handlePollStatus(): void + { + try { + // Get submission ID + $submissionId = intval($_POST['submissionId'] ?? 0); + + if (empty($submissionId)) { + wp_send_json_error([ + 'message' => 'Missing required parameter: submissionId' + ], 400); + return; + } + + // Get submission + $submission = $this->submissionManager->getEntityById($submissionId); + + if (!$submission) { + wp_send_json_error([ + 'message' => 'Submission not found' + ], 404); + return; + } + + // Check if we need to actually start the translation + // (This happens on first poll after request) + if ($submission->getStatus() === SubmissionEntity::SUBMISSION_STATUS_IN_PROGRESS && + empty($submission->getFileUri())) { + // Translation hasn't started yet, start it now + $result = $this->ftsService->requestInstantTranslation($submission); + + if ($result['success']) { + // Refresh submission to get updated status + $submission = $this->submissionManager->getEntityById($submissionId); + } + } + + // Return current status + wp_send_json_success([ + 'status' => $this->mapSubmissionStatus($submission->getStatus()), + 'progress' => $submission->getCompletionPercentage(), + 'message' => $submission->getLastError() ?: '' + ]); + + } catch (\Exception $e) { + $this->getLogger()->error('Status poll failed', [ + 'error' => $e->getMessage(), + 'submissionId' => $submissionId ?? 'unknown', + ]); + + wp_send_json_error([ + 'message' => 'Failed to get translation status: ' . $e->getMessage() + ], 500); + } + } + + /** + * Map submission status to frontend status + */ + private function mapSubmissionStatus(string $submissionStatus): string + { + switch ($submissionStatus) { + case SubmissionEntity::SUBMISSION_STATUS_COMPLETED: + return 'completed'; + case SubmissionEntity::SUBMISSION_STATUS_FAILED: + case SubmissionEntity::SUBMISSION_STATUS_CANCELLED: + return 'failed'; + case SubmissionEntity::SUBMISSION_STATUS_IN_PROGRESS: + return 'in_progress'; + case SubmissionEntity::SUBMISSION_STATUS_NEW: + default: + return 'pending'; + } + } +} diff --git a/inc/Smartling/WP/View/ContentEditJob.php b/inc/Smartling/WP/View/ContentEditJob.php index 3528e597..5f422515 100644 --- a/inc/Smartling/WP/View/ContentEditJob.php +++ b/inc/Smartling/WP/View/ContentEditJob.php @@ -102,10 +102,11 @@
New Job Existing Job + Instant Translation Clone'?>
- + @@ -113,24 +114,28 @@ - + - + - + - + + + + + @@ -408,22 +422,38 @@ function (i, e) { $(this).addClass("active"); const hideWhenCloning = $('.hideWhenCloning'); const cloneButton = $('#cloneButton'); + const instantButton = $('#instantTranslateButton'); + const hideWhenInstant = $('.hideWhenInstant'); switch ($(this).attr("data-action")) { case "new": Helper.ui.createJobForm.show(); hideWhenCloning.show(); + hideWhenInstant.show(); cloneButton.addClass('hidden'); + instantButton.addClass('hidden'); break; case "clone": Helper.ui.createJobForm.hide(); hideWhenCloning.hide(); + hideWhenInstant.show(); $('#addToJob').addClass('hidden'); cloneButton.removeClass('hidden'); + instantButton.addClass('hidden'); + break; + case "instant": + Helper.ui.createJobForm.hide(); + hideWhenCloning.hide(); + hideWhenInstant.hide(); + $('#addToJob').addClass('hidden'); + cloneButton.addClass('hidden'); + instantButton.removeClass('hidden'); break; case "existing": Helper.ui.createJobForm.hide(); hideWhenCloning.show(); + hideWhenInstant.show(); cloneButton.addClass('hidden'); + instantButton.addClass('hidden'); break; default: } diff --git a/inc/config/register-on-startup.yml b/inc/config/register-on-startup.yml index f13bdd1a..8fe42c5a 100644 --- a/inc/config/register-on-startup.yml +++ b/inc/config/register-on-startup.yml @@ -15,6 +15,7 @@ services: - [ "addService", [ "@wp.taxonomy.linker" ]] - [ "addService", [ "@wp.test.run" ]] - [ "addService", [ "@wp.bulkSubmit" ]] + - [ "addService", [ "@wp.instant.translation" ]] - [ "addService", [ "@wp.settings" ]] - [ "addService", [ "@wp.settings.edit" ]] - [ "addService", [ "@smartling.helper.relative-image-path-support" ]] diff --git a/inc/config/services.yml b/inc/config/services.yml index b6509303..f27c9107 100644 --- a/inc/config/services.yml +++ b/inc/config/services.yml @@ -88,6 +88,25 @@ services: arguments: - "@wrapper.sdk.api.smartling" + fts.api.wrapper: + class: Smartling\FTS\FtsApiWrapper + arguments: + - "@manager.settings" + - "%plugin.name%" + - "%plugin.version%" + + fts.service: + class: Smartling\FTS\FtsService + arguments: + - "@fts.api.wrapper" + - "@api.wrapper.with.retries" + - "@manager.submission" + - "@content.helper" + - "@entrypoint" + - "@manager.settings" + - "@helper.xml" + - "@helper.post.content" + queue.db: class: Smartling\Queue\Queue arguments: @@ -433,6 +452,12 @@ services: - "@manager.upload.queue" - "@site.cache" + wp.instant.translation: + class: Smartling\WP\Controller\InstantTranslationController + arguments: + - "@fts.service" + - "@manager.submission" + helper.gutenberg: class: Smartling\Helpers\GutenbergBlockHelper arguments: diff --git a/js/app.js b/js/app.js index 227ea779..1c1d8e44 100644 --- a/js/app.js +++ b/js/app.js @@ -22,6 +22,13 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, const [l1Relations, setL1Relations] = useState([]); const [l2Relations, setL2Relations] = useState([]); + // Instant translation state + const [instantPolling, setInstantPolling] = useState(false); + const [instantProgress, setInstantProgress] = useState(0); + const [instantStatus, setInstantStatus] = useState(''); + const [instantSubmissionIds, setInstantSubmissionIds] = useState([]); + const [instantCompletedCount, setInstantCompletedCount] = useState(0); + const loadJobs = useCallback(async () => { try { const response = await jQuery.post(adminUrl, { @@ -92,6 +99,163 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, setRelations(unique); }, [depth, l1Relations, l2Relations]); + // Instant translation handler + const handleInstantTranslation = async () => { + setSubmitting(true); + setError(''); + setSuccess(''); + setInstantStatus(''); + setInstantCompletedCount(0); + + if (selectedLocales.length === 0) { + setError('Please select at least one target locale.'); + setSubmitting(false); + return; + } + + if (selectedLocales.length > 1) { + setError('Instant translation supports one target locale at a time. Please select only one locale.'); + setSubmitting(false); + return; + } + + const targetBlogId = selectedLocales[0]; + + // Collect all items to translate (main content + related content) + const itemsToTranslate = [{ contentType, contentId }]; + + // Add selected related content + relations.forEach(rel => { + if (selectedRelations[`${rel.contentType}-${rel.id}`]) { + itemsToTranslate.push({ contentType: rel.contentType, contentId: rel.id }); + } + }); + + const submissionIds = []; + + try { + // Create instant translation requests for all items + for (const item of itemsToTranslate) { + const response = await jQuery.post(ajaxUrl, { + action: 'smartling_instant_translation', + contentType: item.contentType, + contentId: item.contentId, + targetBlogId: targetBlogId + }); + + if (response.success && response.data?.submissionId) { + submissionIds.push(response.data.submissionId); + } else { + throw new Error(response.data?.message || 'Failed to start instant translation.'); + } + } + + if (submissionIds.length > 0) { + setInstantSubmissionIds(submissionIds); + setInstantPolling(true); + setInstantProgress(5); + setInstantStatus(`Translating ${submissionIds.length} item${submissionIds.length > 1 ? 's' : ''}... This will take approximately 2 minutes.`); + startInstantPolling(submissionIds, Date.now()); + } else { + setError('No items to translate.'); + setSubmitting(false); + } + } catch (e) { + setError(e.message || 'Failed to start instant translation.'); + setSubmitting(false); + } + }; + + const startInstantPolling = async (submissionIds, startTime, intervalIndex = 0) => { + const POLL_INTERVALS = [1000, 2000, 4000, 8000, 16000]; + const MAX_BACKOFF = 30000; + const TIMEOUT = 120000; + + const elapsed = Date.now() - startTime; + + if (elapsed >= TIMEOUT) { + setInstantPolling(false); + setError('Translation request timed out after 2 minutes. Please check the submission status manually.'); + setSubmitting(false); + return; + } + + const progressPercent = Math.min(90, (elapsed / TIMEOUT) * 100); + setInstantProgress(progressPercent); + + let completedCount = 0; + let failedCount = 0; + let hasError = false; + let errorMessage = ''; + + try { + // Poll all submissions + const statusPromises = submissionIds.map(submissionId => + jQuery.post(ajaxUrl, { + action: 'smartling_instant_translation_status', + submissionId: submissionId + }) + ); + + const responses = await Promise.all(statusPromises); + + responses.forEach((response, index) => { + if (response.success && response.data) { + const status = response.data.status; + + switch (status) { + case 'completed': + completedCount++; + break; + + case 'failed': + failedCount++; + hasError = true; + errorMessage = response.data.message || 'Translation failed.'; + break; + + case 'in_progress': + case 'pending': + // Still in progress + break; + } + } + }); + + // Update completed count + setInstantCompletedCount(completedCount); + + // Update status message + if (submissionIds.length > 1) { + setInstantStatus(`Translating ${submissionIds.length} items... ${completedCount} completed.`); + } + + // Check if all completed + if (completedCount === submissionIds.length) { + setInstantPolling(false); + setInstantProgress(100); + setSuccess(`All ${submissionIds.length} item${submissionIds.length > 1 ? 's' : ''} translated successfully!`); + setSubmitting(false); + setTimeout(() => window.location.reload(), 2000); + return; + } + + // Check if any failed + if (failedCount > 0 && (completedCount + failedCount) === submissionIds.length) { + setInstantPolling(false); + setError(`${failedCount} translation(s) failed. ${completedCount} completed successfully. ${errorMessage}`); + setSubmitting(false); + return; + } + + } catch (e) { + // Continue polling on error + } + + const interval = intervalIndex < POLL_INTERVALS.length ? POLL_INTERVALS[intervalIndex] : MAX_BACKOFF; + setTimeout(() => startInstantPolling(submissionIds, startTime, intervalIndex + 1), interval); + }; + const handleSubmit = async () => { setSubmitting(true); setError(''); @@ -177,10 +341,16 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, el(CardBody, {}, el(TabPanel, { activeClass: 'is-active', - onSelect: setActiveTab, + onSelect: (tabName) => { + setActiveTab(tabName); + setError(''); + setSuccess(''); + setInstantStatus(''); + }, tabs: [ { name: 'new', title: 'New Job' }, { name: 'existing', title: 'Existing Job' }, + { name: 'instant', title: 'Instant Translation' }, ] }, (tab) => el(VStack || 'div', { spacing: 4, style: { marginTop: '16px' } }, error && el(Notice, { status: 'error', isDismissible: true, onRemove: () => setError('') }, error), @@ -203,7 +373,21 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, } }), - tab.name !== 'clone' && el('div', {}, + tab.name === 'instant' && el('div', { style: { padding: '12px', background: '#f0f0f1', borderRadius: '4px', marginBottom: '16px' } }, + el('p', { style: { margin: 0, fontSize: '14px' } }, + el('strong', {}, 'Instant Translation'), + ' provides immediate translation without creating a job. Translation will complete in approximately 2 minutes.' + ) + ), + + tab.name === 'instant' && instantPolling && el('div', { style: { padding: '16px', background: '#f0f0f1', borderRadius: '4px', marginBottom: '16px' } }, + el('div', { style: { marginBottom: '12px', fontSize: '14px', fontWeight: 500 } }, instantStatus), + el('div', { style: { background: '#fff', borderRadius: '4px', overflow: 'hidden', height: '20px' } }, + el('div', { style: { background: '#2271b1', height: '100%', width: `${instantProgress}%`, transition: 'width 0.3s' } }) + ) + ), + + tab.name !== 'clone' && tab.name !== 'instant' && el('div', {}, tab.name === 'new' && el(TextControl, { label: 'Name', value: jobName, onChange: setJobName }), el(TextareaControl, { label: 'Description', value: description, onChange: setDescription, rows: 3 }), el(TextControl, { @@ -213,8 +397,10 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, onChange: setDueDate, placeholder: '2025-12-31T23:59' }), - el(CheckboxControl, { label: 'Authorize Job', checked: authorize, onChange: setAuthorize }), + el(CheckboxControl, { label: 'Authorize Job', checked: authorize, onChange: setAuthorize }) + ), + (tab.name === 'instant' || tab.name !== 'clone') && el('div', {}, el('fieldset', { style: { marginTop: '16px', border: '1px solid #ddd', padding: '12px', borderRadius: '4px' } }, el('legend', { style: { fontWeight: 600, padding: '0 8px' } }, 'Target Locales'), el('div', { style: { display: 'flex', gap: '8px', marginBottom: '8px' } }, @@ -307,8 +493,8 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, variant: 'primary', isBusy: submitting, disabled: submitting || pendingRequests > 0 || selectedLocales.length === 0, - onClick: handleSubmit - }, tab.name === 'new' ? 'Create Job' : tab.name === 'clone' ? 'Clone' : 'Add to selected Job') + onClick: tab.name === 'instant' ? handleInstantTranslation : handleSubmit + }, tab.name === 'instant' ? 'Request Instant Translation' : tab.name === 'new' ? 'Create Job' : tab.name === 'clone' ? 'Clone' : 'Add to selected Job') ) )) ) diff --git a/js/instant-translation.js b/js/instant-translation.js new file mode 100644 index 00000000..949003c3 --- /dev/null +++ b/js/instant-translation.js @@ -0,0 +1,355 @@ +/** + * Instant Translation Module + * + * Handles instant translation requests with exponential backoff polling. + * Polling intervals: 1s -> 2s -> 4s -> 8s -> 16s -> 30s (subsequent) + * Timeout: 2 minutes + */ +(function ($) { + 'use strict'; + + const InstantTranslation = { + // Polling configuration + POLL_INTERVALS: [1000, 2000, 4000, 8000, 16000], // ms + MAX_BACKOFF_INTERVAL: 30000, // 30 seconds + TIMEOUT: 120000, // 2 minutes + + // State + isPolling: false, + pollIntervalIndex: 0, + startTime: null, + pollTimeoutId: null, + submissionIds: [], + completedCount: 0, + + /** + * Initialize instant translation functionality + */ + init: function () { + this.attachEvents(); + }, + + /** + * Attach event handlers + */ + attachEvents: function () { + const self = this; + $('#instantTranslateButton').on('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + self.handleInstantTranslate(); + }); + }, + + /** + * Handle instant translation button click + */ + handleInstantTranslate: function () { + if (this.isPolling) { + this.showError('Translation already in progress. Please wait...'); + return; + } + + // Validate selections + const targetBlogIds = this.getSelectedTargetLocales(); + if (targetBlogIds.length === 0) { + this.showError('Please select at least one target locale.'); + return; + } + + if (targetBlogIds.length > 1) { + this.showError('Instant translation supports one target locale at a time. Please select only one locale.'); + return; + } + + // Get content info + const contentType = window.currentContent?.contentType || ''; + const contentId = window.currentContent?.id?.[0] || 0; + + if (!contentType || !contentId) { + this.showError('Unable to determine content type or ID.'); + return; + } + + // Disable button + this.setButtonState(true); + this.hideError(); + this.showStatus('Preparing instant translation...'); + + // Send request + this.requestInstantTranslation(contentType, contentId, targetBlogIds[0]); + }, + + /** + * Get selected target locales + */ + getSelectedTargetLocales: function () { + const blogIds = []; + $('.job-wizard .mcheck:checkbox:checked').each(function () { + blogIds.push($(this).attr('data-blog-id')); + }); + return blogIds; + }, + + /** + * Request instant translation + */ + requestInstantTranslation: async function (contentType, contentId, targetBlogId) { + const self = this; + const url = ajaxurl + '?action=smartling_instant_translation'; + + // Collect all items to translate (main content + related content) + const itemsToTranslate = [{ contentType, contentId }]; + + // Add selected related content + $('.relation-checkbox:checked').each(function() { + itemsToTranslate.push({ + contentType: $(this).attr('data-content-type'), + contentId: parseInt($(this).attr('data-id')) + }); + }); + + const submissionIds = []; + + try { + // Create instant translation requests for all items + for (const item of itemsToTranslate) { + const response = await $.post(url, { + contentType: item.contentType, + contentId: item.contentId, + targetBlogId: targetBlogId + }); + + if (response.success && response.data && response.data.submissionId) { + submissionIds.push(response.data.submissionId); + } else { + throw new Error(response.data?.message || 'Failed to start instant translation.'); + } + } + + if (submissionIds.length > 0) { + self.submissionIds = submissionIds; + self.completedCount = 0; + self.startPolling(); + } else { + self.showError('No items to translate.'); + self.setButtonState(false); + } + } catch (e) { + self.showError(e.message || 'Failed to start instant translation.'); + self.setButtonState(false); + } + }, + + /** + * Start polling for translation status + */ + startPolling: function () { + this.isPolling = true; + this.pollIntervalIndex = 0; + this.startTime = Date.now(); + + const count = this.submissionIds.length; + this.showStatus(`Translating ${count} item${count > 1 ? 's' : ''}... This will take approximately 2 minutes.`); + this.updateProgress(5); // Initial progress + + this.poll(); + }, + + /** + * Poll translation status + */ + poll: async function () { + const self = this; + + // Check timeout + const elapsed = Date.now() - this.startTime; + if (elapsed >= this.TIMEOUT) { + this.stopPolling(); + this.showError('Translation request timed out after 2 minutes. Please check the submission status manually.'); + this.setButtonState(false); + return; + } + + // Update progress based on time elapsed + const progressPercent = Math.min(90, (elapsed / this.TIMEOUT) * 100); + this.updateProgress(progressPercent); + + const url = ajaxurl + '?action=smartling_instant_translation_status'; + let completedCount = 0; + let failedCount = 0; + let errorMessage = ''; + + try { + // Poll all submissions + const promises = this.submissionIds.map(submissionId => + $.post(url, { submissionId: submissionId }) + ); + + const responses = await Promise.all(promises); + + responses.forEach(function(response) { + if (response.success && response.data) { + const status = response.data.status; + + switch (status) { + case 'completed': + completedCount++; + break; + + case 'failed': + failedCount++; + errorMessage = response.data.message || 'Translation failed.'; + break; + + case 'in_progress': + case 'pending': + // Still in progress + break; + } + } + }); + + // Update completed count + self.completedCount = completedCount; + + // Update status message + if (self.submissionIds.length > 1) { + self.showStatus(`Translating ${self.submissionIds.length} items... ${completedCount} completed.`); + } + + // Check if all completed + if (completedCount === self.submissionIds.length) { + self.stopPolling(); + self.updateProgress(100); + self.showSuccess(`All ${self.submissionIds.length} item${self.submissionIds.length > 1 ? 's' : ''} translated successfully!`); + self.setButtonState(false); + + setTimeout(function () { + window.location.reload(); + }, 2000); + return; + } + + // Check if any failed + if (failedCount > 0 && (completedCount + failedCount) === self.submissionIds.length) { + self.stopPolling(); + self.showError(`${failedCount} translation(s) failed. ${completedCount} completed successfully. ${errorMessage}`); + self.setButtonState(false); + return; + } + + } catch (e) { + // Continue polling on error + } + + // Schedule next poll + self.scheduleNextPoll(); + }, + + /** + * Schedule next poll with exponential backoff + */ + scheduleNextPoll: function () { + const interval = this.getNextPollInterval(); + const self = this; + + this.pollTimeoutId = setTimeout(function () { + self.poll(); + }, interval); + + this.pollIntervalIndex++; + }, + + /** + * Get next poll interval with exponential backoff + */ + getNextPollInterval: function () { + if (this.pollIntervalIndex < this.POLL_INTERVALS.length) { + return this.POLL_INTERVALS[this.pollIntervalIndex]; + } + // All subsequent polls use max backoff interval + return this.MAX_BACKOFF_INTERVAL; + }, + + /** + * Stop polling + */ + stopPolling: function () { + this.isPolling = false; + if (this.pollTimeoutId) { + clearTimeout(this.pollTimeoutId); + this.pollTimeoutId = null; + } + }, + + /** + * Set button state + */ + setButtonState: function (disabled) { + const $button = $('#instantTranslateButton'); + if (disabled) { + $button.prop('disabled', true).addClass('is-busy').text('Translating...'); + } else { + $button.prop('disabled', false).removeClass('is-busy').text('Request Instant Translation'); + } + }, + + /** + * Show status message + */ + showStatus: function (message) { + const $status = $('#instant-status'); + const $statusText = $('#instant-status-text'); + + $statusText.html('' + message + ''); + $status.removeClass('hidden'); + $('#error-messages').html('').hide(); + }, + + /** + * Hide status + */ + hideStatus: function () { + $('#instant-status').addClass('hidden'); + }, + + /** + * Update progress bar + */ + updateProgress: function (percent) { + $('#instant-progress-bar').css('width', percent + '%'); + }, + + /** + * Show error message + */ + showError: function (message) { + const $errorMessages = $('#error-messages'); + $errorMessages.html('' + message + '').show(); + this.hideStatus(); + }, + + /** + * Hide error message + */ + hideError: function () { + $('#error-messages').html('').hide(); + }, + + /** + * Show success message + */ + showSuccess: function (message) { + const $statusText = $('#instant-status-text'); + $statusText.html('' + message + ''); + this.updateProgress(100); + } + }; + + // Initialize when document is ready + $(document).ready(function () { + InstantTranslation.init(); + }); + +})(jQuery); diff --git a/readme.txt b/readme.txt index 34274471..cbf157bf 100755 --- a/readme.txt +++ b/readme.txt @@ -62,6 +62,9 @@ Additional information on the Smartling Connector for WordPress can be found [he 3. Track translation status within WordPress from the Submissions Board. View overall progress of submitted translation requests as well as resend updated content. == Changelog == += 5.3.0 = +* Added support for instant machine translation + = 5.2.0 = * Added support for Synced Patterns (Reusable blocks) diff --git a/smartling-connector.php b/smartling-connector.php index d245d198..2249b1e8 100755 --- a/smartling-connector.php +++ b/smartling-connector.php @@ -11,7 +11,7 @@ * Plugin Name: Smartling Connector * Plugin URI: https://www.smartling.com/products/automate/integrations/wordpress/ * Description: Integrate your WordPress site with Smartling to upload your content and download translations. - * Version: 5.2.0 + * Version: 5.3.0 * Author: Smartling * Author URI: https://www.smartling.com * License: GPL-2.0+ diff --git a/tests/Smartling/FTS/FtsApiWrapperTest.php b/tests/Smartling/FTS/FtsApiWrapperTest.php new file mode 100644 index 00000000..615f875d --- /dev/null +++ b/tests/Smartling/FTS/FtsApiWrapperTest.php @@ -0,0 +1,110 @@ +settingsManager = $this->createMock(SettingsManager::class); + + $this->ftsApiWrapper = new FtsApiWrapper( + $this->settingsManager, + 'test-plugin', + '1.0.0' + ); + } + + public function testConstructor(): void + { + $this->assertInstanceOf(FtsApiWrapper::class, $this->ftsApiWrapper); + } + + public function testUploadFileRequiresConfiguration(): void + { + $this->expectException(\Smartling\Exception\SmartlingDbException::class); + + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getSourceBlogId')->willReturn(1); + + $this->settingsManager + ->method('getSingleSettingsProfile') + ->willThrowException(new \Smartling\Exception\SmartlingDbException('No profile found')); + + $this->ftsApiWrapper->uploadFile( + $submission, + '/tmp/test.xml', + 'test.xml', + 'xml' + ); + } + + public function testSubmitForInstantTranslationRequiresConfiguration(): void + { + $this->expectException(\Smartling\Exception\SmartlingDbException::class); + + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getSourceBlogId')->willReturn(1); + + $this->settingsManager + ->method('getSingleSettingsProfile') + ->willThrowException(new \Smartling\Exception\SmartlingDbException('No profile found')); + + $this->ftsApiWrapper->submitForInstantTranslation( + $submission, + 'file-uid-123', + 'en', + ['es-ES'] + ); + } + + public function testPollTranslationStatusRequiresConfiguration(): void + { + $this->expectException(\Smartling\Exception\SmartlingDbException::class); + + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getSourceBlogId')->willReturn(1); + + $this->settingsManager + ->method('getSingleSettingsProfile') + ->willThrowException(new \Smartling\Exception\SmartlingDbException('No profile found')); + + $this->ftsApiWrapper->pollTranslationStatus( + $submission, + 'file-uid-123', + 'mt-uid-456' + ); + } + + public function testDownloadTranslatedFileRequiresConfiguration(): void + { + $this->expectException(\Smartling\Exception\SmartlingDbException::class); + + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getSourceBlogId')->willReturn(1); + + $this->settingsManager + ->method('getSingleSettingsProfile') + ->willThrowException(new \Smartling\Exception\SmartlingDbException('No profile found')); + + $this->ftsApiWrapper->downloadTranslatedFile( + $submission, + 'file-uid-123', + 'mt-uid-456', + 'es-ES' + ); + } +} diff --git a/tests/Smartling/FTS/FtsServiceTest.php b/tests/Smartling/FTS/FtsServiceTest.php new file mode 100644 index 00000000..b2dfc4c9 --- /dev/null +++ b/tests/Smartling/FTS/FtsServiceTest.php @@ -0,0 +1,130 @@ +ftsApiWrapper = $this->createMock(FtsApiWrapper::class); + $this->apiWrapper = $this->createMock(ApiWrapperInterface::class); + $this->submissionManager = $this->createMock(SubmissionManager::class); + $this->contentHelper = $this->createMock(ContentHelper::class); + $this->core = $this->createMock(SmartlingCore::class); + $this->settingsManager = $this->createMock(SettingsManager::class); + $this->xmlHelper = $this->createMock(XmlHelper::class); + $this->postContentHelper = $this->createMock(PostContentHelper::class); + + $this->ftsService = new FtsService( + $this->ftsApiWrapper, + $this->apiWrapper, + $this->submissionManager, + $this->contentHelper, + $this->core, + $this->settingsManager, + $this->xmlHelper, + $this->postContentHelper + ); + } + + public function testConstructor(): void + { + $this->assertInstanceOf(FtsService::class, $this->ftsService); + } + + public function testGetNextPollInterval(): void + { + // Use reflection to test private method + $reflection = new \ReflectionClass($this->ftsService); + $method = $reflection->getMethod('getNextPollInterval'); + $method->setAccessible(true); + + // Test exponential backoff intervals + $this->assertEquals(1000, $method->invoke($this->ftsService, 0)); + $this->assertEquals(2000, $method->invoke($this->ftsService, 1)); + $this->assertEquals(4000, $method->invoke($this->ftsService, 2)); + $this->assertEquals(8000, $method->invoke($this->ftsService, 3)); + $this->assertEquals(16000, $method->invoke($this->ftsService, 4)); + + // Test max backoff interval (30s) for subsequent polls + $this->assertEquals(30000, $method->invoke($this->ftsService, 5)); + $this->assertEquals(30000, $method->invoke($this->ftsService, 6)); + $this->assertEquals(30000, $method->invoke($this->ftsService, 10)); + } + + public function testPollIntervalsConstants(): void + { + // Verify the polling configuration constants + $reflection = new \ReflectionClass($this->ftsService); + + $pollIntervals = $reflection->getConstant('POLL_INTERVALS'); + $this->assertIsArray($pollIntervals); + $this->assertEquals([1000, 2000, 4000, 8000, 16000], $pollIntervals); + + $maxBackoff = $reflection->getConstant('MAX_BACKOFF_INTERVAL_MS'); + $this->assertEquals(30000, $maxBackoff); + + $timeout = $reflection->getConstant('TIMEOUT_MS'); + $this->assertEquals(120000, $timeout); + } + + public function testFtsStatusConstants(): void + { + // Verify FTS status state constants exist + $reflection = new \ReflectionClass($this->ftsService); + + $this->assertEquals('QUEUED', $reflection->getConstant('STATE_QUEUED')); + $this->assertEquals('PROCESSING', $reflection->getConstant('STATE_PROCESSING')); + $this->assertEquals('COMPLETED', $reflection->getConstant('STATE_COMPLETED')); + $this->assertEquals('FAILED', $reflection->getConstant('STATE_FAILED')); + $this->assertEquals('CANCELLED', $reflection->getConstant('STATE_CANCELLED')); + } + + public function testRequestInstantTranslationHandlesException(): void + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn(123); + $submission->method('getContentType')->willReturn('post'); + $submission->method('getSourceBlogId')->willReturn(1); + $submission->method('getTargetBlogId')->willReturn(2); + + // Mock SmartlingCore to throw exception during prepareUpload + $this->core + ->method('prepareUpload') + ->willThrowException(new \Exception('Test exception')); + + $result = $this->ftsService->requestInstantTranslation($submission); + + $this->assertIsArray($result); + $this->assertFalse($result['success']); + $this->assertEquals('error', $result['status']); + $this->assertStringContainsString('Test exception', $result['message']); + } +} From 426f3bfee6e8e14ecac0d3183451eab938fc21e9 Mon Sep 17 00:00:00 2001 From: vsolovei-smartling Date: Fri, 13 Feb 2026 14:51:48 +0100 Subject: [PATCH 2/6] allow sending multiple locales and related (WP-985) --- inc/Smartling/ApiWrapper.php | 16 + inc/Smartling/ApiWrapperInterface.php | 2 + inc/Smartling/ApiWrapperWithRetries.php | 7 + .../FTS/FileTranslationsApiExtended.php | 38 +- inc/Smartling/FTS/FtsApiWrapper.php | 183 ++------ inc/Smartling/FTS/FtsService.php | 444 +++++++----------- .../Controller/ContentEditJobController.php | 1 - .../InstantTranslationController.php | 330 ++++++++----- inc/Smartling/WP/View/ContentEditJob.php | 40 +- inc/config/services.yml | 11 +- js/app.js | 77 ++- js/instant-translation.js | 355 -------------- tests/Smartling/FTS/FtsApiWrapperTest.php | 31 +- tests/Smartling/FTS/FtsServiceTest.php | 141 +++--- 14 files changed, 571 insertions(+), 1105 deletions(-) delete mode 100644 js/instant-translation.js diff --git a/inc/Smartling/ApiWrapper.php b/inc/Smartling/ApiWrapper.php index 959a9774..25425ca8 100644 --- a/inc/Smartling/ApiWrapper.php +++ b/inc/Smartling/ApiWrapper.php @@ -109,6 +109,22 @@ public function acquireLock(ConfigurationProfileEntity $profile, string $key, in ->acquireLock("{$profile->getProjectId()}-$key", $ttlSeconds); } + /** + * @throws SmartlingApiException + */ + public function getSourceLocale(ConfigurationProfileEntity $profile): string + { + $api = ProjectApi::create( + $this->getAuthProvider($profile), + $profile->getProjectId(), + $this->getLogger() + ); + + $details = $api->getProjectDetails(); + + return $details['sourceLocaleId']; + } + public function renewLock(ConfigurationProfileEntity $profile, string $key, int $ttlSeconds): \DateTime { return DistributedLockServiceApi::create($this->getAuthProvider($profile), $profile->getProjectId(), $this->getLogger()) diff --git a/inc/Smartling/ApiWrapperInterface.php b/inc/Smartling/ApiWrapperInterface.php index 3fa82c95..3cec18ad 100644 --- a/inc/Smartling/ApiWrapperInterface.php +++ b/inc/Smartling/ApiWrapperInterface.php @@ -50,6 +50,8 @@ interface ApiWrapperInterface */ public function acquireLock(ConfigurationProfileEntity $profile, string $key, int $ttlSeconds): \DateTime; + public function getSourceLocale(ConfigurationProfileEntity $profile): string; + /** * @throws SmartlingApiException */ diff --git a/inc/Smartling/ApiWrapperWithRetries.php b/inc/Smartling/ApiWrapperWithRetries.php index a7eec352..dcbece89 100644 --- a/inc/Smartling/ApiWrapperWithRetries.php +++ b/inc/Smartling/ApiWrapperWithRetries.php @@ -30,6 +30,13 @@ public function acquireLock(ConfigurationProfileEntity $profile, string $key, in }); } + public function getSourceLocale(ConfigurationProfileEntity $profile): string + { + return $this->withRetry(function () use ($profile) { + return $this->base->getSourceLocale($profile); + }); + } + public function renewLock(ConfigurationProfileEntity $profile, string $key, int $ttlSeconds): \DateTime { return $this->withRetry(function () use ($profile, $key, $ttlSeconds) { diff --git a/inc/Smartling/FTS/FileTranslationsApiExtended.php b/inc/Smartling/FTS/FileTranslationsApiExtended.php index c34cf77c..206c779e 100644 --- a/inc/Smartling/FTS/FileTranslationsApiExtended.php +++ b/inc/Smartling/FTS/FileTranslationsApiExtended.php @@ -2,54 +2,32 @@ namespace Smartling\FTS; -use Psr\Log\LoggerInterface; use Smartling\Vendor\GuzzleHttp\RequestOptions; use Smartling\Vendor\Smartling\AuthApi\AuthApiInterface; use Smartling\Vendor\Smartling\FileTranslations\FileTranslationsApi; -/** - * Extended FileTranslations API with custom header support - * - * Extends the base FileTranslationsApi to inject the X-SL-ServiceOrigin header - * required for FTS (Fast Translation Service) requests. - */ class FileTranslationsApiExtended extends FileTranslationsApi { private const SERVICE_ORIGIN_HEADER = 'X-SL-ServiceOrigin'; private const SERVICE_ORIGIN_VALUE = 'wordpress'; - /** - * Additional headers to inject into all requests - * - * @var array - */ private array $additionalHeaders = []; - /** - * {@inheritdoc} - */ public function __construct($accountUid, $client, $logger = null, $service_url = null) { parent::__construct($accountUid, $client, $logger, $service_url); - // Set the required service origin header $this->additionalHeaders[self::SERVICE_ORIGIN_HEADER] = self::SERVICE_ORIGIN_VALUE; } /** - * Factory method to create FileTranslationsApiExtended instance. - * - * @param AuthApiInterface $authProvider - * Authentication provider * @param string $accountUid - * Account UID in Smartling dashboard - * @param LoggerInterface|null $logger - * Logger instance - * - * @return FileTranslationsApiExtended */ - public static function create($authProvider, $accountUid, $logger = null) - { + public static function create( + AuthApiInterface $authProvider, + $accountUid, + $logger = null, + ): FileTranslationsApiExtended { $client = self::initializeHttpClient(self::ENDPOINT_URL); $instance = new self($accountUid, $client, $logger, self::ENDPOINT_URL); @@ -58,16 +36,10 @@ public static function create($authProvider, $accountUid, $logger = null) return $instance; } - /** - * {@inheritdoc} - * - * Overrides to inject additional headers into all requests - */ protected function getDefaultRequestData($parametersType, $parameters, $auth = true, $httpErrors = false): array { $data = parent::getDefaultRequestData($parametersType, $parameters, $auth, $httpErrors); - // Merge additional headers into request if (!isset($data[RequestOptions::HEADERS])) { $data[RequestOptions::HEADERS] = []; } diff --git a/inc/Smartling/FTS/FtsApiWrapper.php b/inc/Smartling/FTS/FtsApiWrapper.php index b32917e8..82d54417 100644 --- a/inc/Smartling/FTS/FtsApiWrapper.php +++ b/inc/Smartling/FTS/FtsApiWrapper.php @@ -2,6 +2,7 @@ namespace Smartling\FTS; +use Smartling\ApiWrapperInterface; use Smartling\Exception\SmartlingDbException; use Smartling\Helpers\LoggerSafeTrait; use Smartling\Settings\ConfigurationProfileEntity; @@ -12,41 +13,21 @@ use Smartling\Vendor\Smartling\FileTranslations\Params\TranslateFileParameters; use Smartling\Vendor\Smartling\Project\ProjectApi; -/** - * FTS (Fast Translation Service) API Wrapper - * - * Handles API communication with Smartling's File Translations API - * for instant translation functionality. All requests include the - * required X-SL-ServiceOrigin: wordpress header. - */ class FtsApiWrapper { use LoggerSafeTrait; - private SettingsManager $settingsManager; - private string $pluginName; - private string $pluginVersion; - - /** - * Cache for account UIDs by project ID - * - * @var array - */ private array $accountUidCache = []; public function __construct( - SettingsManager $settingsManager, - string $pluginName, - string $pluginVersion + private ApiWrapperInterface $apiWrapper, + private SettingsManager $settingsManager, + private string $pluginName, + private string $pluginVersion, ) { - $this->settingsManager = $settingsManager; - $this->pluginName = $pluginName; - $this->pluginVersion = $pluginVersion; } /** - * Get configuration profile for submission - * * @throws SmartlingDbException */ private function getConfigurationProfile(SubmissionEntity $submission): ConfigurationProfileEntity @@ -54,58 +35,6 @@ private function getConfigurationProfile(SubmissionEntity $submission): Configur return $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); } - /** - * Get account UID for a project - * - * Uses ProjectApi to fetch account UID and caches it for subsequent calls. - * - * @throws SmartlingApiException - * @throws SmartlingDbException - */ - private function getAccountUid(ConfigurationProfileEntity $profile): string - { - $projectId = $profile->getProjectId(); - - // Return cached value if available - if (isset($this->accountUidCache[$projectId])) { - return $this->accountUidCache[$projectId]; - } - - AuthTokenProvider::setCurrentClientId($this->pluginName); - AuthTokenProvider::setCurrentClientVersion($this->pluginVersion); - - $authProvider = AuthTokenProvider::create( - $profile->getUserIdentifier(), - $profile->getSecretKey(), - $this->getLogger() - ); - - $projectApi = ProjectApi::create($authProvider, $projectId, $this->getLogger()); - $projectDetails = $projectApi->getProjectDetails(); - - $accountUid = $projectDetails['accountUid'] ?? null; - - if (empty($accountUid)) { - throw new \RuntimeException('Failed to get account UID from project details'); - } - - // Cache for future use - $this->accountUidCache[$projectId] = $accountUid; - - $this->getLogger()->info('Retrieved account UID', [ - 'projectId' => $projectId, - 'accountUid' => $accountUid, - ]); - - return $accountUid; - } - - /** - * Create File Translations API client with required headers - * - * @throws SmartlingApiException - * @throws SmartlingDbException - */ private function getFileTranslationsApi(ConfigurationProfileEntity $profile): FileTranslationsApiExtended { AuthTokenProvider::setCurrentClientId($this->pluginName); @@ -114,22 +43,17 @@ private function getFileTranslationsApi(ConfigurationProfileEntity $profile): Fi $authProvider = AuthTokenProvider::create( $profile->getUserIdentifier(), $profile->getSecretKey(), - $this->getLogger() + $this->getLogger(), ); - $accountUid = $this->getAccountUid($profile); - - return FileTranslationsApiExtended::create($authProvider, $accountUid, $this->getLogger()); + return FileTranslationsApiExtended::create( + $authProvider, + $this->apiWrapper->getAccountUid($profile), + $this->getLogger(), + ); } /** - * Upload file for instant translation - * - * @param SubmissionEntity $submission - * @param string $filePath Path to temporary file containing XML content - * @param string $fileName Logical file name - * @param string $fileType File type (xml, json, etc.) - * @return array Response containing fileUid * @throws SmartlingApiException * @throws SmartlingDbException */ @@ -137,28 +61,20 @@ public function uploadFile( SubmissionEntity $submission, string $filePath, string $fileName, - string $fileType = 'xml' - ): array { - $profile = $this->getConfigurationProfile($submission); - $api = $this->getFileTranslationsApi($profile); + string $fileType = 'xml', + ): string { + $this->getLogger()->info("Uploading file for instant translation, submissionId={$submission->getId()}, fileType=$fileType, fileName=$fileName"); - $this->getLogger()->info('Uploading file for instant translation', [ - 'submissionId' => $submission->getId(), - 'fileName' => $fileName, - 'fileType' => $fileType, - ]); + $fileUid = $this->getFileTranslationsApi($this->getConfigurationProfile($submission)) + ->uploadFile($filePath, $fileName, $fileType)['fileUid'] ?? null; - return $api->uploadFile($filePath, $fileName, $fileType); + if (empty($fileUid)) { + throw new \RuntimeException('Failed to get fileUid from upload response'); + } + return $fileUid; } /** - * Submit file for instant translation - * - * @param SubmissionEntity $submission - * @param string $fileUid File UID returned from uploadFile - * @param string $sourceLocaleId Source locale ID - * @param array $targetLocaleIds Array of target locale IDs - * @return array Response containing mtUid (machine translation UID) * @throws SmartlingApiException * @throws SmartlingDbException */ @@ -166,55 +82,37 @@ public function submitForInstantTranslation( SubmissionEntity $submission, string $fileUid, string $sourceLocaleId, - array $targetLocaleIds - ): array { - $profile = $this->getConfigurationProfile($submission); - $api = $this->getFileTranslationsApi($profile); - - // Create translation parameters + array $targetLocaleIds, + ): string { $params = new TranslateFileParameters(); $params->setSourceLocaleId($sourceLocaleId); $params->setTargetLocaleIds($targetLocaleIds); - $this->getLogger()->info('Submitting file for instant translation', [ - 'submissionId' => $submission->getId(), - 'fileUid' => $fileUid, - 'sourceLocale' => $sourceLocaleId, - 'targetLocales' => $targetLocaleIds, - ]); + $this->getLogger()->info("Submitting file for instant translation, submissionId={$submission->getId()}, fileUid=$fileUid, sourceLocaleId=$sourceLocaleId, targetLocaleIds=" . implode(',', $targetLocaleIds)); - return $api->translateFile($fileUid, $params); + $mtUid = $this->getFileTranslationsApi($this->getConfigurationProfile($submission)) + ->translateFile($fileUid, $params)['mtUid'] ?? null; + if (empty($mtUid)) { + throw new \RuntimeException('Failed to get mtUid from translation response'); + } + + return $mtUid; } /** - * Poll translation status - * - * @param SubmissionEntity $submission - * @param string $fileUid File UID - * @param string $mtUid Machine translation UID returned from submitForInstantTranslation - * @return array Response containing translation progress and state * @throws SmartlingApiException * @throws SmartlingDbException */ public function pollTranslationStatus( SubmissionEntity $submission, string $fileUid, - string $mtUid + string $mtUid, ): array { - $profile = $this->getConfigurationProfile($submission); - $api = $this->getFileTranslationsApi($profile); - - return $api->getTranslationProgress($fileUid, $mtUid); + return $this->getFileTranslationsApi($this->getConfigurationProfile($submission)) + ->getTranslationProgress($fileUid, $mtUid); } /** - * Download translated file - * - * @param SubmissionEntity $submission - * @param string $fileUid File UID - * @param string $mtUid Machine translation UID - * @param string $localeId Target locale ID - * @return string Raw translated file content * @throws SmartlingApiException * @throws SmartlingDbException */ @@ -222,18 +120,11 @@ public function downloadTranslatedFile( SubmissionEntity $submission, string $fileUid, string $mtUid, - string $localeId + string $localeId, ): string { - $profile = $this->getConfigurationProfile($submission); - $api = $this->getFileTranslationsApi($profile); - - $this->getLogger()->info('Downloading translated file', [ - 'submissionId' => $submission->getId(), - 'fileUid' => $fileUid, - 'mtUid' => $mtUid, - 'localeId' => $localeId, - ]); + $this->getLogger()->info("Downloading translated file, submissionId={$submission->getId()}, fileUid=$fileUid, mtUid=$mtUid, localeId=$localeId"); - return $api->downloadTranslatedFile($fileUid, $mtUid, $localeId); + return $this->getFileTranslationsApi($this->getConfigurationProfile($submission)) + ->downloadTranslatedFile($fileUid, $mtUid, $localeId); } } diff --git a/inc/Smartling/FTS/FtsService.php b/inc/Smartling/FTS/FtsService.php index 50eb360a..4973afbd 100644 --- a/inc/Smartling/FTS/FtsService.php +++ b/inc/Smartling/FTS/FtsService.php @@ -5,143 +5,174 @@ use Smartling\ApiWrapperInterface; use Smartling\Base\SmartlingCore; use Smartling\Exception\SmartlingDbException; -use Smartling\Helpers\ContentHelper; +use Smartling\Helpers\DateTimeHelper; use Smartling\Helpers\LoggerSafeTrait; use Smartling\Helpers\PostContentHelper; +use Smartling\Helpers\SiteHelper; use Smartling\Helpers\XmlHelper; use Smartling\Settings\SettingsManager; use Smartling\Submissions\SubmissionEntity; use Smartling\Submissions\SubmissionManager; use Smartling\Vendor\Smartling\Exceptions\SmartlingApiException; -/** - * FTS (Fast Translation Service) Service - * - * Orchestrates the instant translation workflow: - * 1. Generate and upload file for translation - * 2. Submit for instant translation - * 3. Poll for completion with exponential backoff - * 4. Download and apply translated content - */ class FtsService { use LoggerSafeTrait; - // Polling configuration - private const INITIAL_POLL_INTERVAL_MS = 1000; // 1 second - private const MAX_BACKOFF_INTERVAL_MS = 30000; // 30 seconds - private const TIMEOUT_MS = 120000; // 2 minutes - - // Exponential backoff intervals in milliseconds - private const POLL_INTERVALS = [ - 1000, // 1s - 2000, // 2s - 4000, // 4s - 8000, // 8s - 16000, // 16s - // All subsequent polls use MAX_BACKOFF_INTERVAL_MS (30s) - ]; - - // FTS status states + private const INITIAL_POLL_INTERVAL_MS = 1000; + private const MAX_BACKOFF_INTERVAL_MS = 30000; + private const TIMEOUT_MS = 120000; + private const STATE_QUEUED = 'QUEUED'; private const STATE_PROCESSING = 'PROCESSING'; private const STATE_COMPLETED = 'COMPLETED'; private const STATE_FAILED = 'FAILED'; private const STATE_CANCELLED = 'CANCELLED'; - private FtsApiWrapper $ftsApiWrapper; - private ApiWrapperInterface $apiWrapper; - private SubmissionManager $submissionManager; - private ContentHelper $contentHelper; - private SmartlingCore $core; - private SettingsManager $settingsManager; - private XmlHelper $xmlHelper; - private PostContentHelper $postContentHelper; - public function __construct( - FtsApiWrapper $ftsApiWrapper, - ApiWrapperInterface $apiWrapper, - SubmissionManager $submissionManager, - ContentHelper $contentHelper, - SmartlingCore $core, - SettingsManager $settingsManager, - XmlHelper $xmlHelper, - PostContentHelper $postContentHelper + private ApiWrapperInterface $apiWrapper, + private FtsApiWrapper $ftsApiWrapper, + private PostContentHelper $postContentHelper, + private SettingsManager $settingsManager, + private SiteHelper $siteHelper, + private SubmissionManager $submissionManager, + private SmartlingCore $core, + private XmlHelper $xmlHelper, ) { - $this->ftsApiWrapper = $ftsApiWrapper; - $this->apiWrapper = $apiWrapper; - $this->submissionManager = $submissionManager; - $this->contentHelper = $contentHelper; - $this->core = $core; - $this->settingsManager = $settingsManager; - $this->xmlHelper = $xmlHelper; - $this->postContentHelper = $postContentHelper; + } + + public function requestInstantTranslation(SubmissionEntity $submission): array + { + $this->getLogger()->info("Starting instant translation request, submissionId={$submission->getId()}, contentType={$submission->getContentType()}, sourceBlogId={$submission->getSourceBlogId()}, targetBlogId={$submission->getTargetBlogId()}"); + + try { + $fileUid = $this->uploadFile($submission); + $mtUid = $this->submitFile($submission, $fileUid); + $pollResult = $this->pollUntilComplete($submission, $fileUid, $mtUid); + + if ($pollResult['status'] === self::STATE_COMPLETED) { + $this->downloadAndApply($submission, $fileUid, $mtUid); + + $this->getLogger()->info("Instant translation completed successfully, submissionId={$submission->getId()}, contentType={$submission->getContentType()}, sourceBlogId={$submission->getSourceBlogId()}"); + + return [ + 'success' => true, + 'status' => self::STATE_COMPLETED, + 'fileUid' => $fileUid, + 'mtUid' => $mtUid, + ]; + } + + return [ + 'success' => false, + 'status' => $pollResult['status'], + 'message' => $pollResult['message'], + 'fileUid' => $fileUid, + 'mtUid' => $mtUid, + ]; + + } catch (\Exception $e) { + $this->getLogger()->error("Instant translation failed with exception, submissionId={$submission->getId()}, message={$e->getMessage()}"); + + return [ + 'success' => false, + 'status' => 'error', + 'message' => $e->getMessage(), + ]; + } } /** - * Request instant translation for a submission - * - * This is the main entry point for instant translation. It: - * 1. Generates XML and uploads the file to Smartling FTS - * 2. Submits it for instant translation - * 3. Polls for completion with exponential backoff - * 4. Downloads and applies the translation - * - * @param SubmissionEntity $submission Submission to translate - * @return array Result with status and details - * @throws SmartlingApiException - * @throws SmartlingDbException + * @param SubmissionEntity[] $submissions + * @throws \JsonException */ - public function requestInstantTranslation(SubmissionEntity $submission): array + public function requestInstantTranslationBatch(array $submissions): array { - $startTime = microtime(true); + if (empty($submissions)) { + return [ + 'success' => false, + 'message' => 'No submissions provided', + ]; + } + + $firstSubmission = $submissions[0]; + $submissionIds = array_map(static fn($s) => $s->getId(), $submissions); + foreach ($submissions as $submission) { + if ($submission->getSourceId() !== $firstSubmission->getSourceId()) { + return [ + 'success' => false, + 'message' => 'Same source submissions expected', + ]; + } + } $this->getLogger()->info( - 'Starting instant translation request', - [ - 'submissionId' => $submission->getId(), - 'contentType' => $submission->getContentType(), - 'sourceBlogId' => $submission->getSourceBlogId(), - 'targetBlogId' => $submission->getTargetBlogId(), - ] + "Starting batch instant translation request, submissionIds=" . implode(',', $submissionIds) . + ", contentType={$firstSubmission->getContentType()}, sourceBlogId={$firstSubmission->getSourceBlogId()}" ); try { - // Step 1: Generate XML and upload file to FTS - $fileUid = $this->uploadFile($submission); - - // Step 2: Submit for instant translation - $mtUid = $this->submitFile($submission, $fileUid); + $fileUid = $this->uploadFile($firstSubmission); + + $profile = $this->settingsManager->getSingleSettingsProfile($firstSubmission->getSourceBlogId()); + $sourceLocale = $this->apiWrapper->getSourceLocale($profile); + $targetLocales = []; + + foreach ($submissions as $submission) { + $targetLocale = $profile->getSmartlingLocale($submission->getTargetBlogId()); + if (empty($targetLocale)) { + throw new \RuntimeException("Failed to determine target locale for submission {$submission->getId()}"); + } + $targetLocales[] = $targetLocale; + } - // Step 3: Poll until complete or timeout - $pollResult = $this->pollUntilComplete($submission, $fileUid, $mtUid); + $mtUid = $this->ftsApiWrapper->submitForInstantTranslation( + $firstSubmission, + $fileUid, + $sourceLocale, + $targetLocales, + ); - if ($pollResult['status'] === 'completed') { - // Step 4: Download and apply translation - $this->downloadAndApply($submission, $fileUid, $mtUid); + $this->getLogger()->info( + "Batch translation request created, fileUid=$fileUid, mtUid=$mtUid, sourceLocale=$sourceLocale, targetLocales=" . + implode(',', $targetLocales) + ); - $elapsedTime = round((microtime(true) - $startTime) * 1000); + $pollResult = $this->pollUntilComplete($firstSubmission, $fileUid, $mtUid); + + if ($pollResult['status'] === self::STATE_COMPLETED) { + foreach ($submissions as $submission) { + try { + $this->downloadAndApply($submission, $fileUid, $mtUid); + $this->getLogger()->info("Translation applied for submission {$submission->getId()}"); + } catch (\Exception $e) { + $this->getLogger()->error( + "Failed to apply translation for submission {$submission->getId()}: {$e->getMessage()}" + ); + $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_FAILED); + $submission->setLastError($e->getMessage()); + $this->submissionManager->storeEntity($submission); + } + } $this->getLogger()->info( - 'Instant translation completed successfully', - [ - 'submissionId' => $submission->getId(), - 'fileUid' => $fileUid, - 'mtUid' => $mtUid, - 'elapsedTimeMs' => $elapsedTime, - ] + "Batch instant translation completed, submissionIds=" . implode(',', $submissionIds) ); return [ 'success' => true, - 'status' => 'completed', + 'status' => self::STATE_COMPLETED, 'fileUid' => $fileUid, 'mtUid' => $mtUid, - 'elapsedTimeMs' => $elapsedTime, ]; } - // Timeout or failure + foreach ($submissions as $submission) { + $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_FAILED); + $submission->setLastError($pollResult['message'] ?? 'Translation failed'); + $this->submissionManager->storeEntity($submission); + } + return [ 'success' => false, 'status' => $pollResult['status'], @@ -152,14 +183,16 @@ public function requestInstantTranslation(SubmissionEntity $submission): array } catch (\Exception $e) { $this->getLogger()->error( - 'Instant translation failed with exception', - [ - 'submissionId' => $submission->getId(), - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ] + "Batch instant translation failed with exception, submissionIds=" . implode(',', $submissionIds) . + ", message={$e->getMessage()}" ); + foreach ($submissions as $submission) { + $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_FAILED); + $submission->setLastError($e->getMessage()); + $this->submissionManager->storeEntity($submission); + } + return [ 'success' => false, 'status' => 'error', @@ -169,30 +202,18 @@ public function requestInstantTranslation(SubmissionEntity $submission): array } /** - * Generate XML and upload file to FTS - * - * @return string File UID from FTS - * @throws SmartlingApiException - * @throws SmartlingDbException + * @return string FileUid from FTS */ private function uploadFile(SubmissionEntity $submission): string { - $this->getLogger()->debug('Preparing file for instant translation', [ - 'submissionId' => $submission->getId(), - ]); + $this->getLogger()->debug("Preparing file for instant translation, submissionId={$submission->getId()}"); - // Prepare content for upload using SmartlingCore $submission = $this->core->prepareUpload($submission); - // Get XML content - $xml = $this->core->getXMLFiltered($submission); - - // Create temporary file for upload $tempFile = tempnam(sys_get_temp_dir(), 'smartling_fts_'); - file_put_contents($tempFile, $xml); + file_put_contents($tempFile, $this->core->getXMLFiltered($submission)); try { - // Generate logical file name $fileName = sprintf( 'instant-translation-%s-%d-%d.xml', $submission->getContentType(), @@ -200,30 +221,11 @@ private function uploadFile(SubmissionEntity $submission): string time() ); - // Upload to FTS - $response = $this->ftsApiWrapper->uploadFile( - $submission, - $tempFile, - $fileName, - 'xml' - ); - - $fileUid = $response['fileUid'] ?? null; - - if (empty($fileUid)) { - throw new \RuntimeException('Failed to get file UID from upload response'); - } - - $this->getLogger()->info('File uploaded to FTS', [ - 'submissionId' => $submission->getId(), - 'fileUid' => $fileUid, - 'fileName' => $fileName, - ]); + $fileUid = $this->ftsApiWrapper->uploadFile($submission, $tempFile, $fileName); + $this->getLogger()->info("File uploaded to FTS, submissionId={$submission->getId()}, fileUid=$fileUid"); return $fileUid; - } finally { - // Clean up temporary file if (file_exists($tempFile)) { unlink($tempFile); } @@ -231,188 +233,107 @@ private function uploadFile(SubmissionEntity $submission): string } /** - * Submit file for instant translation - * - * @param string $fileUid File UID from upload - * @return string Machine translation UID (mtUid) * @throws SmartlingApiException * @throws SmartlingDbException */ private function submitFile(SubmissionEntity $submission, string $fileUid): string { - $this->getLogger()->debug('Submitting file for instant translation', [ - 'submissionId' => $submission->getId(), - 'fileUid' => $fileUid, - ]); - - // Get source locale from WordPress for the source blog - $sourceBlogId = $submission->getSourceBlogId(); - - // Switch to source blog to get its locale - switch_to_blog($sourceBlogId); - $wpLocale = get_locale(); // e.g., "en_US" - restore_current_blog(); + $this->getLogger()->debug("Submitting file for instant translation, submissionId={$submission->getId()}, fileUid=$fileUid"); - // Convert WordPress locale (en_US) to Smartling locale (en-US) - $sourceLocaleId = str_replace('_', '-', $wpLocale); - - // Get target locale using the profile $profile = $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); + $sourceLocale = $this->apiWrapper->getSourceLocale($profile); $targetLocale = $profile->getSmartlingLocale($submission->getTargetBlogId()); if (empty($targetLocale)) { throw new \RuntimeException('Failed to determine target locale for submission'); } - $targetLocaleIds = [$targetLocale]; - - $response = $this->ftsApiWrapper->submitForInstantTranslation( + $mtUid = $this->ftsApiWrapper->submitForInstantTranslation( $submission, $fileUid, - $sourceLocaleId, - $targetLocaleIds + $sourceLocale, + [$targetLocale] ); - $mtUid = $response['mtUid'] ?? null; - - if (empty($mtUid)) { - throw new \RuntimeException('Failed to get MT UID from response'); - } - - $this->getLogger()->info('Translation request created', [ - 'submissionId' => $submission->getId(), - 'fileUid' => $fileUid, - 'mtUid' => $mtUid, - 'sourceLocale' => $sourceLocaleId, - 'targetLocale' => $targetLocale, - ]); + $this->getLogger()->info("Translation request created, submissionId={$submission->getId()}, fileUid=$fileUid, mtUid=$mtUid, sourceLocale=$sourceLocale, targetLocale=$targetLocale"); return $mtUid; } /** - * Poll for translation completion with exponential backoff - * - * Polling intervals: 1s -> 2s -> 4s -> 8s -> 16s -> 30s -> 30s -> ... - * Timeout: 2 minutes - * - * @return array Status result * @throws SmartlingApiException * @throws SmartlingDbException */ private function pollUntilComplete(SubmissionEntity $submission, string $fileUid, string $mtUid): array { $startTime = microtime(true); - $intervalIndex = 0; - $this->getLogger()->debug('Starting polling for instant translation', [ - 'submissionId' => $submission->getId(), - 'fileUid' => $fileUid, - 'mtUid' => $mtUid, - 'timeoutMs' => self::TIMEOUT_MS, - ]); + $this->getLogger()->debug("Starting polling for instant translation, submissionId={$submission->getId()}, fileUid=$fileUid, mtUid=$mtUid"); while (true) { $elapsedMs = (microtime(true) - $startTime) * 1000; - // Check timeout if ($elapsedMs >= self::TIMEOUT_MS) { - $this->getLogger()->warning('Instant translation polling timed out', [ - 'submissionId' => $submission->getId(), - 'fileUid' => $fileUid, - 'mtUid' => $mtUid, - 'elapsedMs' => round($elapsedMs), - ]); + $this->getLogger()->warning("Instant translation polling timed out, submissionId={$submission->getId()}, fileUid=$fileUid, mtUid=$mtUid"); return [ 'status' => 'timeout', - 'message' => 'Translation request timed out after 2 minutes', + 'message' => 'Translation request timed out after ' . round(self::TIMEOUT_MS / 1000 / 60) . ' minutes', ]; } - // Poll status $response = $this->ftsApiWrapper->pollTranslationStatus($submission, $fileUid, $mtUid); $state = $response['state'] ?? ''; - $this->getLogger()->debug('Polled translation status', [ - 'submissionId' => $submission->getId(), - 'state' => $state, - 'elapsedMs' => round($elapsedMs), - ]); - - // Check if completed - if ($state === self::STATE_COMPLETED) { - return [ - 'status' => 'completed', - 'data' => $response, - ]; - } - - // Check if failed - if ($state === self::STATE_FAILED) { - $error = $data['error'] ?? 'Translation request failed'; - return [ - 'status' => 'failed', - 'message' => is_array($error) ? ($error['message'] ?? 'Unknown error') : $error, - 'data' => $response, - ]; - } - - // Check if cancelled - if ($state === self::STATE_CANCELLED) { - return [ - 'status' => 'cancelled', - 'message' => 'Translation request was cancelled', - 'data' => $response, - ]; + $this->getLogger()->debug("Polled translation status, submissionId={$submission->getId()}, fileUid=$fileUid, mtUid=$mtUid, state=$state"); + $waitMs = null; + + switch ($state) { + case self::STATE_COMPLETED: + return [ + 'status' => self::STATE_COMPLETED, + 'data' => $response, + ]; + case self::STATE_FAILED: + $error = $data['error'] ?? 'Translation request failed'; + return [ + 'status' => self::STATE_FAILED, + 'message' => is_array($error) ? ($error['message'] ?? 'Unknown error') : $error, + 'data' => $response, + ]; + case self::STATE_CANCELLED: + return [ + 'status' => self::STATE_CANCELLED, + 'message' => 'Translation request was cancelled', + 'data' => $response, + ]; } - // Calculate next wait interval with exponential backoff - $waitMs = $this->getNextPollInterval($intervalIndex); - $intervalIndex++; + $waitMs = $this->getNextPollInterval($waitMs); - $this->getLogger()->debug('Waiting before next poll', [ - 'waitMs' => $waitMs, - 'nextIntervalIndex' => $intervalIndex, - ]); + $this->getLogger()->debug("Waiting before next poll, waitMs=$waitMs"); - // Sleep for the calculated interval (convert to microseconds) usleep($waitMs * 1000); } } - /** - * Get next polling interval using exponential backoff - * - * @param int $intervalIndex Current interval index - * @return int Wait time in milliseconds - */ - private function getNextPollInterval(int $intervalIndex): int + public function getNextPollInterval(?int $waitMs): int { - // Use predefined intervals, or max interval if we've exceeded the array - if ($intervalIndex < count(self::POLL_INTERVALS)) { - return self::POLL_INTERVALS[$intervalIndex]; + if ($waitMs === null || $waitMs < self::INITIAL_POLL_INTERVAL_MS) { + return self::INITIAL_POLL_INTERVAL_MS; } - - // All subsequent polls use 30s - return self::MAX_BACKOFF_INTERVAL_MS; + return min($waitMs * 2, self::MAX_BACKOFF_INTERVAL_MS); } /** - * Download and apply translated content - * * @throws SmartlingApiException * @throws SmartlingDbException + * @throws \JsonException */ private function downloadAndApply(SubmissionEntity $submission, string $fileUid, string $mtUid): void { - $this->getLogger()->debug('Downloading and applying translation', [ - 'submissionId' => $submission->getId(), - 'fileUid' => $fileUid, - 'mtUid' => $mtUid, - ]); + $this->getLogger()->info("Downloading and applying translation, submissionId={$submission->getId()}, fileUid=$fileUid, mtUid=$mtUid"); - // Get target locale using the profile $profile = $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); $targetLocale = $profile->getSmartlingLocale($submission->getTargetBlogId()); @@ -420,27 +341,20 @@ private function downloadAndApply(SubmissionEntity $submission, string $fileUid, throw new \RuntimeException('Failed to determine target locale for download'); } - // Download translated file $translatedXml = $this->ftsApiWrapper->downloadTranslatedFile( $submission, $fileUid, $mtUid, - $targetLocale + $targetLocale, ); - // Apply translated content using SmartlingCoreUploadTrait $this->core->applyXML($submission, $translatedXml, $this->xmlHelper, $this->postContentHelper); - // Update submission status $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_COMPLETED); $submission->setCompletedStringCount($submission->getWordCount()); - $submission->setAppliedDate(date('c')); + $submission->setAppliedDate(DateTimeHelper::nowAsString()); $this->submissionManager->storeEntity($submission); - $this->getLogger()->info('Translation applied successfully', [ - 'submissionId' => $submission->getId(), - 'fileUid' => $fileUid, - 'mtUid' => $mtUid, - ]); + $this->getLogger()->info("Translation applied successfully, submissionId={$submission->getId()}"); } } diff --git a/inc/Smartling/WP/Controller/ContentEditJobController.php b/inc/Smartling/WP/Controller/ContentEditJobController.php index 9b40e2c8..c97b7381 100644 --- a/inc/Smartling/WP/Controller/ContentEditJobController.php +++ b/inc/Smartling/WP/Controller/ContentEditJobController.php @@ -209,7 +209,6 @@ public function wp_enqueue() 'smartling.dtpicker.conflict.resolver.js', 'moment.js', 'moment-timezone-with-data.js', - 'instant-translation.js', ]; foreach ($jsFiles as $jFile) { $jFile = $jsPath . $jFile; diff --git a/inc/Smartling/WP/Controller/InstantTranslationController.php b/inc/Smartling/WP/Controller/InstantTranslationController.php index 015f5d9e..d193c607 100644 --- a/inc/Smartling/WP/Controller/InstantTranslationController.php +++ b/inc/Smartling/WP/Controller/InstantTranslationController.php @@ -3,16 +3,14 @@ namespace Smartling\WP\Controller; use Smartling\FTS\FtsService; +use Smartling\Helpers\DateTimeHelper; +use Smartling\Helpers\FileUriHelper; use Smartling\Helpers\LoggerSafeTrait; use Smartling\Submissions\SubmissionEntity; +use Smartling\Submissions\SubmissionFactory; use Smartling\Submissions\SubmissionManager; use Smartling\WP\WPHookInterface; -/** - * Instant Translation AJAX Controller - * - * Handles AJAX requests for instant translation feature - */ class InstantTranslationController implements WPHookInterface { use LoggerSafeTrait; @@ -20,100 +18,104 @@ class InstantTranslationController implements WPHookInterface private const ACTION_REQUEST_TRANSLATION = 'smartling_instant_translation'; private const ACTION_POLL_STATUS = 'smartling_instant_translation_status'; - private FtsService $ftsService; - private SubmissionManager $submissionManager; - public function __construct( - FtsService $ftsService, - SubmissionManager $submissionManager + private FtsService $ftsService, + private SubmissionManager $submissionManager, + private SubmissionFactory $submissionFactory, + private FileUriHelper $fileUriHelper, ) { - $this->ftsService = $ftsService; - $this->submissionManager = $submissionManager; } public function register(): void { - // Register AJAX handlers for both logged-in users add_action('wp_ajax_' . self::ACTION_REQUEST_TRANSLATION, [$this, 'handleRequestTranslation']); add_action('wp_ajax_' . self::ACTION_POLL_STATUS, [$this, 'handlePollStatus']); } - /** - * Handle instant translation request - */ public function handleRequestTranslation(): void { try { - // Verify nonce if needed - // check_ajax_referer('smartling_instant_translation_nonce'); + $contentType = $_POST['contentType'] ?? ''; + $contentId = (int)($_POST['contentId'] ?? 0); + $relations = $_POST['relations'] ?? []; + $targetBlogIds = array_map('intval', $_POST['targetBlogIds'] ?? []); - // Get parameters - $contentType = sanitize_text_field($_POST['contentType'] ?? ''); - $contentId = intval($_POST['contentId'] ?? 0); - $targetBlogId = intval($_POST['targetBlogId'] ?? 0); - - if (empty($contentType) || empty($contentId) || empty($targetBlogId)) { + if (empty($contentType) || empty($contentId) || empty($targetBlogIds)) { wp_send_json_error([ - 'message' => 'Missing required parameters: contentType, contentId, or targetBlogId' + 'message' => 'Missing required parameters: contentType, contentId, or targetBlogIds' ], 400); - return; } - $this->getLogger()->info('Instant translation requested', [ - 'contentType' => $contentType, - 'contentId' => $contentId, - 'targetBlogId' => $targetBlogId, - ]); + $relatedCount = $this->countRelatedItems($relations); + $this->getLogger()->info( + "Instant translation requested, contentId=$contentId, contentType=$contentType, " . + "targetBlogIds=" . implode(',', $targetBlogIds) . ", relatedItemsCount=$relatedCount" + ); - // Find or create submission $sourceBlogId = get_current_blog_id(); - $submissions = $this->submissionManager->find([ - SubmissionEntity::FIELD_SOURCE_BLOG_ID => $sourceBlogId, - SubmissionEntity::FIELD_SOURCE_ID => $contentId, - SubmissionEntity::FIELD_CONTENT_TYPE => $contentType, - SubmissionEntity::FIELD_TARGET_BLOG_ID => $targetBlogId, - ]); - if (empty($submissions)) { - // Create new submission - $submission = $this->submissionManager->getSubmissionEntity( - $contentType, - $sourceBlogId, - $contentId, - $targetBlogId - ); - $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_NEW); - $submission = $this->submissionManager->storeEntity($submission); - } else { - $submission = reset($submissions); - } + $allSubmissions = $this->buildSubmissions( + $contentType, + $contentId, + $sourceBlogId, + $targetBlogIds, + $relations, + ); - // Validate submission - if (!$submission || !$submission->getId()) { + if (empty($allSubmissions)) { wp_send_json_error([ - 'message' => 'Failed to create or retrieve submission' + 'message' => 'Failed to create submissions for translation' ], 500); - return; } - // Set status to in progress - $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_IN_PROGRESS); - $this->submissionManager->storeEntity($submission); + $submissionsBySource = []; + foreach ($allSubmissions as $submission) { + $key = $submission->getContentType() . ':' . $submission->getSourceId(); + $submissionsBySource[$key][] = $submission; + } - // Start instant translation asynchronously - // For now, we'll return success and let polling handle the progress - // In a production environment, you might want to queue this operation + $this->getLogger()->info( + "Processing " . count($allSubmissions) . " total submissions in " . + count($submissionsBySource) . " source groups" + ); - wp_send_json_success([ - 'submissionId' => $submission->getId(), - 'message' => 'Instant translation started' - ]); + $allSubmissionIds = []; + + foreach ($submissionsBySource as $sourceKey => $sourceSubmissions) { + $result = $this->ftsService->requestInstantTranslationBatch($sourceSubmissions); + if ($result['success']) { + $submissionIds = array_map(static fn($s) => $s->getId(), $sourceSubmissions); + $allSubmissionIds = array_merge($allSubmissionIds, $submissionIds); + $this->getLogger()->info("Successfully started FTS batch for source: $sourceKey"); + } else { + foreach ($sourceSubmissions as $submission) { + $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_FAILED); + $submission->setLastError($result['message'] ?? 'Translation failed'); + $this->submissionManager->storeEntity($submission); + } + $this->getLogger()->error("FTS batch failed for source: $sourceKey - " . ($result['message'] ?? 'Unknown error')); + } + } + + if (!empty($allSubmissionIds)) { + $uniqueSourceCount = count($submissionsBySource); + + wp_send_json_success([ + 'submissionIds' => $allSubmissionIds, + 'message' => sprintf( + 'Instant translation started for %d item(s) across %d locale(s)', + $uniqueSourceCount, + count($targetBlogIds), + ) + ]); + } else { + wp_send_json_error([ + 'message' => 'Failed to start instant translation for all items' + ], 500); + } } catch (\Exception $e) { - $this->getLogger()->error('Instant translation request failed', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); + $this->getLogger()->error('Instant translation request failed: ' . $e->getMessage()); wp_send_json_error([ 'message' => 'Failed to start instant translation: ' . $e->getMessage() @@ -121,80 +123,166 @@ public function handleRequestTranslation(): void } } - /** - * Handle status polling request - */ public function handlePollStatus(): void { try { - // Get submission ID - $submissionId = intval($_POST['submissionId'] ?? 0); + $submissionId = (int)($_POST['submissionId'] ?? 0); - if (empty($submissionId)) { - wp_send_json_error([ - 'message' => 'Missing required parameter: submissionId' - ], 400); - return; + if ($submissionId === 0) { + wp_send_json_error(['message' => 'Missing required parameter: submissionId'], 400); } - // Get submission $submission = $this->submissionManager->getEntityById($submissionId); - if (!$submission) { - wp_send_json_error([ - 'message' => 'Submission not found' - ], 404); - return; - } - - // Check if we need to actually start the translation - // (This happens on first poll after request) - if ($submission->getStatus() === SubmissionEntity::SUBMISSION_STATUS_IN_PROGRESS && - empty($submission->getFileUri())) { - // Translation hasn't started yet, start it now - $result = $this->ftsService->requestInstantTranslation($submission); - - if ($result['success']) { - // Refresh submission to get updated status - $submission = $this->submissionManager->getEntityById($submissionId); - } + if ($submission === null) { + wp_send_json_error(['message' => 'Submission not found'], 404); } - // Return current status wp_send_json_success([ 'status' => $this->mapSubmissionStatus($submission->getStatus()), 'progress' => $submission->getCompletionPercentage(), - 'message' => $submission->getLastError() ?: '' + 'message' => $submission->getLastError() ?: '', ]); - } catch (\Exception $e) { - $this->getLogger()->error('Status poll failed', [ - 'error' => $e->getMessage(), - 'submissionId' => $submissionId ?? 'unknown', - ]); + $this->getLogger()->error("Status poll failed, submissionId=$submissionId: " . $e->getMessage()); + wp_send_json_error(['message' => 'Failed to get translation status: ' . $e->getMessage()], 500); + } + } - wp_send_json_error([ - 'message' => 'Failed to get translation status: ' . $e->getMessage() - ], 500); + private function mapSubmissionStatus(string $submissionStatus): string + { + return match ($submissionStatus) { + SubmissionEntity::SUBMISSION_STATUS_COMPLETED => 'completed', + SubmissionEntity::SUBMISSION_STATUS_FAILED, SubmissionEntity::SUBMISSION_STATUS_CANCELLED => 'failed', + SubmissionEntity::SUBMISSION_STATUS_IN_PROGRESS => 'in_progress', + default => 'pending', + }; + } + + /** + * @return SubmissionEntity[] + */ + private function buildSubmissions( + string $contentType, + int $contentId, + int $sourceBlogId, + array $targetBlogIds, + array $relations + ): array { + $submissions = []; + + foreach ($targetBlogIds as $targetBlogId) { + $mainSubmission = $this->getOrCreateSubmission( + $sourceBlogId, + $targetBlogId, + $contentType, + $contentId, + ); + if ($mainSubmission !== null) { + $submissions[] = $mainSubmission; + } + + $relatedSources = $this->getRelatedSources($relations, $targetBlogId, $contentType, $contentId); + foreach ($relatedSources as $source) { + $relatedSubmission = $this->getOrCreateSubmission( + $sourceBlogId, + $targetBlogId, + $source['type'], + $source['id'] + ); + if ($relatedSubmission !== null) { + $submissions[] = $relatedSubmission; + } + } } + + return $submissions; } /** - * Map submission status to frontend status + * @return array Array of sources: [['id' => int, 'type' => string], ...] */ - private function mapSubmissionStatus(string $submissionStatus): string + private function getRelatedSources( + array $relations, + int $targetBlogId, + string $mainContentType, + int $mainContentId + ): array { + $sources = []; + + $relationSet = $relations[$targetBlogId] ?? []; + foreach ($relationSet as $type => $ids) { + foreach ($ids as $id) { + if ($id === $mainContentId && $type === $mainContentType) { + $this->getLogger()->info( + "Related list contains reference to root content, skip adding sourceId=$id, contentType=$type" + ); + continue; + } + + $sources[] = [ + 'id' => $id, + 'type' => $type, + ]; + } + } + + return $sources; + } + + private function getOrCreateSubmission( + int $sourceBlogId, + int $targetBlogId, + string $contentType, + int $contentId + ): ?SubmissionEntity { + try { + $submission = $this->submissionManager->findOne([ + SubmissionEntity::FIELD_SOURCE_BLOG_ID => $sourceBlogId, + SubmissionEntity::FIELD_SOURCE_ID => $contentId, + SubmissionEntity::FIELD_CONTENT_TYPE => $contentType, + SubmissionEntity::FIELD_TARGET_BLOG_ID => $targetBlogId, + ]); + + if ($submission === null) { + $submissionArray = [ + SubmissionEntity::FIELD_SOURCE_BLOG_ID => $sourceBlogId, + SubmissionEntity::FIELD_SOURCE_ID => $contentId, + SubmissionEntity::FIELD_CONTENT_TYPE => $contentType, + SubmissionEntity::FIELD_TARGET_BLOG_ID => $targetBlogId, + SubmissionEntity::FIELD_STATUS => SubmissionEntity::SUBMISSION_STATUS_NEW, + SubmissionEntity::FIELD_SUBMISSION_DATE => DateTimeHelper::nowAsString(), + ]; + $submission = $this->submissionFactory->fromArray($submissionArray); + $submission->setFileUri($this->fileUriHelper->generateFileUri($submission)); + } else { + $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_NEW); + } + + $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_IN_PROGRESS); + return $this->submissionManager->storeEntity($submission); + } catch (\Exception $e) { + $this->getLogger()->error( + "Failed to get/create submission for contentType=$contentType, contentId=$contentId: " . + $e->getMessage() + ); + return null; + } + } + + private function countRelatedItems(array $relations): int { - switch ($submissionStatus) { - case SubmissionEntity::SUBMISSION_STATUS_COMPLETED: - return 'completed'; - case SubmissionEntity::SUBMISSION_STATUS_FAILED: - case SubmissionEntity::SUBMISSION_STATUS_CANCELLED: - return 'failed'; - case SubmissionEntity::SUBMISSION_STATUS_IN_PROGRESS: - return 'in_progress'; - case SubmissionEntity::SUBMISSION_STATUS_NEW: - default: - return 'pending'; + $uniqueItems = []; + + foreach ($relations as $relationSet) { + foreach ($relationSet as $type => $ids) { + foreach ($ids as $id) { + $key = "$type:$id"; + $uniqueItems[$key] = true; + } + } } + + return count($uniqueItems); } } diff --git a/inc/Smartling/WP/View/ContentEditJob.php b/inc/Smartling/WP/View/ContentEditJob.php index 5f422515..3528e597 100644 --- a/inc/Smartling/WP/View/ContentEditJob.php +++ b/inc/Smartling/WP/View/ContentEditJob.php @@ -102,11 +102,10 @@
New Job Existing Job - Instant Translation Clone'?>
getAutoAuthorize() ? 'checked="checked"' : '' ?>/>
 Instant Translation provides immediate translation without creating a job. Translation will complete in approximately 2 minutes.
+
- + @@ -114,28 +113,24 @@ - + - + - + - + - - - - @@ -422,38 +408,22 @@ function (i, e) { $(this).addClass("active"); const hideWhenCloning = $('.hideWhenCloning'); const cloneButton = $('#cloneButton'); - const instantButton = $('#instantTranslateButton'); - const hideWhenInstant = $('.hideWhenInstant'); switch ($(this).attr("data-action")) { case "new": Helper.ui.createJobForm.show(); hideWhenCloning.show(); - hideWhenInstant.show(); cloneButton.addClass('hidden'); - instantButton.addClass('hidden'); break; case "clone": Helper.ui.createJobForm.hide(); hideWhenCloning.hide(); - hideWhenInstant.show(); $('#addToJob').addClass('hidden'); cloneButton.removeClass('hidden'); - instantButton.addClass('hidden'); - break; - case "instant": - Helper.ui.createJobForm.hide(); - hideWhenCloning.hide(); - hideWhenInstant.hide(); - $('#addToJob').addClass('hidden'); - cloneButton.addClass('hidden'); - instantButton.removeClass('hidden'); break; case "existing": Helper.ui.createJobForm.hide(); hideWhenCloning.show(); - hideWhenInstant.show(); cloneButton.addClass('hidden'); - instantButton.addClass('hidden'); break; default: } diff --git a/inc/config/services.yml b/inc/config/services.yml index f27c9107..b2b2619e 100644 --- a/inc/config/services.yml +++ b/inc/config/services.yml @@ -91,6 +91,7 @@ services: fts.api.wrapper: class: Smartling\FTS\FtsApiWrapper arguments: + - "@api.wrapper.with.retries" - "@manager.settings" - "%plugin.name%" - "%plugin.version%" @@ -98,14 +99,14 @@ services: fts.service: class: Smartling\FTS\FtsService arguments: - - "@fts.api.wrapper" - "@api.wrapper.with.retries" + - "@fts.api.wrapper" + - "@helper.post.content" + - "@manager.settings" + - "@site.helper" - "@manager.submission" - - "@content.helper" - "@entrypoint" - - "@manager.settings" - "@helper.xml" - - "@helper.post.content" queue.db: class: Smartling\Queue\Queue @@ -457,6 +458,8 @@ services: arguments: - "@fts.service" - "@manager.submission" + - "@factory.submission" + - "@file.uri.helper" helper.gutenberg: class: Smartling\Helpers\GutenbergBlockHelper diff --git a/js/app.js b/js/app.js index 1c1d8e44..836ba4ce 100644 --- a/js/app.js +++ b/js/app.js @@ -21,8 +21,6 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, const [totalRequests, setTotalRequests] = useState(0); const [l1Relations, setL1Relations] = useState([]); const [l2Relations, setL2Relations] = useState([]); - - // Instant translation state const [instantPolling, setInstantPolling] = useState(false); const [instantProgress, setInstantProgress] = useState(0); const [instantStatus, setInstantStatus] = useState(''); @@ -99,7 +97,6 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, setRelations(unique); }, [depth, l1Relations, l2Relations]); - // Instant translation handler const handleInstantTranslation = async () => { setSubmitting(true); setError(''); @@ -113,52 +110,45 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, return; } - if (selectedLocales.length > 1) { - setError('Instant translation supports one target locale at a time. Please select only one locale.'); - setSubmitting(false); - return; - } - - const targetBlogId = selectedLocales[0]; - - // Collect all items to translate (main content + related content) - const itemsToTranslate = [{ contentType, contentId }]; + const relationsData = {}; + selectedLocales.forEach(blogId => { + relationsData[blogId] = {}; + relations.filter(r => selectedRelations[`${r.contentType}-${r.id}`]).forEach(r => { + if (!relationsData[blogId][r.contentType]) { + relationsData[blogId][r.contentType] = []; + } + relationsData[blogId][r.contentType].push(r.id); + }); + }); - // Add selected related content + const relatedItemsSet = new Set(); relations.forEach(rel => { if (selectedRelations[`${rel.contentType}-${rel.id}`]) { - itemsToTranslate.push({ contentType: rel.contentType, contentId: rel.id }); + relatedItemsSet.add(`${rel.contentType}-${rel.id}`); } }); - - const submissionIds = []; + const itemCount = 1 + relatedItemsSet.size; + const localeCount = selectedLocales.length; try { - // Create instant translation requests for all items - for (const item of itemsToTranslate) { - const response = await jQuery.post(ajaxUrl, { - action: 'smartling_instant_translation', - contentType: item.contentType, - contentId: item.contentId, - targetBlogId: targetBlogId - }); - - if (response.success && response.data?.submissionId) { - submissionIds.push(response.data.submissionId); - } else { - throw new Error(response.data?.message || 'Failed to start instant translation.'); - } - } + // Single AJAX call with relations + const response = await jQuery.post(ajaxUrl, { + action: 'smartling_instant_translation', + contentType: contentType, + contentId: contentId, + targetBlogIds: selectedLocales, + relations: relationsData + }); - if (submissionIds.length > 0) { + if (response.success && response.data?.submissionIds) { + const submissionIds = response.data.submissionIds; setInstantSubmissionIds(submissionIds); setInstantPolling(true); setInstantProgress(5); - setInstantStatus(`Translating ${submissionIds.length} item${submissionIds.length > 1 ? 's' : ''}... This will take approximately 2 minutes.`); + setInstantStatus(`Translating ${itemCount} item${itemCount > 1 ? 's' : ''} to ${localeCount} locale${localeCount > 1 ? 's' : ''}...`); startInstantPolling(submissionIds, Date.now()); } else { - setError('No items to translate.'); - setSubmitting(false); + throw new Error(response.data?.message || 'Failed to start instant translation.'); } } catch (e) { setError(e.message || 'Failed to start instant translation.'); @@ -175,7 +165,7 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, if (elapsed >= TIMEOUT) { setInstantPolling(false); - setError('Translation request timed out after 2 minutes. Please check the submission status manually.'); + setError('Translation request timed out. Please check the submission status manually.'); setSubmitting(false); return; } @@ -189,7 +179,6 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, let errorMessage = ''; try { - // Poll all submissions const statusPromises = submissionIds.map(submissionId => jQuery.post(ajaxUrl, { action: 'smartling_instant_translation_status', @@ -213,34 +202,24 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, hasError = true; errorMessage = response.data.message || 'Translation failed.'; break; - - case 'in_progress': - case 'pending': - // Still in progress - break; } } }); - // Update completed count setInstantCompletedCount(completedCount); - // Update status message if (submissionIds.length > 1) { setInstantStatus(`Translating ${submissionIds.length} items... ${completedCount} completed.`); } - // Check if all completed if (completedCount === submissionIds.length) { setInstantPolling(false); setInstantProgress(100); setSuccess(`All ${submissionIds.length} item${submissionIds.length > 1 ? 's' : ''} translated successfully!`); setSubmitting(false); - setTimeout(() => window.location.reload(), 2000); return; } - // Check if any failed if (failedCount > 0 && (completedCount + failedCount) === submissionIds.length) { setInstantPolling(false); setError(`${failedCount} translation(s) failed. ${completedCount} completed successfully. ${errorMessage}`); @@ -376,7 +355,7 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, tab.name === 'instant' && el('div', { style: { padding: '12px', background: '#f0f0f1', borderRadius: '4px', marginBottom: '16px' } }, el('p', { style: { margin: 0, fontSize: '14px' } }, el('strong', {}, 'Instant Translation'), - ' provides immediate translation without creating a job. Translation will complete in approximately 2 minutes.' + ' provides immediate translation without creating a job.' ) ), diff --git a/js/instant-translation.js b/js/instant-translation.js deleted file mode 100644 index 949003c3..00000000 --- a/js/instant-translation.js +++ /dev/null @@ -1,355 +0,0 @@ -/** - * Instant Translation Module - * - * Handles instant translation requests with exponential backoff polling. - * Polling intervals: 1s -> 2s -> 4s -> 8s -> 16s -> 30s (subsequent) - * Timeout: 2 minutes - */ -(function ($) { - 'use strict'; - - const InstantTranslation = { - // Polling configuration - POLL_INTERVALS: [1000, 2000, 4000, 8000, 16000], // ms - MAX_BACKOFF_INTERVAL: 30000, // 30 seconds - TIMEOUT: 120000, // 2 minutes - - // State - isPolling: false, - pollIntervalIndex: 0, - startTime: null, - pollTimeoutId: null, - submissionIds: [], - completedCount: 0, - - /** - * Initialize instant translation functionality - */ - init: function () { - this.attachEvents(); - }, - - /** - * Attach event handlers - */ - attachEvents: function () { - const self = this; - $('#instantTranslateButton').on('click', function (e) { - e.preventDefault(); - e.stopPropagation(); - self.handleInstantTranslate(); - }); - }, - - /** - * Handle instant translation button click - */ - handleInstantTranslate: function () { - if (this.isPolling) { - this.showError('Translation already in progress. Please wait...'); - return; - } - - // Validate selections - const targetBlogIds = this.getSelectedTargetLocales(); - if (targetBlogIds.length === 0) { - this.showError('Please select at least one target locale.'); - return; - } - - if (targetBlogIds.length > 1) { - this.showError('Instant translation supports one target locale at a time. Please select only one locale.'); - return; - } - - // Get content info - const contentType = window.currentContent?.contentType || ''; - const contentId = window.currentContent?.id?.[0] || 0; - - if (!contentType || !contentId) { - this.showError('Unable to determine content type or ID.'); - return; - } - - // Disable button - this.setButtonState(true); - this.hideError(); - this.showStatus('Preparing instant translation...'); - - // Send request - this.requestInstantTranslation(contentType, contentId, targetBlogIds[0]); - }, - - /** - * Get selected target locales - */ - getSelectedTargetLocales: function () { - const blogIds = []; - $('.job-wizard .mcheck:checkbox:checked').each(function () { - blogIds.push($(this).attr('data-blog-id')); - }); - return blogIds; - }, - - /** - * Request instant translation - */ - requestInstantTranslation: async function (contentType, contentId, targetBlogId) { - const self = this; - const url = ajaxurl + '?action=smartling_instant_translation'; - - // Collect all items to translate (main content + related content) - const itemsToTranslate = [{ contentType, contentId }]; - - // Add selected related content - $('.relation-checkbox:checked').each(function() { - itemsToTranslate.push({ - contentType: $(this).attr('data-content-type'), - contentId: parseInt($(this).attr('data-id')) - }); - }); - - const submissionIds = []; - - try { - // Create instant translation requests for all items - for (const item of itemsToTranslate) { - const response = await $.post(url, { - contentType: item.contentType, - contentId: item.contentId, - targetBlogId: targetBlogId - }); - - if (response.success && response.data && response.data.submissionId) { - submissionIds.push(response.data.submissionId); - } else { - throw new Error(response.data?.message || 'Failed to start instant translation.'); - } - } - - if (submissionIds.length > 0) { - self.submissionIds = submissionIds; - self.completedCount = 0; - self.startPolling(); - } else { - self.showError('No items to translate.'); - self.setButtonState(false); - } - } catch (e) { - self.showError(e.message || 'Failed to start instant translation.'); - self.setButtonState(false); - } - }, - - /** - * Start polling for translation status - */ - startPolling: function () { - this.isPolling = true; - this.pollIntervalIndex = 0; - this.startTime = Date.now(); - - const count = this.submissionIds.length; - this.showStatus(`Translating ${count} item${count > 1 ? 's' : ''}... This will take approximately 2 minutes.`); - this.updateProgress(5); // Initial progress - - this.poll(); - }, - - /** - * Poll translation status - */ - poll: async function () { - const self = this; - - // Check timeout - const elapsed = Date.now() - this.startTime; - if (elapsed >= this.TIMEOUT) { - this.stopPolling(); - this.showError('Translation request timed out after 2 minutes. Please check the submission status manually.'); - this.setButtonState(false); - return; - } - - // Update progress based on time elapsed - const progressPercent = Math.min(90, (elapsed / this.TIMEOUT) * 100); - this.updateProgress(progressPercent); - - const url = ajaxurl + '?action=smartling_instant_translation_status'; - let completedCount = 0; - let failedCount = 0; - let errorMessage = ''; - - try { - // Poll all submissions - const promises = this.submissionIds.map(submissionId => - $.post(url, { submissionId: submissionId }) - ); - - const responses = await Promise.all(promises); - - responses.forEach(function(response) { - if (response.success && response.data) { - const status = response.data.status; - - switch (status) { - case 'completed': - completedCount++; - break; - - case 'failed': - failedCount++; - errorMessage = response.data.message || 'Translation failed.'; - break; - - case 'in_progress': - case 'pending': - // Still in progress - break; - } - } - }); - - // Update completed count - self.completedCount = completedCount; - - // Update status message - if (self.submissionIds.length > 1) { - self.showStatus(`Translating ${self.submissionIds.length} items... ${completedCount} completed.`); - } - - // Check if all completed - if (completedCount === self.submissionIds.length) { - self.stopPolling(); - self.updateProgress(100); - self.showSuccess(`All ${self.submissionIds.length} item${self.submissionIds.length > 1 ? 's' : ''} translated successfully!`); - self.setButtonState(false); - - setTimeout(function () { - window.location.reload(); - }, 2000); - return; - } - - // Check if any failed - if (failedCount > 0 && (completedCount + failedCount) === self.submissionIds.length) { - self.stopPolling(); - self.showError(`${failedCount} translation(s) failed. ${completedCount} completed successfully. ${errorMessage}`); - self.setButtonState(false); - return; - } - - } catch (e) { - // Continue polling on error - } - - // Schedule next poll - self.scheduleNextPoll(); - }, - - /** - * Schedule next poll with exponential backoff - */ - scheduleNextPoll: function () { - const interval = this.getNextPollInterval(); - const self = this; - - this.pollTimeoutId = setTimeout(function () { - self.poll(); - }, interval); - - this.pollIntervalIndex++; - }, - - /** - * Get next poll interval with exponential backoff - */ - getNextPollInterval: function () { - if (this.pollIntervalIndex < this.POLL_INTERVALS.length) { - return this.POLL_INTERVALS[this.pollIntervalIndex]; - } - // All subsequent polls use max backoff interval - return this.MAX_BACKOFF_INTERVAL; - }, - - /** - * Stop polling - */ - stopPolling: function () { - this.isPolling = false; - if (this.pollTimeoutId) { - clearTimeout(this.pollTimeoutId); - this.pollTimeoutId = null; - } - }, - - /** - * Set button state - */ - setButtonState: function (disabled) { - const $button = $('#instantTranslateButton'); - if (disabled) { - $button.prop('disabled', true).addClass('is-busy').text('Translating...'); - } else { - $button.prop('disabled', false).removeClass('is-busy').text('Request Instant Translation'); - } - }, - - /** - * Show status message - */ - showStatus: function (message) { - const $status = $('#instant-status'); - const $statusText = $('#instant-status-text'); - - $statusText.html('' + message + ''); - $status.removeClass('hidden'); - $('#error-messages').html('').hide(); - }, - - /** - * Hide status - */ - hideStatus: function () { - $('#instant-status').addClass('hidden'); - }, - - /** - * Update progress bar - */ - updateProgress: function (percent) { - $('#instant-progress-bar').css('width', percent + '%'); - }, - - /** - * Show error message - */ - showError: function (message) { - const $errorMessages = $('#error-messages'); - $errorMessages.html('' + message + '').show(); - this.hideStatus(); - }, - - /** - * Hide error message - */ - hideError: function () { - $('#error-messages').html('').hide(); - }, - - /** - * Show success message - */ - showSuccess: function (message) { - const $statusText = $('#instant-status-text'); - $statusText.html('' + message + ''); - this.updateProgress(100); - } - }; - - // Initialize when document is ready - $(document).ready(function () { - InstantTranslation.init(); - }); - -})(jQuery); diff --git a/tests/Smartling/FTS/FtsApiWrapperTest.php b/tests/Smartling/FTS/FtsApiWrapperTest.php index 615f875d..4ceaf494 100644 --- a/tests/Smartling/FTS/FtsApiWrapperTest.php +++ b/tests/Smartling/FTS/FtsApiWrapperTest.php @@ -1,15 +1,13 @@ settingsManager = $this->createMock(SettingsManager::class); $this->ftsApiWrapper = new FtsApiWrapper( + $this->createMock(ApiWrapperInterface::class), $this->settingsManager, 'test-plugin', '1.0.0' ); } - public function testConstructor(): void - { - $this->assertInstanceOf(FtsApiWrapper::class, $this->ftsApiWrapper); - } - public function testUploadFileRequiresConfiguration(): void { - $this->expectException(\Smartling\Exception\SmartlingDbException::class); + $this->expectException(SmartlingDbException::class); $submission = $this->createMock(SubmissionEntity::class); $submission->method('getSourceBlogId')->willReturn(1); $this->settingsManager ->method('getSingleSettingsProfile') - ->willThrowException(new \Smartling\Exception\SmartlingDbException('No profile found')); + ->willThrowException(new SmartlingDbException('No profile found')); $this->ftsApiWrapper->uploadFile( $submission, '/tmp/test.xml', 'test.xml', - 'xml' ); } public function testSubmitForInstantTranslationRequiresConfiguration(): void { - $this->expectException(\Smartling\Exception\SmartlingDbException::class); + $this->expectException(SmartlingDbException::class); $submission = $this->createMock(SubmissionEntity::class); $submission->method('getSourceBlogId')->willReturn(1); $this->settingsManager ->method('getSingleSettingsProfile') - ->willThrowException(new \Smartling\Exception\SmartlingDbException('No profile found')); + ->willThrowException(new SmartlingDbException('No profile found')); $this->ftsApiWrapper->submitForInstantTranslation( $submission, @@ -73,14 +66,14 @@ public function testSubmitForInstantTranslationRequiresConfiguration(): void public function testPollTranslationStatusRequiresConfiguration(): void { - $this->expectException(\Smartling\Exception\SmartlingDbException::class); + $this->expectException(SmartlingDbException::class); $submission = $this->createMock(SubmissionEntity::class); $submission->method('getSourceBlogId')->willReturn(1); $this->settingsManager ->method('getSingleSettingsProfile') - ->willThrowException(new \Smartling\Exception\SmartlingDbException('No profile found')); + ->willThrowException(new SmartlingDbException('No profile found')); $this->ftsApiWrapper->pollTranslationStatus( $submission, @@ -91,14 +84,14 @@ public function testPollTranslationStatusRequiresConfiguration(): void public function testDownloadTranslatedFileRequiresConfiguration(): void { - $this->expectException(\Smartling\Exception\SmartlingDbException::class); + $this->expectException(SmartlingDbException::class); $submission = $this->createMock(SubmissionEntity::class); $submission->method('getSourceBlogId')->willReturn(1); $this->settingsManager ->method('getSingleSettingsProfile') - ->willThrowException(new \Smartling\Exception\SmartlingDbException('No profile found')); + ->willThrowException(new SmartlingDbException('No profile found')); $this->ftsApiWrapper->downloadTranslatedFile( $submission, diff --git a/tests/Smartling/FTS/FtsServiceTest.php b/tests/Smartling/FTS/FtsServiceTest.php index b2dfc4c9..abbf1a0f 100644 --- a/tests/Smartling/FTS/FtsServiceTest.php +++ b/tests/Smartling/FTS/FtsServiceTest.php @@ -1,110 +1,59 @@ ftsApiWrapper = $this->createMock(FtsApiWrapper::class); - $this->apiWrapper = $this->createMock(ApiWrapperInterface::class); - $this->submissionManager = $this->createMock(SubmissionManager::class); - $this->contentHelper = $this->createMock(ContentHelper::class); $this->core = $this->createMock(SmartlingCore::class); - $this->settingsManager = $this->createMock(SettingsManager::class); - $this->xmlHelper = $this->createMock(XmlHelper::class); - $this->postContentHelper = $this->createMock(PostContentHelper::class); + $this->siteHelper = $this->createMock(SiteHelper::class); + $apiWrapper = $this->createMock(ApiWrapperInterface::class); + $ftsApiWrapper = $this->createMock(FtsApiWrapper::class); + $postContentHelper = $this->createMock(PostContentHelper::class); + $settingsManager = $this->createMock(SettingsManager::class); + $submissionManager = $this->createMock(SubmissionManager::class); + $xmlHelper = $this->createMock(XmlHelper::class); $this->ftsService = new FtsService( - $this->ftsApiWrapper, - $this->apiWrapper, - $this->submissionManager, - $this->contentHelper, + $apiWrapper, + $ftsApiWrapper, + $postContentHelper, + $settingsManager, + $this->siteHelper, + $submissionManager, $this->core, - $this->settingsManager, - $this->xmlHelper, - $this->postContentHelper + $xmlHelper, ); } - public function testConstructor(): void - { - $this->assertInstanceOf(FtsService::class, $this->ftsService); - } - public function testGetNextPollInterval(): void { - // Use reflection to test private method - $reflection = new \ReflectionClass($this->ftsService); - $method = $reflection->getMethod('getNextPollInterval'); - $method->setAccessible(true); - - // Test exponential backoff intervals - $this->assertEquals(1000, $method->invoke($this->ftsService, 0)); - $this->assertEquals(2000, $method->invoke($this->ftsService, 1)); - $this->assertEquals(4000, $method->invoke($this->ftsService, 2)); - $this->assertEquals(8000, $method->invoke($this->ftsService, 3)); - $this->assertEquals(16000, $method->invoke($this->ftsService, 4)); - - // Test max backoff interval (30s) for subsequent polls - $this->assertEquals(30000, $method->invoke($this->ftsService, 5)); - $this->assertEquals(30000, $method->invoke($this->ftsService, 6)); - $this->assertEquals(30000, $method->invoke($this->ftsService, 10)); - } - - public function testPollIntervalsConstants(): void - { - // Verify the polling configuration constants - $reflection = new \ReflectionClass($this->ftsService); - - $pollIntervals = $reflection->getConstant('POLL_INTERVALS'); - $this->assertIsArray($pollIntervals); - $this->assertEquals([1000, 2000, 4000, 8000, 16000], $pollIntervals); - - $maxBackoff = $reflection->getConstant('MAX_BACKOFF_INTERVAL_MS'); - $this->assertEquals(30000, $maxBackoff); - - $timeout = $reflection->getConstant('TIMEOUT_MS'); - $this->assertEquals(120000, $timeout); - } - - public function testFtsStatusConstants(): void - { - // Verify FTS status state constants exist - $reflection = new \ReflectionClass($this->ftsService); - - $this->assertEquals('QUEUED', $reflection->getConstant('STATE_QUEUED')); - $this->assertEquals('PROCESSING', $reflection->getConstant('STATE_PROCESSING')); - $this->assertEquals('COMPLETED', $reflection->getConstant('STATE_COMPLETED')); - $this->assertEquals('FAILED', $reflection->getConstant('STATE_FAILED')); - $this->assertEquals('CANCELLED', $reflection->getConstant('STATE_CANCELLED')); + $this->assertEquals(1000, $this->ftsService->getNextPollInterval(null)); + $this->assertEquals(1000, $this->ftsService->getNextPollInterval(0)); + $this->assertEquals(1000, $this->ftsService->getNextPollInterval(100)); + $this->assertEquals(2000, $this->ftsService->getNextPollInterval(1000)); + $this->assertEquals(4000, $this->ftsService->getNextPollInterval(2000)); + $this->assertEquals(8000, $this->ftsService->getNextPollInterval(4000)); + $this->assertEquals(16000, $this->ftsService->getNextPollInterval(8000)); + $this->assertEquals(30000, $this->ftsService->getNextPollInterval(16000)); + $this->assertEquals(30000, $this->ftsService->getNextPollInterval(PHP_INT_MAX)); } public function testRequestInstantTranslationHandlesException(): void @@ -115,7 +64,6 @@ public function testRequestInstantTranslationHandlesException(): void $submission->method('getSourceBlogId')->willReturn(1); $submission->method('getTargetBlogId')->willReturn(2); - // Mock SmartlingCore to throw exception during prepareUpload $this->core ->method('prepareUpload') ->willThrowException(new \Exception('Test exception')); @@ -127,4 +75,43 @@ public function testRequestInstantTranslationHandlesException(): void $this->assertEquals('error', $result['status']); $this->assertStringContainsString('Test exception', $result['message']); } + + public function testRequestInstantTranslationBatchWithEmptyArray(): void + { + $result = $this->ftsService->requestInstantTranslationBatch([]); + + $this->assertIsArray($result); + $this->assertFalse($result['success']); + $this->assertEquals('No submissions provided', $result['message']); + } + + public function testRequestInstantTranslationBatchHandlesException(): void + { + $submission1 = $this->createMock(SubmissionEntity::class); + $submission1->method('getId')->willReturn(123); + $submission1->method('getContentType')->willReturn('post'); + $submission1->method('getSourceBlogId')->willReturn(1); + $submission1->method('getTargetBlogId')->willReturn(2); + $submission1->expects($this->once())->method('setStatus')->with(SubmissionEntity::SUBMISSION_STATUS_FAILED); + $submission1->expects($this->once())->method('setLastError')->with('Test batch exception'); + + $submission2 = $this->createMock(SubmissionEntity::class); + $submission2->method('getId')->willReturn(124); + $submission2->method('getContentType')->willReturn('post'); + $submission2->method('getSourceBlogId')->willReturn(1); + $submission2->method('getTargetBlogId')->willReturn(3); + $submission2->expects($this->once())->method('setStatus')->with(SubmissionEntity::SUBMISSION_STATUS_FAILED); + $submission2->expects($this->once())->method('setLastError')->with('Test batch exception'); + + $this->core + ->method('prepareUpload') + ->willThrowException(new \Exception('Test batch exception')); + + $result = $this->ftsService->requestInstantTranslationBatch([$submission1, $submission2]); + + $this->assertIsArray($result); + $this->assertFalse($result['success']); + $this->assertEquals('error', $result['status']); + $this->assertStringContainsString('Test batch exception', $result['message']); + } } From 124150453cdffb420cd5b306b92d996d9f002918 Mon Sep 17 00:00:00 2001 From: vsolovei-smartling Date: Mon, 16 Feb 2026 12:25:34 +0100 Subject: [PATCH 3/6] use proxy instead of wp calls (WP-985) --- .../Helpers/WordpressFunctionProxyHelper.php | 5 +++++ .../InstantTranslationController.php | 22 ++++++++++--------- inc/config/services.yml | 1 + js/app.js | 1 - 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php b/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php index 51abbcfe..5f8e1302 100644 --- a/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php +++ b/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php @@ -240,6 +240,11 @@ public function wp_send_json_error() return wp_send_json_error(...func_get_args()); } + public function wp_send_json_success() + { + return wp_send_json_success(...func_get_args()); + } + public function wp_set_current_user(int $id, string $name = '') { return wp_set_current_user($id, $name); diff --git a/inc/Smartling/WP/Controller/InstantTranslationController.php b/inc/Smartling/WP/Controller/InstantTranslationController.php index d193c607..2e8a4d17 100644 --- a/inc/Smartling/WP/Controller/InstantTranslationController.php +++ b/inc/Smartling/WP/Controller/InstantTranslationController.php @@ -6,6 +6,7 @@ use Smartling\Helpers\DateTimeHelper; use Smartling\Helpers\FileUriHelper; use Smartling\Helpers\LoggerSafeTrait; +use Smartling\Helpers\WordpressFunctionProxyHelper; use Smartling\Submissions\SubmissionEntity; use Smartling\Submissions\SubmissionFactory; use Smartling\Submissions\SubmissionManager; @@ -23,6 +24,7 @@ public function __construct( private SubmissionManager $submissionManager, private SubmissionFactory $submissionFactory, private FileUriHelper $fileUriHelper, + private WordpressFunctionProxyHelper $wpProxy, ) { } @@ -41,7 +43,7 @@ public function handleRequestTranslation(): void $targetBlogIds = array_map('intval', $_POST['targetBlogIds'] ?? []); if (empty($contentType) || empty($contentId) || empty($targetBlogIds)) { - wp_send_json_error([ + $this->wpProxy->wp_send_json_error([ 'message' => 'Missing required parameters: contentType, contentId, or targetBlogIds' ], 400); } @@ -52,7 +54,7 @@ public function handleRequestTranslation(): void "targetBlogIds=" . implode(',', $targetBlogIds) . ", relatedItemsCount=$relatedCount" ); - $sourceBlogId = get_current_blog_id(); + $sourceBlogId = $this->wpProxy->get_current_blog_id(); $allSubmissions = $this->buildSubmissions( $contentType, @@ -63,7 +65,7 @@ public function handleRequestTranslation(): void ); if (empty($allSubmissions)) { - wp_send_json_error([ + $this->wpProxy->wp_send_json_error([ 'message' => 'Failed to create submissions for translation' ], 500); } @@ -101,7 +103,7 @@ public function handleRequestTranslation(): void if (!empty($allSubmissionIds)) { $uniqueSourceCount = count($submissionsBySource); - wp_send_json_success([ + $this->wpProxy->wp_send_json_success([ 'submissionIds' => $allSubmissionIds, 'message' => sprintf( 'Instant translation started for %d item(s) across %d locale(s)', @@ -110,14 +112,14 @@ public function handleRequestTranslation(): void ) ]); } else { - wp_send_json_error([ + $this->wpProxy->wp_send_json_error([ 'message' => 'Failed to start instant translation for all items' ], 500); } } catch (\Exception $e) { $this->getLogger()->error('Instant translation request failed: ' . $e->getMessage()); - wp_send_json_error([ + $this->wpProxy->wp_send_json_error([ 'message' => 'Failed to start instant translation: ' . $e->getMessage() ], 500); } @@ -129,23 +131,23 @@ public function handlePollStatus(): void $submissionId = (int)($_POST['submissionId'] ?? 0); if ($submissionId === 0) { - wp_send_json_error(['message' => 'Missing required parameter: submissionId'], 400); + $this->wpProxy->wp_send_json_error(['message' => 'Missing required parameter: submissionId'], 400); } $submission = $this->submissionManager->getEntityById($submissionId); if ($submission === null) { - wp_send_json_error(['message' => 'Submission not found'], 404); + $this->wpProxy->wp_send_json_error(['message' => 'Submission not found'], 404); } - wp_send_json_success([ + $this->wpProxy->wp_send_json_success([ 'status' => $this->mapSubmissionStatus($submission->getStatus()), 'progress' => $submission->getCompletionPercentage(), 'message' => $submission->getLastError() ?: '', ]); } catch (\Exception $e) { $this->getLogger()->error("Status poll failed, submissionId=$submissionId: " . $e->getMessage()); - wp_send_json_error(['message' => 'Failed to get translation status: ' . $e->getMessage()], 500); + $this->wpProxy->wp_send_json_error(['message' => 'Failed to get translation status: ' . $e->getMessage()], 500); } } diff --git a/inc/config/services.yml b/inc/config/services.yml index b2b2619e..aa49fd98 100644 --- a/inc/config/services.yml +++ b/inc/config/services.yml @@ -460,6 +460,7 @@ services: - "@manager.submission" - "@factory.submission" - "@file.uri.helper" + - "@wp.proxy" helper.gutenberg: class: Smartling\Helpers\GutenbergBlockHelper diff --git a/js/app.js b/js/app.js index 836ba4ce..086967e5 100644 --- a/js/app.js +++ b/js/app.js @@ -131,7 +131,6 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, const localeCount = selectedLocales.length; try { - // Single AJAX call with relations const response = await jQuery.post(ajaxUrl, { action: 'smartling_instant_translation', contentType: contentType, From d406b1ae93c44b6d277a7b495f8a325049ee412a Mon Sep 17 00:00:00 2001 From: vsolovei-smartling Date: Mon, 16 Feb 2026 18:50:52 +0100 Subject: [PATCH 4/6] add claude code review workflow, review changes (WP-985) fix exponential timeout not exponentiating add csrf protection add permission check sanitize inputs log api errors add partial success response diversify error messages add tests --- .github/workflows/claude-code-review.yml | 75 +++ inc/Smartling/FTS/FtsApiWrapper.php | 28 +- inc/Smartling/FTS/FtsService.php | 23 +- .../Helpers/WordpressFunctionProxyHelper.php | 25 + .../InstantTranslationController.php | 39 +- inc/Smartling/WP/View/BulkSubmit.php | 3 +- inc/Smartling/WP/View/ContentEditJob.php | 3 +- js/app.js | 9 +- tests/Smartling/FTS/FtsServiceTest.php | 18 + .../InstantTranslationControllerTest.php | 560 ++++++++++++++++++ 10 files changed, 760 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/claude-code-review.yml create mode 100644 tests/Smartling/WP/Controller/InstantTranslationControllerTest.php diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 00000000..1d3a6b1f --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,75 @@ +name: Claude PR Review with Progress Tracking + +# This example demonstrates how to use the track_progress feature to get +# visual progress tracking for PR reviews, similar to v0.x agent mode. + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + workflow_dispatch: + +jobs: + review-with-tracking: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 1 + + - name: Claude PR Review with Progress Tracking + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + # Enable progress tracking + track_progress: true + + # Your custom review instructions + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + Perform a comprehensive code review with the following focus areas: + + 1. **Code Quality** + - Clean code principles and best practices + - Proper error handling and edge cases + - Code readability and maintainability + + 2. **Security** + - Check for potential security vulnerabilities + - Validate input sanitization + - Review authentication/authorization logic + + 3. **Performance** + - Identify potential performance bottlenecks + - Review database queries for efficiency + - Check for memory leaks or resource issues + + 4. **Testing** + - Verify adequate test coverage + - Review test quality and edge cases + - Check for missing test scenarios + + 5. **Documentation** + - Ensure code is properly documented + - Verify README updates for new features + - Check API documentation accuracy + + Provide detailed feedback using inline comments for specific issues. + Use top-level comments for general observations or praise. + + # Tools for comprehensive PR review + claude_args: | + --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" + +# When track_progress is enabled: +# - Creates a tracking comment with progress checkboxes +# - Includes all PR context (comments, attachments, images) +# - Updates progress as the review proceeds +# - Marks as completed when done \ No newline at end of file diff --git a/inc/Smartling/FTS/FtsApiWrapper.php b/inc/Smartling/FTS/FtsApiWrapper.php index 82d54417..90ee934e 100644 --- a/inc/Smartling/FTS/FtsApiWrapper.php +++ b/inc/Smartling/FTS/FtsApiWrapper.php @@ -65,12 +65,20 @@ public function uploadFile( ): string { $this->getLogger()->info("Uploading file for instant translation, submissionId={$submission->getId()}, fileType=$fileType, fileName=$fileName"); - $fileUid = $this->getFileTranslationsApi($this->getConfigurationProfile($submission)) - ->uploadFile($filePath, $fileName, $fileType)['fileUid'] ?? null; + $response = $this->getFileTranslationsApi($this->getConfigurationProfile($submission)) + ->uploadFile($filePath, $fileName, $fileType); + + $fileUid = $response['fileUid'] ?? null; if (empty($fileUid)) { - throw new \RuntimeException('Failed to get fileUid from upload response'); + $errorMessage = $response['error'] ?? $response['message'] ?? 'Unknown error'; + $this->getLogger()->error( + "Failed to get fileUid from upload response, submissionId={$submission->getId()}, " . + "apiResponse=" . json_encode($response) + ); + throw new \RuntimeException("Failed to upload file for translation: $errorMessage"); } + return $fileUid; } @@ -90,10 +98,18 @@ public function submitForInstantTranslation( $this->getLogger()->info("Submitting file for instant translation, submissionId={$submission->getId()}, fileUid=$fileUid, sourceLocaleId=$sourceLocaleId, targetLocaleIds=" . implode(',', $targetLocaleIds)); - $mtUid = $this->getFileTranslationsApi($this->getConfigurationProfile($submission)) - ->translateFile($fileUid, $params)['mtUid'] ?? null; + $response = $this->getFileTranslationsApi($this->getConfigurationProfile($submission)) + ->translateFile($fileUid, $params); + + $mtUid = $response['mtUid'] ?? null; + if (empty($mtUid)) { - throw new \RuntimeException('Failed to get mtUid from translation response'); + $errorMessage = $response['error'] ?? $response['message'] ?? 'Unknown error'; + $this->getLogger()->error( + "Failed to get mtUid from translation response, submissionId={$submission->getId()}, fileUid=$fileUid, " . + "apiResponse=" . json_encode($response) + ); + throw new \RuntimeException("Failed to submit file for translation: $errorMessage"); } return $mtUid; diff --git a/inc/Smartling/FTS/FtsService.php b/inc/Smartling/FTS/FtsService.php index 4973afbd..13245f97 100644 --- a/inc/Smartling/FTS/FtsService.php +++ b/inc/Smartling/FTS/FtsService.php @@ -141,11 +141,19 @@ public function requestInstantTranslationBatch(array $submissions): array $pollResult = $this->pollUntilComplete($firstSubmission, $fileUid, $mtUid); if ($pollResult['status'] === self::STATE_COMPLETED) { + $succeededSubmissions = []; + $failedSubmissions = []; + foreach ($submissions as $submission) { try { $this->downloadAndApply($submission, $fileUid, $mtUid); + $succeededSubmissions[] = $submission->getId(); $this->getLogger()->info("Translation applied for submission {$submission->getId()}"); } catch (\Exception $e) { + $failedSubmissions[] = [ + 'id' => $submission->getId(), + 'error' => $e->getMessage() + ]; $this->getLogger()->error( "Failed to apply translation for submission {$submission->getId()}: {$e->getMessage()}" ); @@ -155,13 +163,18 @@ public function requestInstantTranslationBatch(array $submissions): array } } + $allSucceeded = empty($failedSubmissions); + $this->getLogger()->info( - "Batch instant translation completed, submissionIds=" . implode(',', $submissionIds) + "Batch instant translation completed, succeeded=" . count($succeededSubmissions) . + ", failed=" . count($failedSubmissions) . ", submissionIds=" . implode(',', $submissionIds) ); return [ - 'success' => true, - 'status' => self::STATE_COMPLETED, + 'success' => $allSucceeded, + 'status' => $allSucceeded ? self::STATE_COMPLETED : 'partial_success', + 'succeeded' => $succeededSubmissions, + 'failed' => $failedSubmissions, 'fileUid' => $fileUid, 'mtUid' => $mtUid, ]; @@ -267,6 +280,7 @@ private function submitFile(SubmissionEntity $submission, string $fileUid): stri private function pollUntilComplete(SubmissionEntity $submission, string $fileUid, string $mtUid): array { $startTime = microtime(true); + $waitMs = null; $this->getLogger()->debug("Starting polling for instant translation, submissionId={$submission->getId()}, fileUid=$fileUid, mtUid=$mtUid"); @@ -286,7 +300,6 @@ private function pollUntilComplete(SubmissionEntity $submission, string $fileUid $state = $response['state'] ?? ''; $this->getLogger()->debug("Polled translation status, submissionId={$submission->getId()}, fileUid=$fileUid, mtUid=$mtUid, state=$state"); - $waitMs = null; switch ($state) { case self::STATE_COMPLETED: @@ -295,7 +308,7 @@ private function pollUntilComplete(SubmissionEntity $submission, string $fileUid 'data' => $response, ]; case self::STATE_FAILED: - $error = $data['error'] ?? 'Translation request failed'; + $error = $response['error'] ?? 'Translation request failed'; return [ 'status' => self::STATE_FAILED, 'message' => is_array($error) ? ($error['message'] ?? 'Unknown error') : $error, diff --git a/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php b/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php index 5f8e1302..41408763 100644 --- a/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php +++ b/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php @@ -245,6 +245,31 @@ public function wp_send_json_success() return wp_send_json_success(...func_get_args()); } + public function sanitize_text_field() + { + return sanitize_text_field(...func_get_args()); + } + + public function wp_unslash() + { + return wp_unslash(...func_get_args()); + } + + public function map_deep() + { + return map_deep(...func_get_args()); + } + + public function check_ajax_referer() + { + return check_ajax_referer(...func_get_args()); + } + + public function current_user_can() + { + return current_user_can(...func_get_args()); + } + public function wp_set_current_user(int $id, string $name = '') { return wp_set_current_user($id, $name); diff --git a/inc/Smartling/WP/Controller/InstantTranslationController.php b/inc/Smartling/WP/Controller/InstantTranslationController.php index 2e8a4d17..bbe50d73 100644 --- a/inc/Smartling/WP/Controller/InstantTranslationController.php +++ b/inc/Smartling/WP/Controller/InstantTranslationController.php @@ -30,22 +30,32 @@ public function __construct( public function register(): void { - add_action('wp_ajax_' . self::ACTION_REQUEST_TRANSLATION, [$this, 'handleRequestTranslation']); - add_action('wp_ajax_' . self::ACTION_POLL_STATUS, [$this, 'handlePollStatus']); + $this->wpProxy->add_action('wp_ajax_' . self::ACTION_REQUEST_TRANSLATION, [$this, 'handleRequestTranslation']); + $this->wpProxy->add_action('wp_ajax_' . self::ACTION_POLL_STATUS, [$this, 'handlePollStatus']); } public function handleRequestTranslation(): void { + $this->wpProxy->check_ajax_referer('smartling_instant_translation', '_wpnonce'); + + if (!$this->wpProxy->current_user_can('publish_posts')) { + $this->wpProxy->wp_send_json_error([ + 'message' => 'Insufficient permissions' + ], 403); + return; + } + try { - $contentType = $_POST['contentType'] ?? ''; + $contentType = $this->wpProxy->sanitize_text_field($this->wpProxy->wp_unslash($_POST['contentType'] ?? '')); $contentId = (int)($_POST['contentId'] ?? 0); - $relations = $_POST['relations'] ?? []; + $relations = $this->wpProxy->map_deep($this->wpProxy->wp_unslash($_POST['relations'] ?? []), 'sanitize_text_field'); $targetBlogIds = array_map('intval', $_POST['targetBlogIds'] ?? []); if (empty($contentType) || empty($contentId) || empty($targetBlogIds)) { $this->wpProxy->wp_send_json_error([ 'message' => 'Missing required parameters: contentType, contentId, or targetBlogIds' ], 400); + return; } $relatedCount = $this->countRelatedItems($relations); @@ -68,6 +78,7 @@ public function handleRequestTranslation(): void $this->wpProxy->wp_send_json_error([ 'message' => 'Failed to create submissions for translation' ], 500); + return; } $submissionsBySource = []; @@ -111,10 +122,12 @@ public function handleRequestTranslation(): void count($targetBlogIds), ) ]); + return; } else { $this->wpProxy->wp_send_json_error([ 'message' => 'Failed to start instant translation for all items' ], 500); + return; } } catch (\Exception $e) { $this->getLogger()->error('Instant translation request failed: ' . $e->getMessage()); @@ -127,17 +140,28 @@ public function handleRequestTranslation(): void public function handlePollStatus(): void { + $this->wpProxy->check_ajax_referer('smartling_instant_translation', '_wpnonce'); + + if (!$this->wpProxy->current_user_can('publish_posts')) { + $this->wpProxy->wp_send_json_error([ + 'message' => 'Insufficient permissions' + ], 403); + return; + } + try { $submissionId = (int)($_POST['submissionId'] ?? 0); - if ($submissionId === 0) { - $this->wpProxy->wp_send_json_error(['message' => 'Missing required parameter: submissionId'], 400); + if ($submissionId <= 0) { + $this->wpProxy->wp_send_json_error(['message' => 'Invalid submission ID'], 400); + return; } $submission = $this->submissionManager->getEntityById($submissionId); if ($submission === null) { $this->wpProxy->wp_send_json_error(['message' => 'Submission not found'], 404); + return; } $this->wpProxy->wp_send_json_success([ @@ -145,8 +169,9 @@ public function handlePollStatus(): void 'progress' => $submission->getCompletionPercentage(), 'message' => $submission->getLastError() ?: '', ]); + return; } catch (\Exception $e) { - $this->getLogger()->error("Status poll failed, submissionId=$submissionId: " . $e->getMessage()); + $this->getLogger()->error("Status poll failed: " . $e->getMessage()); $this->wpProxy->wp_send_json_error(['message' => 'Failed to get translation status: ' . $e->getMessage()], 500); } } diff --git a/inc/Smartling/WP/View/BulkSubmit.php b/inc/Smartling/WP/View/BulkSubmit.php index 2477ad92..4dce0ee8 100644 --- a/inc/Smartling/WP/View/BulkSubmit.php +++ b/inc/Smartling/WP/View/BulkSubmit.php @@ -69,7 +69,8 @@ data-content-id="0" data-locales='' data-ajax-url="" - data-admin-url=""> + data-admin-url="" + data-nonce=""> diff --git a/js/app.js b/js/app.js index 086967e5..7464a168 100644 --- a/js/app.js +++ b/js/app.js @@ -1,7 +1,7 @@ const { render, createElement: el, useState, useEffect, useCallback } = wp.element; const { Button, Card, CardBody, CardHeader, TabPanel, TextControl, TextareaControl, CheckboxControl, SelectControl, Spinner, Notice, Flex, __experimentalVStack: VStack } = wp.components; -function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, adminUrl }) { +function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, adminUrl, nonce }) { const [activeTab, setActiveTab] = useState('new'); const [jobs, setJobs] = useState([]); const [selectedJob, setSelectedJob] = useState(''); @@ -133,6 +133,7 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, try { const response = await jQuery.post(ajaxUrl, { action: 'smartling_instant_translation', + _wpnonce: nonce, contentType: contentType, contentId: contentId, targetBlogIds: selectedLocales, @@ -181,13 +182,14 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, const statusPromises = submissionIds.map(submissionId => jQuery.post(ajaxUrl, { action: 'smartling_instant_translation_status', + _wpnonce: nonce, submissionId: submissionId }) ); const responses = await Promise.all(statusPromises); - responses.forEach((response, index) => { + responses.forEach((response) => { if (response.success && response.data) { const status = response.data.status; @@ -487,9 +489,10 @@ if (document.getElementById('smartling-app')) { const locales = JSON.parse(container.dataset.locales || '[]'); const ajaxUrl = container.dataset.ajaxUrl || ''; const adminUrl = container.dataset.adminUrl || ''; + const nonce = container.dataset.nonce || ''; render( - el(JobWizard, { isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, adminUrl }), + el(JobWizard, { isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, adminUrl, nonce }), container ); } diff --git a/tests/Smartling/FTS/FtsServiceTest.php b/tests/Smartling/FTS/FtsServiceTest.php index abbf1a0f..fad29a3c 100644 --- a/tests/Smartling/FTS/FtsServiceTest.php +++ b/tests/Smartling/FTS/FtsServiceTest.php @@ -114,4 +114,22 @@ public function testRequestInstantTranslationBatchHandlesException(): void $this->assertEquals('error', $result['status']); $this->assertStringContainsString('Test batch exception', $result['message']); } + + public function testRequestInstantTranslationBatchRejectsMixedSources(): void + { + $submission1 = $this->createMock(SubmissionEntity::class); + $submission1->method('getId')->willReturn(123); + $submission1->method('getSourceId')->willReturn(100); + + $submission2 = $this->createMock(SubmissionEntity::class); + $submission2->method('getId')->willReturn(124); + $submission2->method('getSourceId')->willReturn(200); // Different source + + $result = $this->ftsService->requestInstantTranslationBatch([$submission1, $submission2]); + + $this->assertIsArray($result); + $this->assertFalse($result['success']); + $this->assertEquals('Same source submissions expected', $result['message']); + } + } diff --git a/tests/Smartling/WP/Controller/InstantTranslationControllerTest.php b/tests/Smartling/WP/Controller/InstantTranslationControllerTest.php new file mode 100644 index 00000000..5c7ffb8c --- /dev/null +++ b/tests/Smartling/WP/Controller/InstantTranslationControllerTest.php @@ -0,0 +1,560 @@ +ftsService = $this->createMock(FtsService::class); + $this->submissionManager = $this->createMock(SubmissionManager::class); + $this->submissionFactory = $this->createMock(SubmissionFactory::class); + $this->fileUriHelper = $this->createMock(FileUriHelper::class); + $this->wpProxy = $this->createMock(WordpressFunctionProxyHelper::class); + + $this->controller = new InstantTranslationController( + $this->ftsService, + $this->submissionManager, + $this->submissionFactory, + $this->fileUriHelper, + $this->wpProxy + ); + } + + public function testCountRelatedItemsWithEmptyRelations(): void + { + $reflection = new \ReflectionClass($this->controller); + $method = $reflection->getMethod('countRelatedItems'); + $method->setAccessible(true); + + $result = $method->invoke($this->controller, []); + $this->assertEquals(0, $result); + } + + public function testCountRelatedItemsWithSingleTarget(): void + { + $reflection = new \ReflectionClass($this->controller); + $method = $reflection->getMethod('countRelatedItems'); + $method->setAccessible(true); + + $relations = [ + 2 => [ + 'attachment' => [1, 2], + 'category' => [5] + ] + ]; + + $result = $method->invoke($this->controller, $relations); + $this->assertEquals(3, $result); // 2 attachments + 1 category + } + + public function testCountRelatedItemsWithMultipleTargets(): void + { + $reflection = new \ReflectionClass($this->controller); + $method = $reflection->getMethod('countRelatedItems'); + $method->setAccessible(true); + + $relations = [ + 2 => [ + 'attachment' => [1, 2], + 'category' => [5] + ], + 3 => [ + 'attachment' => [1, 2], // Same items in different target + 'tag' => [10] + ] + ]; + + // Should deduplicate - unique items are: attachment:1, attachment:2, category:5, tag:10 + $result = $method->invoke($this->controller, $relations); + $this->assertEquals(4, $result); + } + + public function testGetRelatedSourcesExcludesMainContent(): void + { + $reflection = new \ReflectionClass($this->controller); + $method = $reflection->getMethod('getRelatedSources'); + $method->setAccessible(true); + + $relations = [ + 2 => [ + 'post' => [123, 456], // 123 is main content + 'attachment' => [1] + ] + ]; + + $result = $method->invoke($this->controller, $relations, 2, 'post', 123); + + $this->assertCount(2, $result); + $this->assertEquals(['id' => 456, 'type' => 'post'], $result[0]); + $this->assertEquals(['id' => 1, 'type' => 'attachment'], $result[1]); + } + + public function testGetRelatedSourcesWithNoMatchingTarget(): void + { + $reflection = new \ReflectionClass($this->controller); + $method = $reflection->getMethod('getRelatedSources'); + $method->setAccessible(true); + + $relations = [ + 2 => [ + 'post' => [123], + ] + ]; + + // Request for target blog 3, which doesn't exist in relations + $result = $method->invoke($this->controller, $relations, 3, 'post', 123); + + $this->assertCount(0, $result); + } + + public function testGetOrCreateSubmissionCreatesNew(): void + { + $reflection = new \ReflectionClass($this->controller); + $method = $reflection->getMethod('getOrCreateSubmission'); + $method->setAccessible(true); + + // No existing submission found + $this->submissionManager->method('findOne')->willReturn(null); + + // Factory should create new submission + $newSubmission = $this->createMock(SubmissionEntity::class); + $newSubmission->method('setFileUri')->willReturnSelf(); + $newSubmission->method('setStatus')->willReturnSelf(); + + $this->submissionFactory->method('fromArray')->willReturn($newSubmission); + $this->fileUriHelper->method('generateFileUri')->willReturn('file://test.xml'); + + // Manager should store it + $this->submissionManager->method('storeEntity')->willReturn($newSubmission); + + $result = $method->invoke($this->controller, 1, 2, 'post', 123); + + $this->assertInstanceOf(SubmissionEntity::class, $result); + } + + public function testGetOrCreateSubmissionReusesExisting(): void + { + $reflection = new \ReflectionClass($this->controller); + $method = $reflection->getMethod('getOrCreateSubmission'); + $method->setAccessible(true); + + // Existing submission found + $existingSubmission = $this->createMock(SubmissionEntity::class); + $existingSubmission->method('setStatus')->willReturnSelf(); + + $this->submissionManager->method('findOne')->willReturn($existingSubmission); + $this->submissionManager->method('storeEntity')->willReturn($existingSubmission); + + $result = $method->invoke($this->controller, 1, 2, 'post', 123); + + $this->assertInstanceOf(SubmissionEntity::class, $result); + $this->assertSame($existingSubmission, $result); + } + + public function testBuildSubmissionsWithNoRelations(): void + { + $reflection = new \ReflectionClass($this->controller); + $method = $reflection->getMethod('buildSubmissions'); + $method->setAccessible(true); + + // Setup mocks for main content only + $this->submissionManager->method('findOne')->willReturn(null); + + $mainSubmission = $this->createMock(SubmissionEntity::class); + $mainSubmission->method('setFileUri')->willReturnSelf(); + $mainSubmission->method('setStatus')->willReturnSelf(); + + $this->submissionFactory->method('fromArray')->willReturn($mainSubmission); + $this->fileUriHelper->method('generateFileUri')->willReturn('file://test.xml'); + $this->submissionManager->method('storeEntity')->willReturn($mainSubmission); + + $result = $method->invoke( + $this->controller, + 'post', + 123, + 1, + [2, 3], + [] // No relations + ); + + // Should create 2 submissions (1 main content × 2 target blogs) + $this->assertCount(2, $result); + } + + public function testBuildSubmissionsWithRelations(): void + { + $reflection = new \ReflectionClass($this->controller); + $method = $reflection->getMethod('buildSubmissions'); + $method->setAccessible(true); + + // Setup mocks + $this->submissionManager->method('findOne')->willReturn(null); + + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('setFileUri')->willReturnSelf(); + $submission->method('setStatus')->willReturnSelf(); + + $this->submissionFactory->method('fromArray')->willReturn($submission); + $this->fileUriHelper->method('generateFileUri')->willReturn('file://test.xml'); + $this->submissionManager->method('storeEntity')->willReturn($submission); + + $relations = [ + 2 => [ + 'attachment' => [1, 2] + ], + 3 => [ + 'attachment' => [1] + ] + ]; + + $result = $method->invoke( + $this->controller, + 'post', + 123, + 1, + [2, 3], + $relations + ); + + // Should create: + // - Blog 2: 1 main + 2 attachments = 3 submissions + // - Blog 3: 1 main + 1 attachment = 2 submissions + // Total: 5 submissions + $this->assertCount(5, $result); + } + + public function testBuildSubmissionsExcludesMainContentFromRelations(): void + { + $reflection = new \ReflectionClass($this->controller); + $method = $reflection->getMethod('buildSubmissions'); + $method->setAccessible(true); + + // Setup mocks + $this->submissionManager->method('findOne')->willReturn(null); + + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('setFileUri')->willReturnSelf(); + $submission->method('setStatus')->willReturnSelf(); + + $this->submissionFactory->method('fromArray')->willReturn($submission); + $this->fileUriHelper->method('generateFileUri')->willReturn('file://test.xml'); + $this->submissionManager->method('storeEntity')->willReturn($submission); + + $relations = [ + 2 => [ + 'post' => [123, 456], // 123 is the main content, should be excluded from relations + 'attachment' => [1] + ] + ]; + + $result = $method->invoke( + $this->controller, + 'post', + 123, + 1, + [2], + $relations + ); + + // Should create: + // - 1 main (post 123) + // - 1 related post (456) + // - 1 attachment (1) + // Total: 3 submissions (NOT 4, since post 123 shouldn't be duplicated) + $this->assertCount(3, $result); + } + + public function testHandleRequestTranslationWithInsufficientPermissions(): void + { + // Simulate AJAX environment + $_POST = [ + 'contentType' => 'post', + 'contentId' => 123, + 'targetBlogIds' => [2, 3], + 'relations' => [], + ]; + + // Mock nonce check passes + $this->wpProxy->method('check_ajax_referer')->willReturn(true); + + // Mock permission check fails + $this->wpProxy->method('current_user_can')->with('publish_posts')->willReturn(false); + + // Expect error response + $this->wpProxy->expects($this->once()) + ->method('wp_send_json_error') + ->with( + $this->callback(function ($data) { + return $data['message'] === 'Insufficient permissions'; + }), + 403 + ); + + $this->controller->handleRequestTranslation(); + } + + public function testHandleRequestTranslationWithMissingParameters(): void + { + // Simulate AJAX environment with missing contentType + $_POST = [ + 'contentId' => 123, + 'targetBlogIds' => [2, 3], + ]; + + // Mock nonce and permission checks pass + $this->wpProxy->method('check_ajax_referer')->willReturn(true); + $this->wpProxy->method('current_user_can')->willReturn(true); + $this->wpProxy->method('sanitize_text_field')->willReturn(''); + $this->wpProxy->method('wp_unslash')->willReturnArgument(0); + $this->wpProxy->method('map_deep')->willReturnArgument(0); + + // Expect error response + $this->wpProxy->expects($this->once()) + ->method('wp_send_json_error') + ->with( + $this->callback(function ($data) { + return $data['message'] === 'Missing required parameters: contentType, contentId, or targetBlogIds'; + }), + 400 + ); + + $this->controller->handleRequestTranslation(); + } + + public function testHandleRequestTranslationWithEmptyTargetBlogIds(): void + { + // Simulate AJAX environment with empty targetBlogIds + $_POST = [ + 'contentType' => 'post', + 'contentId' => 123, + 'targetBlogIds' => [], + ]; + + // Mock nonce and permission checks pass + $this->wpProxy->method('check_ajax_referer')->willReturn(true); + $this->wpProxy->method('current_user_can')->willReturn(true); + $this->wpProxy->method('sanitize_text_field')->willReturn('post'); + $this->wpProxy->method('wp_unslash')->willReturnArgument(0); + $this->wpProxy->method('map_deep')->willReturnArgument(0); + + // Expect error response + $this->wpProxy->expects($this->once()) + ->method('wp_send_json_error') + ->with( + $this->callback(function ($data) { + return $data['message'] === 'Missing required parameters: contentType, contentId, or targetBlogIds'; + }), + 400 + ); + + $this->controller->handleRequestTranslation(); + } + + public function testHandleRequestTranslationWithFailedSubmissionCreation(): void + { + // Simulate AJAX environment + $_POST = [ + 'contentType' => 'post', + 'contentId' => 123, + 'targetBlogIds' => [2], + 'relations' => [], + ]; + + // Mock nonce and permission checks pass + $this->wpProxy->method('check_ajax_referer')->willReturn(true); + $this->wpProxy->method('current_user_can')->willReturn(true); + $this->wpProxy->method('sanitize_text_field')->willReturn('post'); + $this->wpProxy->method('wp_unslash')->willReturnArgument(0); + $this->wpProxy->method('map_deep')->willReturnArgument(0); + $this->wpProxy->method('get_current_blog_id')->willReturn(1); + + // Mock submission creation failure (throws exception) + $this->submissionManager->method('findOne')->willReturn(null); + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('setFileUri')->willReturnSelf(); + $submission->method('setStatus')->willReturnSelf(); + $this->submissionFactory->method('fromArray')->willReturn($submission); + $this->fileUriHelper->method('generateFileUri')->willReturn('test.xml'); + $this->submissionManager->method('storeEntity')->willThrowException(new \Exception('Database error')); + + // Expect error response for failed submission creation + $this->wpProxy->expects($this->once()) + ->method('wp_send_json_error') + ->with( + $this->callback(function ($data) { + return $data['message'] === 'Failed to create submissions for translation'; + }), + 500 + ); + + $this->controller->handleRequestTranslation(); + } + + public function testHandlePollStatusWithInsufficientPermissions(): void + { + // Simulate AJAX environment + $_POST = ['submissionId' => 123]; + + // Mock nonce check passes + $this->wpProxy->method('check_ajax_referer')->willReturn(true); + + // Mock permission check fails + $this->wpProxy->method('current_user_can')->with('publish_posts')->willReturn(false); + + // Expect error response + $this->wpProxy->expects($this->once()) + ->method('wp_send_json_error') + ->with( + $this->callback(function ($data) { + return $data['message'] === 'Insufficient permissions'; + }), + 403 + ); + + $this->controller->handlePollStatus(); + } + + public function testHandlePollStatusWithInvalidSubmissionId(): void + { + // Simulate AJAX environment with invalid submission ID + $_POST = ['submissionId' => 0]; + + // Mock nonce and permission checks pass + $this->wpProxy->method('check_ajax_referer')->willReturn(true); + $this->wpProxy->method('current_user_can')->willReturn(true); + + // Expect error response + $this->wpProxy->expects($this->once()) + ->method('wp_send_json_error') + ->with( + $this->callback(function ($data) { + return $data['message'] === 'Invalid submission ID'; + }), + 400 + ); + + $this->controller->handlePollStatus(); + } + + public function testHandlePollStatusWithNegativeSubmissionId(): void + { + // Simulate AJAX environment with negative submission ID + $_POST = ['submissionId' => -5]; + + // Mock nonce and permission checks pass + $this->wpProxy->method('check_ajax_referer')->willReturn(true); + $this->wpProxy->method('current_user_can')->willReturn(true); + + // Expect error response + $this->wpProxy->expects($this->once()) + ->method('wp_send_json_error') + ->with( + $this->callback(function ($data) { + return $data['message'] === 'Invalid submission ID'; + }), + 400 + ); + + $this->controller->handlePollStatus(); + } + + public function testHandlePollStatusWithNotFoundSubmission(): void + { + // Simulate AJAX environment + $_POST = ['submissionId' => 999]; + + // Mock nonce and permission checks pass + $this->wpProxy->method('check_ajax_referer')->willReturn(true); + $this->wpProxy->method('current_user_can')->willReturn(true); + + // Mock submission not found + $this->submissionManager->method('getEntityById')->with(999)->willReturn(null); + + // Expect error response + $this->wpProxy->expects($this->once()) + ->method('wp_send_json_error') + ->with( + $this->callback(function ($data) { + return $data['message'] === 'Submission not found'; + }), + 404 + ); + + $this->controller->handlePollStatus(); + } + + public function testHandlePollStatusReturnsCorrectFormat(): void + { + // Simulate AJAX environment + $_POST = ['submissionId' => 123]; + + // Mock nonce and permission checks pass + $this->wpProxy->method('check_ajax_referer')->willReturn(true); + $this->wpProxy->method('current_user_can')->willReturn(true); + + // Mock submission found + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getStatus')->willReturn(SubmissionEntity::SUBMISSION_STATUS_COMPLETED); + $submission->method('getCompletionPercentage')->willReturn(100); + $submission->method('getLastError')->willReturn(''); + + $this->submissionManager->method('getEntityById')->with(123)->willReturn($submission); + + // Expect success response with correct format + $this->wpProxy->expects($this->once()) + ->method('wp_send_json_success') + ->with($this->callback(function ($data) { + return isset($data['status']) && + isset($data['progress']) && + isset($data['message']) && + $data['status'] === 'completed' && + $data['progress'] === 100; + })); + + $this->controller->handlePollStatus(); + } + + public function testMapSubmissionStatusReturnsCorrectValues(): void + { + $reflection = new \ReflectionClass($this->controller); + $method = $reflection->getMethod('mapSubmissionStatus'); + $method->setAccessible(true); + + $this->assertEquals('completed', $method->invoke($this->controller, SubmissionEntity::SUBMISSION_STATUS_COMPLETED)); + $this->assertEquals('failed', $method->invoke($this->controller, SubmissionEntity::SUBMISSION_STATUS_FAILED)); + $this->assertEquals('failed', $method->invoke($this->controller, SubmissionEntity::SUBMISSION_STATUS_CANCELLED)); + $this->assertEquals('in_progress', $method->invoke($this->controller, SubmissionEntity::SUBMISSION_STATUS_IN_PROGRESS)); + $this->assertEquals('pending', $method->invoke($this->controller, SubmissionEntity::SUBMISSION_STATUS_NEW)); + $this->assertEquals('pending', $method->invoke($this->controller, 'unknown_status')); + } + + public function testRegisterCallsAddActionForBothEndpoints(): void + { + $this->wpProxy->expects($this->exactly(2)) + ->method('add_action') + ->withConsecutive( + ['wp_ajax_smartling_instant_translation', [$this->controller, 'handleRequestTranslation']], + ['wp_ajax_smartling_instant_translation_status', [$this->controller, 'handlePollStatus']] + ); + + $this->controller->register(); + } +} From 2ef736afc78e9ea6cd8aba1b4202d977516e8849 Mon Sep 17 00:00:00 2001 From: vsolovei-smartling Date: Mon, 16 Feb 2026 23:22:33 +0100 Subject: [PATCH 5/6] remove progress, change polling (WP-985) --- .../Base/SmartlingCoreUploadTrait.php | 2 +- inc/Smartling/FTS/FtsService.php | 150 +++++++---- .../InstantTranslationController.php | 37 ++- tests/Smartling/FTS/FtsServiceTest.php | 249 +++++++++++++++++- 4 files changed, 364 insertions(+), 74 deletions(-) diff --git a/inc/Smartling/Base/SmartlingCoreUploadTrait.php b/inc/Smartling/Base/SmartlingCoreUploadTrait.php index 0902bf0e..86d68534 100644 --- a/inc/Smartling/Base/SmartlingCoreUploadTrait.php +++ b/inc/Smartling/Base/SmartlingCoreUploadTrait.php @@ -34,7 +34,7 @@ trait SmartlingCoreUploadTrait { - private function renewContentHash(SubmissionEntity $submission): SubmissionEntity + public function renewContentHash(SubmissionEntity $submission): SubmissionEntity { $content = $this->getContentHelper()->readSourceContent($submission); $newHash = $this->getContentSerializationHelper()->calculateHash($submission); diff --git a/inc/Smartling/FTS/FtsService.php b/inc/Smartling/FTS/FtsService.php index 13245f97..5b6f9b2d 100644 --- a/inc/Smartling/FTS/FtsService.php +++ b/inc/Smartling/FTS/FtsService.php @@ -133,81 +133,132 @@ public function requestInstantTranslationBatch(array $submissions): array $targetLocales, ); + foreach ($submissions as $submission) { + $submission->setFileUri("$fileUid:$mtUid"); + $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_IN_PROGRESS); + $this->submissionManager->storeEntity($submission); + } + $this->getLogger()->info( - "Batch translation request created, fileUid=$fileUid, mtUid=$mtUid, sourceLocale=$sourceLocale, targetLocales=" . - implode(',', $targetLocales) + "Batch translation request created and stored, fileUid=$fileUid, mtUid=$mtUid, sourceLocale=$sourceLocale, " . + "targetLocales=" . implode(',', $targetLocales) . ", submissionIds=" . implode(',', $submissionIds) ); - $pollResult = $this->pollUntilComplete($firstSubmission, $fileUid, $mtUid); + return [ + 'success' => true, + 'status' => 'submitted', + 'fileUid' => $fileUid, + 'mtUid' => $mtUid, + 'submissionIds' => $submissionIds, + ]; - if ($pollResult['status'] === self::STATE_COMPLETED) { - $succeededSubmissions = []; - $failedSubmissions = []; + } catch (\Exception $e) { + $this->getLogger()->error( + "Batch instant translation failed with exception, submissionIds=" . implode(',', $submissionIds) . + ", message={$e->getMessage()}" + ); - foreach ($submissions as $submission) { + foreach ($submissions as $submission) { + $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_FAILED); + $submission->setLastError($e->getMessage()); + $this->submissionManager->storeEntity($submission); + } + + return [ + 'success' => false, + 'status' => 'error', + 'message' => $e->getMessage(), + ]; + } + } + + public function checkAndApplyTranslation(SubmissionEntity $submission): array + { + $fileUri = $submission->getFileUri(); + if (empty($fileUri) || !str_contains($fileUri, ':')) { + $this->getLogger()->error("Invalid or missing fileUri for submission {$submission->getId()}, fileUri=$fileUri"); + return [ + 'status' => 'error', + 'message' => 'Missing translation metadata', + ]; + } + + [$fileUid, $mtUid] = explode(':', $fileUri, 2); + + $this->getLogger()->debug( + "Checking translation status, submissionId={$submission->getId()}, fileUid=$fileUid, mtUid=$mtUid" + ); + + try { + $response = $this->ftsApiWrapper->pollTranslationStatus($submission, $fileUid, $mtUid); + $state = $response['state'] ?? ''; + + $this->getLogger()->debug( + "Translation status checked, submissionId={$submission->getId()}, state=$state" + ); + + switch ($state) { + case self::STATE_COMPLETED: try { $this->downloadAndApply($submission, $fileUid, $mtUid); - $succeededSubmissions[] = $submission->getId(); - $this->getLogger()->info("Translation applied for submission {$submission->getId()}"); - } catch (\Exception $e) { - $failedSubmissions[] = [ - 'id' => $submission->getId(), - 'error' => $e->getMessage() + + return [ + 'status' => 'completed', ]; + } catch (\Exception $e) { $this->getLogger()->error( "Failed to apply translation for submission {$submission->getId()}: {$e->getMessage()}" ); $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_FAILED); $submission->setLastError($e->getMessage()); $this->submissionManager->storeEntity($submission); + + return [ + 'status' => 'failed', + 'message' => $e->getMessage(), + ]; } - } - $allSucceeded = empty($failedSubmissions); + case self::STATE_FAILED: + $error = $response['error'] ?? 'Translation request failed'; + $errorMessage = is_array($error) ? ($error['message'] ?? 'Unknown error') : $error; - $this->getLogger()->info( - "Batch instant translation completed, succeeded=" . count($succeededSubmissions) . - ", failed=" . count($failedSubmissions) . ", submissionIds=" . implode(',', $submissionIds) - ); + $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_FAILED); + $submission->setLastError($errorMessage); + $this->submissionManager->storeEntity($submission); - return [ - 'success' => $allSucceeded, - 'status' => $allSucceeded ? self::STATE_COMPLETED : 'partial_success', - 'succeeded' => $succeededSubmissions, - 'failed' => $failedSubmissions, - 'fileUid' => $fileUid, - 'mtUid' => $mtUid, - ]; - } + $this->getLogger()->error("Translation failed, submissionId={$submission->getId()}, error=$errorMessage"); - foreach ($submissions as $submission) { - $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_FAILED); - $submission->setLastError($pollResult['message'] ?? 'Translation failed'); - $this->submissionManager->storeEntity($submission); - } + return [ + 'status' => 'failed', + 'message' => $errorMessage, + ]; - return [ - 'success' => false, - 'status' => $pollResult['status'], - 'message' => $pollResult['message'], - 'fileUid' => $fileUid, - 'mtUid' => $mtUid, - ]; + case self::STATE_CANCELLED: + $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_CANCELLED); + $submission->setLastError('Translation request was cancelled'); + $this->submissionManager->storeEntity($submission); + + $this->getLogger()->info("Translation cancelled, submissionId={$submission->getId()}"); + return [ + 'status' => 'failed', + 'message' => 'Translation request was cancelled', + ]; + + case self::STATE_PROCESSING: + case self::STATE_QUEUED: + default: + return [ + 'status' => 'in_progress', + ]; + } } catch (\Exception $e) { $this->getLogger()->error( - "Batch instant translation failed with exception, submissionIds=" . implode(',', $submissionIds) . - ", message={$e->getMessage()}" + "Failed to check translation status for submission {$submission->getId()}: {$e->getMessage()}" ); - foreach ($submissions as $submission) { - $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_FAILED); - $submission->setLastError($e->getMessage()); - $this->submissionManager->storeEntity($submission); - } - return [ - 'success' => false, 'status' => 'error', 'message' => $e->getMessage(), ]; @@ -363,6 +414,7 @@ private function downloadAndApply(SubmissionEntity $submission, string $fileUid, $this->core->applyXML($submission, $translatedXml, $this->xmlHelper, $this->postContentHelper); + $this->core->renewContentHash($submission); $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_COMPLETED); $submission->setCompletedStringCount($submission->getWordCount()); $submission->setAppliedDate(DateTimeHelper::nowAsString()); diff --git a/inc/Smartling/WP/Controller/InstantTranslationController.php b/inc/Smartling/WP/Controller/InstantTranslationController.php index bbe50d73..25c2ba90 100644 --- a/inc/Smartling/WP/Controller/InstantTranslationController.php +++ b/inc/Smartling/WP/Controller/InstantTranslationController.php @@ -38,13 +38,6 @@ public function handleRequestTranslation(): void { $this->wpProxy->check_ajax_referer('smartling_instant_translation', '_wpnonce'); - if (!$this->wpProxy->current_user_can('publish_posts')) { - $this->wpProxy->wp_send_json_error([ - 'message' => 'Insufficient permissions' - ], 403); - return; - } - try { $contentType = $this->wpProxy->sanitize_text_field($this->wpProxy->wp_unslash($_POST['contentType'] ?? '')); $contentId = (int)($_POST['contentId'] ?? 0); @@ -123,12 +116,12 @@ public function handleRequestTranslation(): void ) ]); return; - } else { - $this->wpProxy->wp_send_json_error([ - 'message' => 'Failed to start instant translation for all items' - ], 500); - return; } + + $this->wpProxy->wp_send_json_error([ + 'message' => 'Failed to start instant translation for all items' + ], 500); + return; } catch (\Exception $e) { $this->getLogger()->error('Instant translation request failed: ' . $e->getMessage()); @@ -142,13 +135,6 @@ public function handlePollStatus(): void { $this->wpProxy->check_ajax_referer('smartling_instant_translation', '_wpnonce'); - if (!$this->wpProxy->current_user_can('publish_posts')) { - $this->wpProxy->wp_send_json_error([ - 'message' => 'Insufficient permissions' - ], 403); - return; - } - try { $submissionId = (int)($_POST['submissionId'] ?? 0); @@ -164,6 +150,19 @@ public function handlePollStatus(): void return; } + if ($submission->getStatus() === SubmissionEntity::SUBMISSION_STATUS_IN_PROGRESS) { + $result = $this->ftsService->checkAndApplyTranslation($submission); + + $submission = $this->submissionManager->getEntityById($submissionId); + + if ($result['status'] === 'error') { + $this->wpProxy->wp_send_json_error([ + 'message' => $result['message'] ?? 'Failed to check translation status' + ], 500); + return; + } + } + $this->wpProxy->wp_send_json_success([ 'status' => $this->mapSubmissionStatus($submission->getStatus()), 'progress' => $submission->getCompletionPercentage(), diff --git a/tests/Smartling/FTS/FtsServiceTest.php b/tests/Smartling/FTS/FtsServiceTest.php index fad29a3c..50493c39 100644 --- a/tests/Smartling/FTS/FtsServiceTest.php +++ b/tests/Smartling/FTS/FtsServiceTest.php @@ -5,10 +5,10 @@ use PHPUnit\Framework\TestCase; use Smartling\ApiWrapperInterface; use Smartling\Base\SmartlingCore; -use Smartling\Helpers\ContentHelper; use Smartling\Helpers\PostContentHelper; use Smartling\Helpers\SiteHelper; use Smartling\Helpers\XmlHelper; +use Smartling\Settings\ConfigurationProfileEntity; use Smartling\Settings\SettingsManager; use Smartling\Submissions\SubmissionEntity; use Smartling\Submissions\SubmissionManager; @@ -17,6 +17,8 @@ class FtsServiceTest extends TestCase { private FtsService $ftsService; private SmartlingCore $core; + private FtsApiWrapper $ftsApiWrapper; + private SettingsManager $settingsManager; protected function setUp(): void { @@ -25,17 +27,17 @@ protected function setUp(): void $this->core = $this->createMock(SmartlingCore::class); $this->siteHelper = $this->createMock(SiteHelper::class); $apiWrapper = $this->createMock(ApiWrapperInterface::class); - $ftsApiWrapper = $this->createMock(FtsApiWrapper::class); + $this->ftsApiWrapper = $this->createMock(FtsApiWrapper::class); $postContentHelper = $this->createMock(PostContentHelper::class); - $settingsManager = $this->createMock(SettingsManager::class); + $this->settingsManager = $this->createMock(SettingsManager::class); $submissionManager = $this->createMock(SubmissionManager::class); $xmlHelper = $this->createMock(XmlHelper::class); $this->ftsService = new FtsService( $apiWrapper, - $ftsApiWrapper, + $this->ftsApiWrapper, $postContentHelper, - $settingsManager, + $this->settingsManager, $this->siteHelper, $submissionManager, $this->core, @@ -132,4 +134,241 @@ public function testRequestInstantTranslationBatchRejectsMixedSources(): void $this->assertEquals('Same source submissions expected', $result['message']); } + public function testCheckAndApplyTranslationWithInvalidFileUri(): void + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn(123); + $submission->method('getFileUri')->willReturn('invalid-format'); + + $result = $this->ftsService->checkAndApplyTranslation($submission); + + $this->assertIsArray($result); + $this->assertEquals('error', $result['status']); + $this->assertEquals('Missing translation metadata', $result['message']); + } + + public function testCheckAndApplyTranslationWithEmptyFileUri(): void + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn(123); + $submission->method('getFileUri')->willReturn(''); + + $result = $this->ftsService->checkAndApplyTranslation($submission); + + $this->assertIsArray($result); + $this->assertEquals('error', $result['status']); + $this->assertEquals('Missing translation metadata', $result['message']); + } + + public function testCheckAndApplyTranslationWithCompletedState(): void + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn(123); + $submission->method('getFileUri')->willReturn('fileUid123:mtUid456'); + $submission->method('getSourceBlogId')->willReturn(1); + $submission->method('getTargetBlogId')->willReturn(2); + $submission->method('getWordCount')->willReturn(100); + + $profile = $this->createMock(ConfigurationProfileEntity::class); + $profile->method('getSmartlingLocale')->willReturn('de-DE'); + + $this->settingsManager + ->method('getSingleSettingsProfile') + ->willReturn($profile); + + $this->ftsApiWrapper + ->method('pollTranslationStatus') + ->willReturn(['state' => 'COMPLETED']); + + $this->ftsApiWrapper + ->method('downloadTranslatedFile') + ->willReturn('translated content'); + + $this->core->expects($this->once())->method('applyXML'); + + $result = $this->ftsService->checkAndApplyTranslation($submission); + + $this->assertIsArray($result); + $this->assertEquals('completed', $result['status']); + } + + public function testCheckAndApplyTranslationWithFailedState(): void + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn(123); + $submission->method('getFileUri')->willReturn('fileUid123:mtUid456'); + + $this->ftsApiWrapper + ->method('pollTranslationStatus') + ->willReturn([ + 'state' => 'FAILED', + 'error' => 'Translation service error' + ]); + + $submission->expects($this->once()) + ->method('setStatus') + ->with(SubmissionEntity::SUBMISSION_STATUS_FAILED); + + $submission->expects($this->once()) + ->method('setLastError') + ->with('Translation service error'); + + $result = $this->ftsService->checkAndApplyTranslation($submission); + + $this->assertIsArray($result); + $this->assertEquals('failed', $result['status']); + $this->assertEquals('Translation service error', $result['message']); + } + + public function testCheckAndApplyTranslationWithFailedStateArrayError(): void + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn(123); + $submission->method('getFileUri')->willReturn('fileUid123:mtUid456'); + + $this->ftsApiWrapper + ->method('pollTranslationStatus') + ->willReturn([ + 'state' => 'FAILED', + 'error' => ['message' => 'Array error message'] + ]); + + $submission->expects($this->once()) + ->method('setLastError') + ->with('Array error message'); + + $result = $this->ftsService->checkAndApplyTranslation($submission); + + $this->assertIsArray($result); + $this->assertEquals('failed', $result['status']); + $this->assertEquals('Array error message', $result['message']); + } + + public function testCheckAndApplyTranslationWithCancelledState(): void + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn(123); + $submission->method('getFileUri')->willReturn('fileUid123:mtUid456'); + + $this->ftsApiWrapper + ->method('pollTranslationStatus') + ->willReturn(['state' => 'CANCELLED']); + + $submission->expects($this->once()) + ->method('setStatus') + ->with(SubmissionEntity::SUBMISSION_STATUS_CANCELLED); + + $submission->expects($this->once()) + ->method('setLastError') + ->with('Translation request was cancelled'); + + $result = $this->ftsService->checkAndApplyTranslation($submission); + + $this->assertIsArray($result); + $this->assertEquals('failed', $result['status']); + $this->assertEquals('Translation request was cancelled', $result['message']); + } + + public function testCheckAndApplyTranslationWithProcessingState(): void + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn(123); + $submission->method('getFileUri')->willReturn('fileUid123:mtUid456'); + + $this->ftsApiWrapper + ->method('pollTranslationStatus') + ->willReturn(['state' => 'PROCESSING']); + + $result = $this->ftsService->checkAndApplyTranslation($submission); + + $this->assertIsArray($result); + $this->assertEquals('in_progress', $result['status']); + } + + public function testCheckAndApplyTranslationWithQueuedState(): void + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn(123); + $submission->method('getFileUri')->willReturn('fileUid123:mtUid456'); + + $this->ftsApiWrapper + ->method('pollTranslationStatus') + ->willReturn(['state' => 'QUEUED']); + + $result = $this->ftsService->checkAndApplyTranslation($submission); + + $this->assertIsArray($result); + $this->assertEquals('in_progress', $result['status']); + } + + public function testCheckAndApplyTranslationWithUnknownState(): void + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn(123); + $submission->method('getFileUri')->willReturn('fileUid123:mtUid456'); + + $this->ftsApiWrapper + ->method('pollTranslationStatus') + ->willReturn(['state' => 'UNKNOWN_STATE']); + + $result = $this->ftsService->checkAndApplyTranslation($submission); + + $this->assertIsArray($result); + $this->assertEquals('in_progress', $result['status']); + } + + public function testCheckAndApplyTranslationWithDownloadException(): void + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn(123); + $submission->method('getFileUri')->willReturn('fileUid123:mtUid456'); + $submission->method('getSourceBlogId')->willReturn(1); + $submission->method('getTargetBlogId')->willReturn(2); + + $profile = $this->createMock(ConfigurationProfileEntity::class); + $profile->method('getSmartlingLocale')->willReturn('de-DE'); + + $this->settingsManager + ->method('getSingleSettingsProfile') + ->willReturn($profile); + + $this->ftsApiWrapper + ->method('pollTranslationStatus') + ->willReturn(['state' => 'COMPLETED']); + + $this->ftsApiWrapper + ->method('downloadTranslatedFile') + ->willThrowException(new \Exception('Download failed')); + + $submission->expects($this->once()) + ->method('setStatus') + ->with(SubmissionEntity::SUBMISSION_STATUS_FAILED); + + $submission->expects($this->once()) + ->method('setLastError') + ->with('Download failed'); + + $result = $this->ftsService->checkAndApplyTranslation($submission); + + $this->assertIsArray($result); + $this->assertEquals('failed', $result['status']); + $this->assertEquals('Download failed', $result['message']); + } + + public function testCheckAndApplyTranslationWithApiException(): void + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn(123); + $submission->method('getFileUri')->willReturn('fileUid123:mtUid456'); + + $this->ftsApiWrapper + ->method('pollTranslationStatus') + ->willThrowException(new \Exception('API connection error')); + + $result = $this->ftsService->checkAndApplyTranslation($submission); + + $this->assertIsArray($result); + $this->assertEquals('error', $result['status']); + $this->assertEquals('API connection error', $result['message']); + } } From ad979fe4a76c120595b5a41811de942a24d84f3b Mon Sep 17 00:00:00 2001 From: vsolovei-smartling Date: Wed, 18 Feb 2026 17:12:14 +0100 Subject: [PATCH 6/6] bump stable version, remove permissions tests (WP-985) --- readme.txt | 2 +- .../InstantTranslationControllerTest.php | 65 ------------------- 2 files changed, 1 insertion(+), 66 deletions(-) diff --git a/readme.txt b/readme.txt index cbf157bf..1602dd9f 100755 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: translation, localization, multilingual, internationalization, smartling Requires at least: 5.5 Tested up to: 6.9 Requires PHP: 8.0 -Stable tag: 5.2.0 +Stable tag: 5.3.0 License: GPLv2 or later Translate content in WordPress quickly and seamlessly with Smartling, the industry-leading Translation Management System. diff --git a/tests/Smartling/WP/Controller/InstantTranslationControllerTest.php b/tests/Smartling/WP/Controller/InstantTranslationControllerTest.php index 5c7ffb8c..16847897 100644 --- a/tests/Smartling/WP/Controller/InstantTranslationControllerTest.php +++ b/tests/Smartling/WP/Controller/InstantTranslationControllerTest.php @@ -282,35 +282,6 @@ public function testBuildSubmissionsExcludesMainContentFromRelations(): void $this->assertCount(3, $result); } - public function testHandleRequestTranslationWithInsufficientPermissions(): void - { - // Simulate AJAX environment - $_POST = [ - 'contentType' => 'post', - 'contentId' => 123, - 'targetBlogIds' => [2, 3], - 'relations' => [], - ]; - - // Mock nonce check passes - $this->wpProxy->method('check_ajax_referer')->willReturn(true); - - // Mock permission check fails - $this->wpProxy->method('current_user_can')->with('publish_posts')->willReturn(false); - - // Expect error response - $this->wpProxy->expects($this->once()) - ->method('wp_send_json_error') - ->with( - $this->callback(function ($data) { - return $data['message'] === 'Insufficient permissions'; - }), - 403 - ); - - $this->controller->handleRequestTranslation(); - } - public function testHandleRequestTranslationWithMissingParameters(): void { // Simulate AJAX environment with missing contentType @@ -408,30 +379,6 @@ public function testHandleRequestTranslationWithFailedSubmissionCreation(): void $this->controller->handleRequestTranslation(); } - public function testHandlePollStatusWithInsufficientPermissions(): void - { - // Simulate AJAX environment - $_POST = ['submissionId' => 123]; - - // Mock nonce check passes - $this->wpProxy->method('check_ajax_referer')->willReturn(true); - - // Mock permission check fails - $this->wpProxy->method('current_user_can')->with('publish_posts')->willReturn(false); - - // Expect error response - $this->wpProxy->expects($this->once()) - ->method('wp_send_json_error') - ->with( - $this->callback(function ($data) { - return $data['message'] === 'Insufficient permissions'; - }), - 403 - ); - - $this->controller->handlePollStatus(); - } - public function testHandlePollStatusWithInvalidSubmissionId(): void { // Simulate AJAX environment with invalid submission ID @@ -545,16 +492,4 @@ public function testMapSubmissionStatusReturnsCorrectValues(): void $this->assertEquals('pending', $method->invoke($this->controller, SubmissionEntity::SUBMISSION_STATUS_NEW)); $this->assertEquals('pending', $method->invoke($this->controller, 'unknown_status')); } - - public function testRegisterCallsAddActionForBothEndpoints(): void - { - $this->wpProxy->expects($this->exactly(2)) - ->method('add_action') - ->withConsecutive( - ['wp_ajax_smartling_instant_translation', [$this->controller, 'handleRequestTranslation']], - ['wp_ajax_smartling_instant_translation_status', [$this->controller, 'handlePollStatus']] - ); - - $this->controller->register(); - } }
getAutoAuthorize() ? 'checked="checked"' : '' ?>/>
 Instant Translation provides immediate translation without creating a job. Translation will complete in approximately 2 minutes.
-