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/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/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/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/FileTranslationsApiExtended.php b/inc/Smartling/FTS/FileTranslationsApiExtended.php new file mode 100644 index 00000000..206c779e --- /dev/null +++ b/inc/Smartling/FTS/FileTranslationsApiExtended.php @@ -0,0 +1,54 @@ +additionalHeaders[self::SERVICE_ORIGIN_HEADER] = self::SERVICE_ORIGIN_VALUE; + } + + /** + * @param string $accountUid + */ + 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); + $instance->setAuth($authProvider); + + return $instance; + } + + protected function getDefaultRequestData($parametersType, $parameters, $auth = true, $httpErrors = false): array + { + $data = parent::getDefaultRequestData($parametersType, $parameters, $auth, $httpErrors); + + 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..90ee934e --- /dev/null +++ b/inc/Smartling/FTS/FtsApiWrapper.php @@ -0,0 +1,146 @@ +settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); + } + + private function getFileTranslationsApi(ConfigurationProfileEntity $profile): FileTranslationsApiExtended + { + AuthTokenProvider::setCurrentClientId($this->pluginName); + AuthTokenProvider::setCurrentClientVersion($this->pluginVersion); + + $authProvider = AuthTokenProvider::create( + $profile->getUserIdentifier(), + $profile->getSecretKey(), + $this->getLogger(), + ); + + return FileTranslationsApiExtended::create( + $authProvider, + $this->apiWrapper->getAccountUid($profile), + $this->getLogger(), + ); + } + + /** + * @throws SmartlingApiException + * @throws SmartlingDbException + */ + public function uploadFile( + SubmissionEntity $submission, + string $filePath, + string $fileName, + string $fileType = 'xml', + ): string { + $this->getLogger()->info("Uploading file for instant translation, submissionId={$submission->getId()}, fileType=$fileType, fileName=$fileName"); + + $response = $this->getFileTranslationsApi($this->getConfigurationProfile($submission)) + ->uploadFile($filePath, $fileName, $fileType); + + $fileUid = $response['fileUid'] ?? null; + + if (empty($fileUid)) { + $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; + } + + /** + * @throws SmartlingApiException + * @throws SmartlingDbException + */ + public function submitForInstantTranslation( + SubmissionEntity $submission, + string $fileUid, + string $sourceLocaleId, + 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, sourceLocaleId=$sourceLocaleId, targetLocaleIds=" . implode(',', $targetLocaleIds)); + + $response = $this->getFileTranslationsApi($this->getConfigurationProfile($submission)) + ->translateFile($fileUid, $params); + + $mtUid = $response['mtUid'] ?? null; + + if (empty($mtUid)) { + $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; + } + + /** + * @throws SmartlingApiException + * @throws SmartlingDbException + */ + public function pollTranslationStatus( + SubmissionEntity $submission, + string $fileUid, + string $mtUid, + ): array { + return $this->getFileTranslationsApi($this->getConfigurationProfile($submission)) + ->getTranslationProgress($fileUid, $mtUid); + } + + /** + * @throws SmartlingApiException + * @throws SmartlingDbException + */ + public function downloadTranslatedFile( + SubmissionEntity $submission, + string $fileUid, + string $mtUid, + string $localeId, + ): string { + $this->getLogger()->info("Downloading translated file, submissionId={$submission->getId()}, fileUid=$fileUid, mtUid=$mtUid, localeId=$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 new file mode 100644 index 00000000..5b6f9b2d --- /dev/null +++ b/inc/Smartling/FTS/FtsService.php @@ -0,0 +1,425 @@ +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(), + ]; + } + } + + /** + * @param SubmissionEntity[] $submissions + * @throws \JsonException + */ + public function requestInstantTranslationBatch(array $submissions): array + { + 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 batch instant translation request, submissionIds=" . implode(',', $submissionIds) . + ", contentType={$firstSubmission->getContentType()}, sourceBlogId={$firstSubmission->getSourceBlogId()}" + ); + + try { + $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; + } + + $mtUid = $this->ftsApiWrapper->submitForInstantTranslation( + $firstSubmission, + $fileUid, + $sourceLocale, + $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 and stored, fileUid=$fileUid, mtUid=$mtUid, sourceLocale=$sourceLocale, " . + "targetLocales=" . implode(',', $targetLocales) . ", submissionIds=" . implode(',', $submissionIds) + ); + + return [ + 'success' => true, + 'status' => 'submitted', + 'fileUid' => $fileUid, + 'mtUid' => $mtUid, + 'submissionIds' => $submissionIds, + ]; + + } catch (\Exception $e) { + $this->getLogger()->error( + "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', + '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); + + 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(), + ]; + } + + case self::STATE_FAILED: + $error = $response['error'] ?? 'Translation request failed'; + $errorMessage = is_array($error) ? ($error['message'] ?? 'Unknown error') : $error; + + $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_FAILED); + $submission->setLastError($errorMessage); + $this->submissionManager->storeEntity($submission); + + $this->getLogger()->error("Translation failed, submissionId={$submission->getId()}, error=$errorMessage"); + + return [ + 'status' => 'failed', + 'message' => $errorMessage, + ]; + + 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( + "Failed to check translation status for submission {$submission->getId()}: {$e->getMessage()}" + ); + + return [ + 'status' => 'error', + 'message' => $e->getMessage(), + ]; + } + } + + /** + * @return string FileUid from FTS + */ + private function uploadFile(SubmissionEntity $submission): string + { + $this->getLogger()->debug("Preparing file for instant translation, submissionId={$submission->getId()}"); + + $submission = $this->core->prepareUpload($submission); + + $tempFile = tempnam(sys_get_temp_dir(), 'smartling_fts_'); + file_put_contents($tempFile, $this->core->getXMLFiltered($submission)); + + try { + $fileName = sprintf( + 'instant-translation-%s-%d-%d.xml', + $submission->getContentType(), + $submission->getSourceId(), + time() + ); + + $fileUid = $this->ftsApiWrapper->uploadFile($submission, $tempFile, $fileName); + $this->getLogger()->info("File uploaded to FTS, submissionId={$submission->getId()}, fileUid=$fileUid"); + + return $fileUid; + } finally { + if (file_exists($tempFile)) { + unlink($tempFile); + } + } + } + + /** + * @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"); + + $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'); + } + + $mtUid = $this->ftsApiWrapper->submitForInstantTranslation( + $submission, + $fileUid, + $sourceLocale, + [$targetLocale] + ); + + $this->getLogger()->info("Translation request created, submissionId={$submission->getId()}, fileUid=$fileUid, mtUid=$mtUid, sourceLocale=$sourceLocale, targetLocale=$targetLocale"); + + return $mtUid; + } + + /** + * @throws SmartlingApiException + * @throws SmartlingDbException + */ + 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"); + + while (true) { + $elapsedMs = (microtime(true) - $startTime) * 1000; + + if ($elapsedMs >= self::TIMEOUT_MS) { + $this->getLogger()->warning("Instant translation polling timed out, submissionId={$submission->getId()}, fileUid=$fileUid, mtUid=$mtUid"); + + return [ + 'status' => 'timeout', + 'message' => 'Translation request timed out after ' . round(self::TIMEOUT_MS / 1000 / 60) . ' minutes', + ]; + } + + $response = $this->ftsApiWrapper->pollTranslationStatus($submission, $fileUid, $mtUid); + $state = $response['state'] ?? ''; + + $this->getLogger()->debug("Polled translation status, submissionId={$submission->getId()}, fileUid=$fileUid, mtUid=$mtUid, state=$state"); + + switch ($state) { + case self::STATE_COMPLETED: + return [ + 'status' => self::STATE_COMPLETED, + 'data' => $response, + ]; + case self::STATE_FAILED: + $error = $response['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, + ]; + } + + $waitMs = $this->getNextPollInterval($waitMs); + + $this->getLogger()->debug("Waiting before next poll, waitMs=$waitMs"); + + usleep($waitMs * 1000); + } + } + + public function getNextPollInterval(?int $waitMs): int + { + if ($waitMs === null || $waitMs < self::INITIAL_POLL_INTERVAL_MS) { + return self::INITIAL_POLL_INTERVAL_MS; + } + return min($waitMs * 2, self::MAX_BACKOFF_INTERVAL_MS); + } + + /** + * @throws SmartlingApiException + * @throws SmartlingDbException + * @throws \JsonException + */ + private function downloadAndApply(SubmissionEntity $submission, string $fileUid, string $mtUid): void + { + $this->getLogger()->info("Downloading and applying translation, submissionId={$submission->getId()}, fileUid=$fileUid, mtUid=$mtUid"); + + $profile = $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); + $targetLocale = $profile->getSmartlingLocale($submission->getTargetBlogId()); + + if (empty($targetLocale)) { + throw new \RuntimeException('Failed to determine target locale for download'); + } + + $translatedXml = $this->ftsApiWrapper->downloadTranslatedFile( + $submission, + $fileUid, + $mtUid, + $targetLocale, + ); + + $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()); + $this->submissionManager->storeEntity($submission); + + $this->getLogger()->info("Translation applied successfully, submissionId={$submission->getId()}"); + } +} diff --git a/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php b/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php index 51abbcfe..41408763 100644 --- a/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php +++ b/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php @@ -240,6 +240,36 @@ 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 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 new file mode 100644 index 00000000..25c2ba90 --- /dev/null +++ b/inc/Smartling/WP/Controller/InstantTranslationController.php @@ -0,0 +1,314 @@ +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'); + + try { + $contentType = $this->wpProxy->sanitize_text_field($this->wpProxy->wp_unslash($_POST['contentType'] ?? '')); + $contentId = (int)($_POST['contentId'] ?? 0); + $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); + $this->getLogger()->info( + "Instant translation requested, contentId=$contentId, contentType=$contentType, " . + "targetBlogIds=" . implode(',', $targetBlogIds) . ", relatedItemsCount=$relatedCount" + ); + + $sourceBlogId = $this->wpProxy->get_current_blog_id(); + + $allSubmissions = $this->buildSubmissions( + $contentType, + $contentId, + $sourceBlogId, + $targetBlogIds, + $relations, + ); + + if (empty($allSubmissions)) { + $this->wpProxy->wp_send_json_error([ + 'message' => 'Failed to create submissions for translation' + ], 500); + return; + } + + $submissionsBySource = []; + foreach ($allSubmissions as $submission) { + $key = $submission->getContentType() . ':' . $submission->getSourceId(); + $submissionsBySource[$key][] = $submission; + } + + $this->getLogger()->info( + "Processing " . count($allSubmissions) . " total submissions in " . + count($submissionsBySource) . " source groups" + ); + + $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); + + $this->wpProxy->wp_send_json_success([ + 'submissionIds' => $allSubmissionIds, + 'message' => sprintf( + 'Instant translation started for %d item(s) across %d locale(s)', + $uniqueSourceCount, + count($targetBlogIds), + ) + ]); + 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()); + + $this->wpProxy->wp_send_json_error([ + 'message' => 'Failed to start instant translation: ' . $e->getMessage() + ], 500); + } + } + + public function handlePollStatus(): void + { + $this->wpProxy->check_ajax_referer('smartling_instant_translation', '_wpnonce'); + + try { + $submissionId = (int)($_POST['submissionId'] ?? 0); + + 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; + } + + 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(), + 'message' => $submission->getLastError() ?: '', + ]); + return; + } catch (\Exception $e) { + $this->getLogger()->error("Status poll failed: " . $e->getMessage()); + $this->wpProxy->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; + } + + /** + * @return array Array of sources: [['id' => int, 'type' => 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 + { + $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/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='= htmlspecialchars(json_encode(array_values($localesData), JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_HEX_TAG), ENT_QUOTES, 'UTF-8') ?>' data-ajax-url="= admin_url('admin-ajax.php') ?>" - data-admin-url="= admin_url('admin-ajax.php') ?>"> + data-admin-url="= admin_url('admin-ajax.php') ?>" + data-nonce="= wp_create_nonce('smartling_instant_translation') ?>">
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..aa49fd98 100644 --- a/inc/config/services.yml +++ b/inc/config/services.yml @@ -88,6 +88,26 @@ services: arguments: - "@wrapper.sdk.api.smartling" + fts.api.wrapper: + class: Smartling\FTS\FtsApiWrapper + arguments: + - "@api.wrapper.with.retries" + - "@manager.settings" + - "%plugin.name%" + - "%plugin.version%" + + fts.service: + class: Smartling\FTS\FtsService + arguments: + - "@api.wrapper.with.retries" + - "@fts.api.wrapper" + - "@helper.post.content" + - "@manager.settings" + - "@site.helper" + - "@manager.submission" + - "@entrypoint" + - "@helper.xml" + queue.db: class: Smartling\Queue\Queue arguments: @@ -433,6 +453,15 @@ services: - "@manager.upload.queue" - "@site.cache" + wp.instant.translation: + class: Smartling\WP\Controller\InstantTranslationController + arguments: + - "@fts.service" + - "@manager.submission" + - "@factory.submission" + - "@file.uri.helper" + - "@wp.proxy" + helper.gutenberg: class: Smartling\Helpers\GutenbergBlockHelper arguments: diff --git a/js/app.js b/js/app.js index 227ea779..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(''); @@ -21,6 +21,11 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, const [totalRequests, setTotalRequests] = useState(0); const [l1Relations, setL1Relations] = useState([]); const [l2Relations, setL2Relations] = useState([]); + 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 { @@ -92,6 +97,145 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, setRelations(unique); }, [depth, l1Relations, l2Relations]); + 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; + } + + 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); + }); + }); + + const relatedItemsSet = new Set(); + relations.forEach(rel => { + if (selectedRelations[`${rel.contentType}-${rel.id}`]) { + relatedItemsSet.add(`${rel.contentType}-${rel.id}`); + } + }); + const itemCount = 1 + relatedItemsSet.size; + const localeCount = selectedLocales.length; + + try { + const response = await jQuery.post(ajaxUrl, { + action: 'smartling_instant_translation', + _wpnonce: nonce, + contentType: contentType, + contentId: contentId, + targetBlogIds: selectedLocales, + relations: relationsData + }); + + if (response.success && response.data?.submissionIds) { + const submissionIds = response.data.submissionIds; + setInstantSubmissionIds(submissionIds); + setInstantPolling(true); + setInstantProgress(5); + setInstantStatus(`Translating ${itemCount} item${itemCount > 1 ? 's' : ''} to ${localeCount} locale${localeCount > 1 ? 's' : ''}...`); + startInstantPolling(submissionIds, Date.now()); + } else { + throw new Error(response.data?.message || 'Failed to start instant translation.'); + } + } 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. 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 { + 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) => { + 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; + } + } + }); + + setInstantCompletedCount(completedCount); + + if (submissionIds.length > 1) { + setInstantStatus(`Translating ${submissionIds.length} items... ${completedCount} completed.`); + } + + if (completedCount === submissionIds.length) { + setInstantPolling(false); + setInstantProgress(100); + setSuccess(`All ${submissionIds.length} item${submissionIds.length > 1 ? 's' : ''} translated successfully!`); + setSubmitting(false); + return; + } + + 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 +321,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 +353,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.' + ) + ), + + 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 +377,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 +473,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') ) )) ) @@ -323,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/readme.txt b/readme.txt index 34274471..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. @@ -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..4ceaf494 --- /dev/null +++ b/tests/Smartling/FTS/FtsApiWrapperTest.php @@ -0,0 +1,103 @@ +settingsManager = $this->createMock(SettingsManager::class); + + $this->ftsApiWrapper = new FtsApiWrapper( + $this->createMock(ApiWrapperInterface::class), + $this->settingsManager, + 'test-plugin', + '1.0.0' + ); + } + + public function testUploadFileRequiresConfiguration(): void + { + $this->expectException(SmartlingDbException::class); + + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getSourceBlogId')->willReturn(1); + + $this->settingsManager + ->method('getSingleSettingsProfile') + ->willThrowException(new SmartlingDbException('No profile found')); + + $this->ftsApiWrapper->uploadFile( + $submission, + '/tmp/test.xml', + 'test.xml', + ); + } + + public function testSubmitForInstantTranslationRequiresConfiguration(): void + { + $this->expectException(SmartlingDbException::class); + + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getSourceBlogId')->willReturn(1); + + $this->settingsManager + ->method('getSingleSettingsProfile') + ->willThrowException(new SmartlingDbException('No profile found')); + + $this->ftsApiWrapper->submitForInstantTranslation( + $submission, + 'file-uid-123', + 'en', + ['es-ES'] + ); + } + + public function testPollTranslationStatusRequiresConfiguration(): void + { + $this->expectException(SmartlingDbException::class); + + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getSourceBlogId')->willReturn(1); + + $this->settingsManager + ->method('getSingleSettingsProfile') + ->willThrowException(new SmartlingDbException('No profile found')); + + $this->ftsApiWrapper->pollTranslationStatus( + $submission, + 'file-uid-123', + 'mt-uid-456' + ); + } + + public function testDownloadTranslatedFileRequiresConfiguration(): void + { + $this->expectException(SmartlingDbException::class); + + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getSourceBlogId')->willReturn(1); + + $this->settingsManager + ->method('getSingleSettingsProfile') + ->willThrowException(new 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..50493c39 --- /dev/null +++ b/tests/Smartling/FTS/FtsServiceTest.php @@ -0,0 +1,374 @@ +core = $this->createMock(SmartlingCore::class); + $this->siteHelper = $this->createMock(SiteHelper::class); + $apiWrapper = $this->createMock(ApiWrapperInterface::class); + $this->ftsApiWrapper = $this->createMock(FtsApiWrapper::class); + $postContentHelper = $this->createMock(PostContentHelper::class); + $this->settingsManager = $this->createMock(SettingsManager::class); + $submissionManager = $this->createMock(SubmissionManager::class); + $xmlHelper = $this->createMock(XmlHelper::class); + + $this->ftsService = new FtsService( + $apiWrapper, + $this->ftsApiWrapper, + $postContentHelper, + $this->settingsManager, + $this->siteHelper, + $submissionManager, + $this->core, + $xmlHelper, + ); + } + + public function testGetNextPollInterval(): void + { + $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 + { + $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); + + $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']); + } + + 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']); + } + + 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']); + } + + 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('