diff --git a/inc/Smartling/Base/SmartlingCoreExportApi.php b/inc/Smartling/Base/SmartlingCoreExportApi.php
index 22cc5c44c..27ab9b2ce 100644
--- a/inc/Smartling/Base/SmartlingCoreExportApi.php
+++ b/inc/Smartling/Base/SmartlingCoreExportApi.php
@@ -28,7 +28,7 @@ public function getFullyRelateAttachmentPathByBlogId($blogId, $foundRelativePath
return trim(str_replace($this->getUploadPathForSite($blogId), '', $foundRelativePath), '/');
}
- public function sendAttachmentForTranslation(int $sourceBlogId, int $targetBlogId, int $sourceId, JobEntityWithBatchUid $jobInfo, bool $clone = false): SubmissionEntity
+ public function sendAttachmentForTranslation(int $sourceBlogId, int $targetBlogId, int $sourceId, JobEntityWithBatchUid $jobInfo, bool $clone = false, ?int $profileId = null): SubmissionEntity
{
return $this->getTranslationHelper()->tryPrepareRelatedContent(
'attachment',
@@ -36,7 +36,8 @@ public function sendAttachmentForTranslation(int $sourceBlogId, int $targetBlogI
$sourceId,
$targetBlogId,
$jobInfo,
- $clone
+ $clone,
+ $profileId,
);
}
diff --git a/inc/Smartling/Helpers/MetaFieldProcessor/ReferencedContentProcessor.php b/inc/Smartling/Helpers/MetaFieldProcessor/ReferencedContentProcessor.php
index cb72f78b2..28ec18c61 100644
--- a/inc/Smartling/Helpers/MetaFieldProcessor/ReferencedContentProcessor.php
+++ b/inc/Smartling/Helpers/MetaFieldProcessor/ReferencedContentProcessor.php
@@ -96,6 +96,7 @@ public function processFieldPreTranslation(
$targetBlogId,
JobEntityWithBatchUid::fromJob($submission->getJobInfo(), ''),
$submission->isCloned(),
+ $submission->getConfigurationProfileId(),
);
}
diff --git a/inc/Smartling/Helpers/TranslationHelper.php b/inc/Smartling/Helpers/TranslationHelper.php
index e0d156e7d..9ec7f2428 100644
--- a/inc/Smartling/Helpers/TranslationHelper.php
+++ b/inc/Smartling/Helpers/TranslationHelper.php
@@ -86,7 +86,7 @@ private function validateBlogs(int $sourceBlogId, int $targetBlogId): void
}
}
- public function prepareSubmissionEntity(string $contentType, int $sourceBlog, int $sourceEntity, int $targetBlog, ?int $targetEntity = null): SubmissionEntity
+ public function prepareSubmissionEntity(string $contentType, int $sourceBlog, int $sourceEntity, int $targetBlog, ?int $targetEntity = null, ?int $profileId = null): SubmissionEntity
{
$this->validateBlogs($sourceBlog, $targetBlog);
@@ -96,7 +96,8 @@ public function prepareSubmissionEntity(string $contentType, int $sourceBlog, in
$sourceEntity,
$targetBlog,
$this->multilangProxy,
- $targetEntity
+ $targetEntity,
+ $profileId,
);
}
@@ -107,7 +108,7 @@ public function prepareSubmissionEntity(string $contentType, int $sourceBlog, in
* @throws SmartlingDataReadException
* @throws SmartlingInvalidFactoryArgumentException
*/
- public function prepareSubmission(string $contentType, int $sourceBlog, int $sourceId, int $targetBlog, bool $clone = false): SubmissionEntity
+ public function prepareSubmission(string $contentType, int $sourceBlog, int $sourceId, int $targetBlog, bool $clone = false, ?int $profileId = null): SubmissionEntity
{
if (0 === $sourceId) {
throw new \InvalidArgumentException('Source id cannot be 0.');
@@ -116,7 +117,9 @@ public function prepareSubmission(string $contentType, int $sourceBlog, int $sou
$contentType,
$sourceBlog,
$sourceId,
- $targetBlog
+ $targetBlog,
+ null,
+ $profileId,
);
if ($submission->getFileUri() === '') {
$submission->setFileUri($this->fileUriHelper->generateFileUri($submission));
@@ -158,11 +161,11 @@ public function isRelatedSubmissionCreationNeeded(string $contentType, int $sour
/**
* @throws SmartlingDataReadException
*/
- public function getExistingSubmissionOrCreateNew(string $contentType, int $sourceBlogId, int $contentId, int $targetBlogId, JobEntityWithBatchUid $jobInfo): SubmissionEntity {
- $submission = $this->submissionManager->getSubmissionEntity($contentType, $sourceBlogId, $contentId, $targetBlogId, $this->multilangProxy);
+ public function getExistingSubmissionOrCreateNew(string $contentType, int $sourceBlogId, int $contentId, int $targetBlogId, JobEntityWithBatchUid $jobInfo, ?int $profileId = null): SubmissionEntity {
+ $submission = $this->submissionManager->getSubmissionEntity($contentType, $sourceBlogId, $contentId, $targetBlogId, $this->multilangProxy, null, $profileId);
if ($submission->getTargetId() === 0) {
$this->getLogger()->debug("Got submission with 0 target id");
- $submission = $this->tryPrepareRelatedContent($contentType, $sourceBlogId, $contentId, $targetBlogId, $jobInfo);
+ $submission = $this->tryPrepareRelatedContent($contentType, $sourceBlogId, $contentId, $targetBlogId, $jobInfo, false, $profileId);
}
return $submission;
}
@@ -174,9 +177,9 @@ public function getExistingSubmissionOrCreateNew(string $contentType, int $sourc
* @throws SmartlingDataReadException
* @throws SmartlingInvalidFactoryArgumentException
*/
- public function tryPrepareRelatedContent(string $contentType, int $sourceBlog, int $sourceId, int $targetBlog, JobEntityWithBatchUid $jobInfo, bool $clone = false): SubmissionEntity
+ public function tryPrepareRelatedContent(string $contentType, int $sourceBlog, int $sourceId, int $targetBlog, JobEntityWithBatchUid $jobInfo, bool $clone = false, ?int $profileId = null): SubmissionEntity
{
- $relatedSubmission = $this->prepareSubmission($contentType, $sourceBlog, $sourceId, $targetBlog, $clone);
+ $relatedSubmission = $this->prepareSubmission($contentType, $sourceBlog, $sourceId, $targetBlog, $clone, $profileId);
if (0 !== $sourceId && 0 === $relatedSubmission->getTargetId() &&
SubmissionEntity::SUBMISSION_STATUS_FAILED !== $relatedSubmission->getStatus()
diff --git a/inc/Smartling/Models/UserTranslationRequest.php b/inc/Smartling/Models/UserTranslationRequest.php
index a2a2991b5..a7c71680d 100644
--- a/inc/Smartling/Models/UserTranslationRequest.php
+++ b/inc/Smartling/Models/UserTranslationRequest.php
@@ -7,23 +7,24 @@
class UserTranslationRequest
{
- private int $contentId;
- private string $contentType;
- private string $description;
private array $relations;
private array $targetBlogIds;
- private JobInformation $jobInformation;
private array $ids;
- public function __construct(int $contentId, string $contentType, array $relations, array $targetBlogIds, JobInformation $jobInformation, array $ids = [], string $description = '')
+ public function __construct(
+ private int $contentId,
+ private string $contentType,
+ array $relations,
+ array $targetBlogIds,
+ private JobInformation $jobInformation,
+ array $ids = [],
+ private string $description = '',
+ private ?int $profileId = null,
+ )
{
- $this->contentId = $contentId;
- $this->contentType = $contentType;
- $this->description = $description;
krsort($relations);
$this->relations = $relations;
$this->targetBlogIds = ArrayHelper::toArrayOfIntegers($targetBlogIds, 'Target blog id expected to be numeric');
- $this->jobInformation = $jobInformation;
$this->ids = self::toIntegerArray($ids);
}
@@ -60,6 +61,11 @@ public function getJobInformation(): JobInformation
return $this->jobInformation;
}
+ public function getProfileId(): ?int
+ {
+ return $this->profileId;
+ }
+
public function getIds(): array
{
return $this->ids;
@@ -79,6 +85,7 @@ public static function fromArray(array $array): self
new JobInformation($array['job']['id'], $array['job']['authorize'] === 'true', $array['job']['name'], $array['job']['description'], $array['job']['dueDate'], $array['job']['timeZone']),
$ids,
$array['description'] ?? (count($ids) > 0 ? 'From Bulk Submit' : 'From Widget'),
+ self::parseProfileId($array['profileId'] ?? null),
);
}
@@ -124,6 +131,22 @@ private static function validate(array $array): void
}
}
+ public static function parseProfileId(mixed $value): ?int
+ {
+ if ($value === null || $value === '') {
+ return null;
+ }
+ if (!is_int($value) && !(is_string($value) && ctype_digit($value))) {
+ throw new \InvalidArgumentException('Profile id must be a positive integer');
+ }
+ $profileId = (int)$value;
+ if ($profileId < 1) {
+ throw new \InvalidArgumentException('Profile id must be a positive integer');
+ }
+
+ return $profileId;
+ }
+
private static function toIntegerArray(array $ids): array
{
return ArrayHelper::toArrayOfIntegers($ids, 'Content id expected to be numeric');
diff --git a/inc/Smartling/Services/ContentRelationsDiscoveryService.php b/inc/Smartling/Services/ContentRelationsDiscoveryService.php
index 2d9c7570b..e9509c63a 100644
--- a/inc/Smartling/Services/ContentRelationsDiscoveryService.php
+++ b/inc/Smartling/Services/ContentRelationsDiscoveryService.php
@@ -103,13 +103,11 @@ public function bulkUpload(
}
$submission->setFileUri($this->fileUriHelper->generateFileUri($submission));
}
+ $this->warnIfReprofilingInProgress($submission, $profile->getId());
$submission->setJobInfo($jobInfo);
$submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_NEW);
$submission->setIsCloned(0);
- // Bulk-submitting is an explicit new translation request: (re)stamp with the
- // profile this request's batch is being created under, for both a found
- // existing submission (which bypasses getSubmissionEntity() above) and a new
- // one (where this just confirms what getSubmissionEntity() already stamped).
+ // Bulk-submitting is an explicit new translation request: (re)stamp with the profile it was requested with.
$submission->setConfigurationProfileId($profile->getId());
$submission = $this->submissionManager->storeEntity($submission);
$queueIds[] = $submission->getId();
@@ -153,7 +151,8 @@ public function bulkUpload(
public function createSubmissions(UserTranslationRequest $request): void
{
$curBlogId = $this->wordpressProxy->get_current_blog_id();
- $profile = $this->settingsManager->getSingleSettingsProfile($curBlogId);
+ $profile = $this->settingsManager->resolveRequestedProfile($request->getProfileId(), $curBlogId);
+ $this->settingsManager->assertTargetBlogIdsBelongToProfile($profile, $request->getTargetBlogIds());
$job = $request->getJobInformation();
$jobInfo = new JobEntity($job->getName(), $job->getId(), $profile->getProjectId());
@@ -228,12 +227,11 @@ public function createSubmissions(UserTranslationRequest $request): void
'type' => $request->getContentType(),
];
} else {
+ $this->warnIfReprofilingInProgress($submission, $profile->getId());
$submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_NEW);
$submission->setIsCloned(0);
- // Resubmitting an existing submission is an explicit new translation request,
- // so (re)stamp it with the profile active right now, same as
- // SubmissionManager::getSubmissionEntity() does - this path never goes through
- // that method, so it would otherwise keep whatever profile (or none) it had.
+ // Resubmitting an existing submission is an explicit new translation request, so restamp it with the
+ // profile it was requested with.
$submission->setConfigurationProfileId($profile->getId());
$submission = $this->storeWithJobInfo($submission, $jobInfo, $request->getDescription());
$fileUris[] = $submission->getFileUri();
@@ -242,9 +240,7 @@ public function createSubmissions(UserTranslationRequest $request): void
$submissionTemplateArray[SubmissionEntity::FIELD_STATUS] = SubmissionEntity::SUBMISSION_STATUS_NEW;
$submissionTemplateArray[SubmissionEntity::FIELD_SUBMISSION_DATE] = DateTimeHelper::nowAsString();
- // New submissions built from this template bypass getSubmissionEntity() too; stamp
- // them with the profile this request's batch is being created under (below), so
- // UploadJob never has to guess at a profile for them later.
+ // New submissions built from this template bypass getSubmissionEntity(), stamp them with the requested profile.
$submissionTemplateArray[SubmissionEntity::FIELD_CONFIGURATION_PROFILE_ID] = $profile->getId();
foreach ($sources as $source) {
@@ -594,6 +590,27 @@ public function normalizeReferences(array $references): array
return $result;
}
+ /**
+ * Re-stamping an existing submission to a different profile is an explicit, supported part of resubmitting
+ * under a user-chosen profile - but if it's still IN_PROGRESS under the old one, it loses its link to that
+ * project's file/job, and status checks/downloads will look at the new project instead. Warn so this is at
+ * least visible, without blocking the resubmission.
+ */
+ private function warnIfReprofilingInProgress(SubmissionEntity $submission, int $newProfileId): void
+ {
+ $oldProfileId = $submission->getConfigurationProfileId();
+ if ($oldProfileId !== null && $oldProfileId !== $newProfileId
+ && $submission->getStatus() === SubmissionEntity::SUBMISSION_STATUS_IN_PROGRESS
+ ) {
+ $this->getLogger()->warning(sprintf(
+ 'Resubmitting submissionId=%d from profileId=%d to profileId=%d while still in progress; it will lose its link to the previous profile\'s file/job.',
+ $submission->getId(),
+ $oldProfileId,
+ $newProfileId,
+ ));
+ }
+ }
+
private function storeWithJobInfo(SubmissionEntity $submission, JobEntity $jobInfo, string $description): SubmissionEntity
{
$submission->setJobInfo($jobInfo);
diff --git a/inc/Smartling/Services/ContentRelationsHandler.php b/inc/Smartling/Services/ContentRelationsHandler.php
index c73ae87a2..8e5b0c4b4 100644
--- a/inc/Smartling/Services/ContentRelationsHandler.php
+++ b/inc/Smartling/Services/ContentRelationsHandler.php
@@ -101,6 +101,8 @@ public function createSubmissionsHandler(array $data = null): void
try {
$this->service->createSubmissions(UserTranslationRequest::fromArray($data));
$this->returnResponse(['status' => BaseAjaxServiceAbstract::RESPONSE_SUCCESS]);
+ } catch (SmartlingHumanReadableException $e) {
+ $this->returnError($e->getKey(), $e->getMessage(), $e->getResponseCode());
} catch (Exception $e) {
$this->returnError('content.submission.failed', $e->getMessage());
}
diff --git a/inc/Smartling/Settings/ConfigurationProfileEntity.php b/inc/Smartling/Settings/ConfigurationProfileEntity.php
index 4de78c6c9..96b245d20 100644
--- a/inc/Smartling/Settings/ConfigurationProfileEntity.php
+++ b/inc/Smartling/Settings/ConfigurationProfileEntity.php
@@ -3,6 +3,7 @@
namespace Smartling\Settings;
use Smartling\Base\SmartlingEntityAbstract;
+use Smartling\Helpers\ArrayHelper;
use Smartling\Vendor\Psr\Log\LoggerInterface;
use Smartling\WP\Controller\ConfigurationProfileFormController as Form;
@@ -443,4 +444,24 @@ public function toArraySafe(): array
unset($struct['secret_key']);
return $struct;
}
+
+ /**
+ * Data for the job wizard profile select and its target locale list.
+ */
+ public function toWizardArray(): array
+ {
+ $locales = $this->getTargetLocales();
+ ArrayHelper::sortLocales($locales);
+
+ return [
+ 'id' => $this->getId(),
+ 'name' => $this->getProfileName(),
+ 'locales' => array_values(array_map(static fn(TargetLocale $l) => [
+ 'blogId' => $l->getBlogId(),
+ 'label' => $l->getLabel(),
+ 'smartlingLocale' => $l->getSmartlingLocale(),
+ 'enabled' => $l->isEnabled(),
+ ], array_filter($locales, static fn(TargetLocale $l) => $l->isEnabled()))),
+ ];
+ }
}
diff --git a/inc/Smartling/Settings/SettingsManager.php b/inc/Smartling/Settings/SettingsManager.php
index 5d79a55ba..02a2d1a7f 100644
--- a/inc/Smartling/Settings/SettingsManager.php
+++ b/inc/Smartling/Settings/SettingsManager.php
@@ -10,6 +10,7 @@
use Smartling\Exception\EntityNotFoundException;
use Smartling\Exception\SmartlingConfigException;
use Smartling\Exception\SmartlingDbException;
+use Smartling\Exception\SmartlingHumanReadableException;
use Smartling\Helpers\ArrayHelper;
use Smartling\Helpers\QueryBuilder\Condition\Condition;
use Smartling\Helpers\QueryBuilder\Condition\ConditionBlock;
@@ -119,6 +120,68 @@ public function getSingleSettingsProfile(int $mainBlogId): ConfigurationProfileE
throw new SmartlingDbException($message);
}
+ /**
+ * Resolves the profile for a translation request.
+ * An explicitly requested profile must exist, belong to the blog and be active, otherwise the request is rejected.
+ * Without an explicit profile, the blog's active profile is used.
+ *
+ * Failures are reported as SmartlingHumanReadableException (rather than the broader SmartlingDbException thrown
+ * by unrelated DB/content errors elsewhere in the request) so callers can map profile-resolution failures to a
+ * clean 400 without mislabeling other failures as invalid profile errors.
+ *
+ * @throws SmartlingHumanReadableException
+ */
+ public function resolveRequestedProfile(?int $requestedProfileId, int $blogId): ConfigurationProfileEntity
+ {
+ if ($requestedProfileId === null) {
+ try {
+ return $this->getSingleSettingsProfile($blogId);
+ } catch (SmartlingDbException $e) {
+ throw new SmartlingHumanReadableException($e->getMessage(), 'profile.not.found', 400);
+ }
+ }
+
+ $profile = ArrayHelper::first($this->getEntityById($requestedProfileId));
+ if (!$profile instanceof ConfigurationProfileEntity) {
+ $message = "Requested profileId=$requestedProfileId not found";
+ $this->getLogger()->warning($message);
+ throw new SmartlingHumanReadableException($message, 'profile.invalid', 400);
+ }
+ if ($profile->getSourceLocale()->getBlogId() !== $blogId || 1 !== $profile->getIsActive()) {
+ $message = "Requested profileId=$requestedProfileId is not an active profile for blogId=$blogId";
+ $this->getLogger()->warning($message);
+ throw new SmartlingHumanReadableException($message, 'profile.invalid', 400);
+ }
+ $this->getLogger()->debug("Using requested profileId=$requestedProfileId for blogId=$blogId");
+
+ return $profile;
+ }
+
+ /**
+ * @param int[] $targetBlogIds
+ * @throws SmartlingHumanReadableException
+ */
+ public function assertTargetBlogIdsBelongToProfile(ConfigurationProfileEntity $profile, array $targetBlogIds): void
+ {
+ $allowedBlogIds = [];
+ foreach ($profile->getTargetLocales() as $locale) {
+ if ($locale->isEnabled()) {
+ $allowedBlogIds[] = $locale->getBlogId();
+ }
+ }
+
+ $invalidBlogIds = array_diff($targetBlogIds, $allowedBlogIds);
+ if (0 < count($invalidBlogIds)) {
+ $message = sprintf(
+ 'Target blogId(s) %s are not enabled target locales for profileId=%d',
+ implode(',', $invalidBlogIds),
+ $profile->getId(),
+ );
+ $this->getLogger()->warning($message);
+ throw new SmartlingHumanReadableException($message, 'target.blog.invalid', 400);
+ }
+ }
+
/**
* Returns the profile the submission was requested with, so delivery doesn't depend on which profile is active now.
* Falls back to the active profile of the source blog for submissions without a stored (or an existing) profile.
diff --git a/inc/Smartling/Submissions/SubmissionManager.php b/inc/Smartling/Submissions/SubmissionManager.php
index 426aa6b77..3ff341159 100644
--- a/inc/Smartling/Submissions/SubmissionManager.php
+++ b/inc/Smartling/Submissions/SubmissionManager.php
@@ -450,6 +450,12 @@ public function createSubmission(array $fields): SubmissionEntity
/**
* Loads from database or creates a new instance of SubmissionEntity
+ *
+ * When $profileId is given (e.g. the profile the parent submission was requested under, for related content
+ * created while processing it), it is stamped unconditionally, since the caller knows better than any default.
+ * Otherwise, an entity that already has a stamped profile is left alone - only a submission with none gets the
+ * source blog's active profile as a best-effort default. Re-stamping an already-stamped submission would make
+ * it diverge from the batch/job it was actually created under.
*/
public function getSubmissionEntity(
string $contentType,
@@ -457,7 +463,8 @@ public function getSubmissionEntity(
int $sourceEntity,
int $targetBlog,
?LocalizationPluginProxyInterface $localizationProxy = null,
- ?int $targetEntity = null
+ ?int $targetEntity = null,
+ ?int $profileId = null,
): SubmissionEntity
{
$params = [
@@ -486,7 +493,12 @@ public function getSubmissionEntity(
$entity->setSourceTitle('no title');
$entity->setCreatedAt(DateTimeHelper::nowAsString());
}
- $this->stampConfigurationProfile($entity);
+
+ if ($profileId !== null) {
+ $entity->setConfigurationProfileId($profileId);
+ } elseif ($entity->getConfigurationProfileId() === null) {
+ $this->stampConfigurationProfile($entity);
+ }
return $entity;
}
diff --git a/inc/Smartling/WP/Controller/BulkSubmitController.php b/inc/Smartling/WP/Controller/BulkSubmitController.php
index b9cbbae69..c184d66f5 100644
--- a/inc/Smartling/WP/Controller/BulkSubmitController.php
+++ b/inc/Smartling/WP/Controller/BulkSubmitController.php
@@ -96,6 +96,7 @@ public function renderPage()
$profile,
$this->wpProxy,
$this->nonceVerifier,
+ $applicableProfiles,
);
$this->view($table);
}
diff --git a/inc/Smartling/WP/Controller/ContentEditJobController.php b/inc/Smartling/WP/Controller/ContentEditJobController.php
index 0bdb2737b..c5a0c4998 100644
--- a/inc/Smartling/WP/Controller/ContentEditJobController.php
+++ b/inc/Smartling/WP/Controller/ContentEditJobController.php
@@ -7,6 +7,7 @@
use Smartling\ApiWrapperInterface;
use Smartling\Bootstrap;
use Smartling\DbAl\LocalizationPluginProxyInterface;
+use Smartling\Exception\SmartlingHumanReadableException;
use Smartling\Exceptions\SmartlingApiException;
use Smartling\Helpers\ArrayHelper;
use Smartling\Helpers\Cache;
@@ -17,6 +18,7 @@
use Smartling\Helpers\SiteHelper;
use Smartling\Helpers\SmartlingUserCapabilities;
use Smartling\Helpers\WordpressFunctionProxyHelper;
+use Smartling\Models\UserTranslationRequest;
use Smartling\Settings\SettingsManager;
use Smartling\Submissions\SubmissionManager;
use Smartling\Vendor\Smartling\Jobs\JobStatus;
@@ -101,8 +103,18 @@ public function initJobApiProxy(): void
'status' => 200,
];
- $profile = $this->settingsManager->getSingleSettingsProfile($this->siteHelper->getCurrentBlogId());
$params = &$data['params'];
+ try {
+ $blogId = $this->siteHelper->getCurrentBlogId();
+ $profile = $this->settingsManager->resolveRequestedProfile(
+ UserTranslationRequest::parseProfileId($params['profileId'] ?? null),
+ $blogId,
+ );
+ } catch (\InvalidArgumentException | SmartlingHumanReadableException $e) {
+ $this->getLogger()->warning('Unable to resolve requested profile: ' . $e->getMessage());
+ $this->wpProxy->wp_send_json(['status' => 400, 'message' => ['profileId' => 'Invalid translation profile']], 400);
+ return;
+ }
$validateRequires = static function ($fieldName) use (&$result, $params) {
$value = trim($params[$fieldName] ?? '');
@@ -144,10 +156,18 @@ public function initJobApiProxy(): void
$timezone = $validateRequires('timezone');
$jobDescription = $params['description'];
$jobDueDate = $params['dueDate'];
- $jobLocalesRaw = explode(',', $validateRequires('locales'));
+ $jobLocalesRaw = array_map('intval', explode(',', $validateRequires('locales')));
+ if ($result['status'] === 200) {
+ try {
+ $this->settingsManager->assertTargetBlogIdsBelongToProfile($profile, $jobLocalesRaw);
+ } catch (SmartlingHumanReadableException $e) {
+ $result['status'] = 400;
+ $result['message']['locales'] = $e->getMessage();
+ }
+ }
$jobLocales = [];
foreach ($jobLocalesRaw as $blogId) {
- $jobLocales[] = $this->settingsManager->getSmartlingLocaleIdBySettingsProfile($profile, (int)$blogId);
+ $jobLocales[] = $this->settingsManager->getSmartlingLocaleIdBySettingsProfile($profile, $blogId);
}
if ($result['status'] === 200) {
try {
@@ -273,6 +293,7 @@ public function box($attr)
$this->view(
[
'profile' => $profile,
+ 'profiles' => $applicableProfiles,
'contentType' => $contentType,
]
);
@@ -289,6 +310,7 @@ public function box($attr)
$this->view(
[
'profile' => $profile,
+ 'profiles' => $applicableProfiles,
'contentType' => $contentType,
]
);
diff --git a/inc/Smartling/WP/Controller/InstantTranslationController.php b/inc/Smartling/WP/Controller/InstantTranslationController.php
index 0c283eea5..b36ed722c 100644
--- a/inc/Smartling/WP/Controller/InstantTranslationController.php
+++ b/inc/Smartling/WP/Controller/InstantTranslationController.php
@@ -2,6 +2,7 @@
namespace Smartling\WP\Controller;
+use Smartling\Exception\SmartlingHumanReadableException;
use Smartling\FTS\FtsService;
use Smartling\Helpers\AjaxSecurityChecker;
use Smartling\Helpers\DateTimeHelper;
@@ -9,6 +10,8 @@
use Smartling\Helpers\LoggerSafeTrait;
use Smartling\Helpers\SmartlingUserCapabilities;
use Smartling\Helpers\WordpressFunctionProxyHelper;
+use Smartling\Models\UserTranslationRequest;
+use Smartling\Settings\SettingsManager;
use Smartling\Submissions\SubmissionEntity;
use Smartling\Submissions\SubmissionFactory;
use Smartling\Submissions\SubmissionManager;
@@ -28,6 +31,7 @@ public function __construct(
private FileUriHelper $fileUriHelper,
private WordpressFunctionProxyHelper $wpProxy,
private AjaxSecurityChecker $ajaxSecurity,
+ private SettingsManager $settingsManager,
) {
}
@@ -68,12 +72,26 @@ public function handleRequestTranslation(): void
$sourceBlogId = $this->wpProxy->get_current_blog_id();
+ try {
+ $profile = $this->settingsManager->resolveRequestedProfile(
+ UserTranslationRequest::parseProfileId($_POST['profileId'] ?? null),
+ $sourceBlogId,
+ );
+ $this->settingsManager->assertTargetBlogIdsBelongToProfile($profile, $targetBlogIds);
+ } catch (\InvalidArgumentException | SmartlingHumanReadableException $e) {
+ $this->getLogger()->warning('Unable to resolve requested profile: ' . $e->getMessage());
+ $this->wpProxy->wp_send_json_error(['message' => 'Invalid translation profile'], 400);
+ return;
+ }
+ $profileId = $profile->getId();
+
$allSubmissions = $this->buildSubmissions(
$contentType,
$contentId,
$sourceBlogId,
$targetBlogIds,
$relations,
+ $profileId,
);
if (empty($allSubmissions)) {
@@ -208,7 +226,8 @@ private function buildSubmissions(
int $contentId,
int $sourceBlogId,
array $targetBlogIds,
- array $relations
+ array $relations,
+ int $profileId,
): array {
$submissions = [];
@@ -218,6 +237,7 @@ private function buildSubmissions(
$targetBlogId,
$contentType,
$contentId,
+ $profileId,
);
if ($mainSubmission !== null) {
$submissions[] = $mainSubmission;
@@ -229,7 +249,8 @@ private function buildSubmissions(
$sourceBlogId,
$targetBlogId,
$source['type'],
- $source['id']
+ $source['id'],
+ $profileId,
);
if ($relatedSubmission !== null) {
$submissions[] = $relatedSubmission;
@@ -275,7 +296,8 @@ private function getOrCreateSubmission(
int $sourceBlogId,
int $targetBlogId,
string $contentType,
- int $contentId
+ int $contentId,
+ int $profileId,
): ?SubmissionEntity {
try {
$submission = $this->submissionManager->findOne([
@@ -297,9 +319,21 @@ private function getOrCreateSubmission(
$submission = $this->submissionFactory->fromArray($submissionArray);
$submission->setFileUri($this->fileUriHelper->generateFileUri($submission));
} else {
+ $oldProfileId = $submission->getConfigurationProfileId();
+ if ($oldProfileId !== null && $oldProfileId !== $profileId
+ && $submission->getStatus() === SubmissionEntity::SUBMISSION_STATUS_IN_PROGRESS
+ ) {
+ $this->getLogger()->warning(sprintf(
+ 'Resubmitting submissionId=%d from profileId=%d to profileId=%d while still in progress; it will lose its link to the previous profile\'s file/job.',
+ $submission->getId(),
+ $oldProfileId,
+ $profileId,
+ ));
+ }
$submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_NEW);
}
+ $submission->setConfigurationProfileId($profileId);
$submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_IN_PROGRESS);
return $this->submissionManager->storeEntity($submission);
} catch (\Exception $e) {
diff --git a/inc/Smartling/WP/Controller/PostBasedWidgetControllerStd.php b/inc/Smartling/WP/Controller/PostBasedWidgetControllerStd.php
index 6a6731f36..fe55c8fb0 100644
--- a/inc/Smartling/WP/Controller/PostBasedWidgetControllerStd.php
+++ b/inc/Smartling/WP/Controller/PostBasedWidgetControllerStd.php
@@ -6,6 +6,7 @@
use Smartling\Base\SmartlingCore;
use Smartling\Bootstrap;
use Smartling\Exception\SmartlingDbException;
+use Smartling\Exception\SmartlingHumanReadableException;
use Smartling\Extensions\Acf\AcfDynamicSupport;
use Smartling\Helpers\ArrayHelper;
use Smartling\Helpers\CommonLogMessagesTrait;
@@ -13,6 +14,7 @@
use Smartling\Helpers\DiagnosticsHelper;
use Smartling\Helpers\SmartlingUserCapabilities;
use Smartling\Jobs\JobEntityWithBatchUid;
+use Smartling\Models\UserTranslationRequest;
use Smartling\Submissions\SubmissionEntity;
use Smartling\Vendor\Smartling\AuditLog\Params\CreateRecordParameters;
use Smartling\WP\WPAbstract;
@@ -217,25 +219,33 @@ public function ajaxUploadHandler()
}
}
- $profile = ArrayHelper::first($this->getProfiles());
+ $profile = null;
/**
- * checking profiles
+ * checking profile - resolves the explicit profileId when the caller sends one (consistent with every
+ * other entry point), falling back to the blog's single active profile otherwise.
*/
- if ($continue && !$profile) {
- $this->getLogger()->error(
- vsprintf(
- 'Failed adding content to upload queue: %s for %s',
- [self::ERROR_MSG_NO_PROFILE_FOUND, var_export($_POST, true)])
- );
+ if ($continue) {
+ try {
+ $profile = $this->settingsManager->resolveRequestedProfile(
+ UserTranslationRequest::parseProfileId($data['profileId'] ?? null),
+ $this->siteHelper->getCurrentBlogId(),
+ );
+ } catch (\InvalidArgumentException | SmartlingHumanReadableException $e) {
+ $this->getLogger()->error(
+ vsprintf(
+ 'Failed adding content to upload queue: %s for %s',
+ [$e->getMessage(), var_export($_POST, true)])
+ );
- $result = [
- 'status' => 'FAIL',
- 'key' => self::ERROR_KEY_NO_PROFILE_FOUND,
- 'message' => self::ERROR_MSG_NO_PROFILE_FOUND,
- ];
+ $result = [
+ 'status' => 'FAIL',
+ 'key' => self::ERROR_KEY_NO_PROFILE_FOUND,
+ 'message' => self::ERROR_MSG_NO_PROFILE_FOUND,
+ ];
- $continue = false;
+ $continue = false;
+ }
}
/**
@@ -280,6 +290,26 @@ public function ajaxUploadHandler()
}
}
+ /**
+ * Validates that every selected target blog is actually covered by the resolved profile - the profile is
+ * either the one explicitly requested or the blog's single active one, never inferred from the blogs.
+ */
+ if ($continue) {
+ try {
+ $this->settingsManager->assertTargetBlogIdsBelongToProfile($profile, array_map('intval', $data['blogs']));
+ } catch (SmartlingHumanReadableException $e) {
+ $this->getLogger()->error(
+ vsprintf('Failed adding content to upload queue: %s for %s', [$e->getMessage(), var_export($_POST, true)])
+ );
+ $result = [
+ 'status' => 'FAIL',
+ 'key' => self::ERROR_KEY_TARGET_BLOG_EMPTY,
+ 'message' => $e->getMessage(),
+ ];
+ $continue = false;
+ }
+ }
+
if ($continue) {
$data['content']['id'] = explode(',', $data['content']['id']);
@@ -355,9 +385,9 @@ public function ajaxUploadHandler()
try {
$jobInfo = new JobEntityWithBatchUid('', $jobName, $data['job']['id'], $profile->getProjectId());
if ($this->getCore()->getTranslationHelper()->isRelatedSubmissionCreationNeeded($contentType, $sourceBlog, (int)$sourceId, (int)$targetBlogId)) {
- $submission = $this->getCore()->getTranslationHelper()->tryPrepareRelatedContent($contentType, $sourceBlog, (int)$sourceId, (int)$targetBlogId, $jobInfo);
+ $submission = $this->getCore()->getTranslationHelper()->tryPrepareRelatedContent($contentType, $sourceBlog, (int)$sourceId, (int)$targetBlogId, $jobInfo, false, $profile->getId());
} else {
- $submission = $this->getCore()->getTranslationHelper()->getExistingSubmissionOrCreateNew($contentType, $sourceBlog, (int)$sourceId, (int)$targetBlogId, $jobInfo);
+ $submission = $this->getCore()->getTranslationHelper()->getExistingSubmissionOrCreateNew($contentType, $sourceBlog, (int)$sourceId, (int)$targetBlogId, $jobInfo, $profile->getId());
}
if (0 < $submission->getId()) {
@@ -472,6 +502,7 @@ public function preView($post)
'submissions' => $submissions,
'post' => $post,
'profile' => ArrayHelper::first($profile),
+ 'profiles' => $profile,
]
);
} else {
diff --git a/inc/Smartling/WP/Controller/TestRunController.php b/inc/Smartling/WP/Controller/TestRunController.php
index 916b6e25c..a4a7fb0ed 100644
--- a/inc/Smartling/WP/Controller/TestRunController.php
+++ b/inc/Smartling/WP/Controller/TestRunController.php
@@ -200,7 +200,8 @@ public function testRun($data): void
[$targetBlogId],
new JobInformation($job->getJobUid(), true, $job->getJobName(), 'Test run job', '', 'UTC'),
[],
- 'Test run'
+ 'Test run',
+ $profile->getId(),
));
}
diff --git a/inc/Smartling/WP/Table/BulkSubmitTableWidget.php b/inc/Smartling/WP/Table/BulkSubmitTableWidget.php
index a65427b5c..aff4f3925 100644
--- a/inc/Smartling/WP/Table/BulkSubmitTableWidget.php
+++ b/inc/Smartling/WP/Table/BulkSubmitTableWidget.php
@@ -71,6 +71,14 @@ public function getProfile(): ConfigurationProfileEntity
return $this->profile;
}
+ /**
+ * @return ConfigurationProfileEntity[]
+ */
+ public function getApplicableProfiles(): array
+ {
+ return $this->applicableProfiles;
+ }
+
public function __construct(
private AcfDynamicSupport $acfDynamicSupport,
private ApiWrapperInterface $apiWrapper,
@@ -82,6 +90,7 @@ public function __construct(
protected ConfigurationProfileEntity $profile,
protected WordpressFunctionProxyHelper $wpProxy,
protected NonceVerifier $nonceVerifier,
+ protected array $applicableProfiles,
) {
$this->setSource($_REQUEST);
diff --git a/inc/Smartling/WP/View/BulkSubmit.php b/inc/Smartling/WP/View/BulkSubmit.php
index 659638864..081a04785 100644
--- a/inc/Smartling/WP/View/BulkSubmit.php
+++ b/inc/Smartling/WP/View/BulkSubmit.php
@@ -1,6 +1,7 @@
display() ?>
getProfile()->getTargetLocales();
- ArrayHelper::sortLocales($locales);
- $localesData = array_map(function($locale) {
- return [
- 'blogId' => $locale->getBlogId(),
- 'label' => $locale->getLabel(),
- 'smartlingLocale' => $locale->getSmartlingLocale(),
- 'enabled' => $locale->isEnabled()
- ];
- }, array_filter($locales, fn($l) => $l->isEnabled()));
+ $profilesData = array_map(static fn(ConfigurationProfileEntity $p) => $p->toWizardArray(), $data->getApplicableProfiles());
?>
'
+ data-blog-id="= (int)$this->siteHelper->getCurrentBlogId() ?>"
+ data-profiles='= htmlspecialchars(json_encode(array_values($profilesData), JSON_THROW_ON_ERROR | 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-nonce="= wp_create_nonce('smartling_translation') ?>">
diff --git a/inc/Smartling/WP/View/ContentEditJob.php b/inc/Smartling/WP/View/ContentEditJob.php
index 7906f5dac..9127d2661 100644
--- a/inc/Smartling/WP/View/ContentEditJob.php
+++ b/inc/Smartling/WP/View/ContentEditJob.php
@@ -35,16 +35,8 @@
}
}
-$locales = $profile->getTargetLocales();
-ArrayHelper::sortLocales($locales);
-$localesData = array_map(function($locale) {
- return [
- 'blogId' => $locale->getBlogId(),
- 'label' => $locale->getLabel(),
- 'smartlingLocale' => $locale->getSmartlingLocale(),
- 'enabled' => $locale->isEnabled()
- ];
-}, array_filter($locales, fn($l) => $l->isEnabled()));
+$profiles = $data['profiles'] ?? [$profile];
+$profilesData = array_map(static fn(ConfigurationProfileEntity $p) => $p->toWizardArray(), $profiles);
if (!$isBulkSubmitPage) : ?>
@@ -56,7 +48,8 @@
data-bulk-submit="false"
data-content-type="= $data['contentType'] ?? $baseType ?>"
data-content-id="= $id ?>"
- data-locales='= htmlspecialchars(json_encode(array_values($localesData), JSON_THROW_ON_ERROR | JSON_HEX_APOS | JSON_HEX_QUOT), ENT_QUOTES, 'UTF-8') ?>'
+ data-blog-id="= (int)$this->siteHelper->getCurrentBlogId() ?>"
+ data-profiles='= htmlspecialchars(json_encode(array_values($profilesData), JSON_THROW_ON_ERROR | JSON_HEX_APOS | JSON_HEX_QUOT), ENT_QUOTES, 'UTF-8') ?>'
data-ajax-url="= admin_url('admin-ajax.php') ?>"
data-admin-url="= admin_url('admin-ajax.php') ?>"
data-nonce="= wp_create_nonce('smartling_translation') ?>">
diff --git a/inc/Smartling/WP/View/post-based-content-type.php b/inc/Smartling/WP/View/post-based-content-type.php
index d426a8dde..e82c9bbe2 100644
--- a/inc/Smartling/WP/View/post-based-content-type.php
+++ b/inc/Smartling/WP/View/post-based-content-type.php
@@ -6,6 +6,7 @@
use Smartling\Helpers\WordpressContentTypeHelper;
use Smartling\Settings\TargetLocale;
use Smartling\Submissions\SubmissionEntity;
+use Smartling\WP\Controller\ConfigurationProfilesController;
use Smartling\WP\Controller\PostBasedWidgetControllerStd;
use Smartling\WP\WPAbstract;
@@ -18,7 +19,15 @@
/**
* @var TargetLocale[] $locales
*/
-$locales = $data['profile']->getTargetLocales();
+$locales = [];
+foreach ($data['profiles'] ?? [$data['profile']] as $profile) {
+ foreach ($profile->getTargetLocales() as $locale) {
+ if ($locale->isEnabled()) {
+ $locales[$locale->getBlogId()] ??= $locale;
+ }
+ }
+}
+$locales = array_values($locales);
$filteredLocales = [];
@@ -141,7 +150,7 @@
No suitable target locales found.
Please check your
settings.
+ href="= get_site_url() ?>/wp-admin/admin.php?page== ConfigurationProfilesController::MENU_SLUG ?>">settings.
diff --git a/inc/config/services.yml b/inc/config/services.yml
index 59df001ee..760553b44 100644
--- a/inc/config/services.yml
+++ b/inc/config/services.yml
@@ -516,6 +516,7 @@ services:
- "@file.uri.helper"
- "@wp.proxy"
- "@helper.ajax-security"
+ - "@manager.settings"
wp.upload-queue-count:
class: Smartling\WP\Controller\UploadQueueCountController
diff --git a/js/app.js b/js/app.js
index c287321cf..8e43e9d96 100644
--- a/js/app.js
+++ b/js/app.js
@@ -1,8 +1,32 @@
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, nonce }) {
+function getStoredProfileId(blogId) {
+ try {
+ const stored = window.localStorage.getItem(`smartling_last_profile_${blogId}`);
+ const profileId = stored ? parseInt(stored, 10) : NaN;
+ return Number.isNaN(profileId) ? null : profileId;
+ } catch (e) {
+ return null;
+ }
+}
+
+function setStoredProfileId(blogId, profileId) {
+ try {
+ window.localStorage.setItem(`smartling_last_profile_${blogId}`, String(profileId));
+ } catch (e) {
+ // Ignore storage errors (private browsing, quota, etc.) - profile selection
+ // just won't be remembered across page loads.
+ }
+}
+
+function JobWizard({ isBulkSubmitPage, contentType, contentId, profiles, blogId, ajaxUrl, adminUrl, nonce }) {
const [activeTab, setActiveTab] = useState('new');
+ const [selectedProfileId, setSelectedProfileId] = useState(() => {
+ const stored = getStoredProfileId(blogId);
+ return (stored !== null && profiles.some(p => p.id === stored)) ? stored : profiles[0]?.id;
+ });
+ const locales = profiles.find(p => p.id === selectedProfileId)?.locales || [];
const [jobs, setJobs] = useState([]);
const [selectedJob, setSelectedJob] = useState('');
const [jobName, setJobName] = useState('');
@@ -27,27 +51,59 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl,
const [instantSubmissionIds, setInstantSubmissionIds] = useState([]);
const [instantCompletedCount, setInstantCompletedCount] = useState(0);
- const loadJobs = useCallback(async () => {
- try {
- const response = await jQuery.post(adminUrl, {
- action: 'smartling_job_api_proxy',
- _wpnonce: nonce,
- innerAction: 'list-jobs',
- params: {}
- });
- if (response.status === 200) {
- setJobs(response.data);
- }
- } catch (e) {
- setError('Failed to load jobs');
- } finally {
- setLoading(false);
- }
- }, [adminUrl]);
-
useEffect(() => {
- loadJobs();
- }, [loadJobs]);
+ // Ignore responses for a previously selected profile, a slow one must not overwrite the current job list.
+ // The old profile's jobs must not stay selectable while the new list loads (or forever, if it fails), so
+ // clear them - and any stale error from a previous attempt - up front rather than waiting for the response.
+ let stale = false;
+ setJobs([]);
+ setLoading(true);
+ setError('');
+ (async () => {
+ try {
+ const response = await jQuery.post(adminUrl, {
+ action: 'smartling_job_api_proxy',
+ _wpnonce: nonce,
+ innerAction: 'list-jobs',
+ params: { profileId: selectedProfileId }
+ });
+ if (!stale && response.status === 200) {
+ setJobs(response.data);
+ }
+ } catch (e) {
+ if (!stale) {
+ setError('Failed to load jobs');
+ }
+ } finally {
+ if (!stale) {
+ setLoading(false);
+ }
+ }
+ })();
+ return () => {
+ stale = true;
+ };
+ }, [adminUrl, nonce, selectedProfileId]);
+
+ const handleProfileChange = (val) => {
+ const newProfileId = parseInt(val, 10);
+ setSelectedProfileId(newProfileId);
+ setStoredProfileId(blogId, newProfileId);
+ const newLocales = (profiles.find(p => p.id === newProfileId)?.locales || []).map(l => l.blogId);
+ setSelectedLocales(prev => prev.filter(id => newLocales.includes(id)));
+ setSelectedJob('');
+ setJobName('');
+ setDescription('');
+ setDueDate('');
+ // Relations were fetched for the previous profile's target locales; clear them so the
+ // depth effect (keyed off the new locale list) starts a fresh fetch instead of appending
+ // to stale entries and inflating the progress bar's totalRequests count.
+ setL1Relations([]);
+ setL2Relations([]);
+ setSelectedRelations({});
+ setPendingRequests(0);
+ setTotalRequests(0);
+ };
const loadRelations = useCallback(async (type, id, level = 1) => {
const localeList = locales.map(l => l.blogId).join(',');
@@ -79,7 +135,7 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl,
if (isBulkSubmitPage) {
jQuery('input.bulkaction[type=checkbox]:checked').each(function() {
const parts = jQuery(this).attr('id').split('-');
- const id = parseInt(parts.shift());
+ const id = parseInt(parts.shift(), 10);
const type = parts.join('-');
loadRelations(type, id, 1);
});
@@ -137,6 +193,7 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl,
const response = await jQuery.post(ajaxUrl, {
action: 'smartling_instant_translation',
_wpnonce: nonce,
+ profileId: selectedProfileId,
contentType: contentType,
contentId: contentId,
targetBlogIds: selectedLocales,
@@ -250,6 +307,7 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl,
const data = {
_wpnonce: nonce,
formAction: 'upload',
+ profileId: selectedProfileId,
source: { contentType, id: isBulkSubmitPage ? [] : [contentId] },
job: {
id: activeTab === 'new' ? '' : selectedJob,
@@ -277,7 +335,7 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl,
data.ids = [];
jQuery('input.bulkaction[type=checkbox]:checked').each(function() {
const parts = jQuery(this).attr('id').split('-');
- data.ids.push(parseInt(parts.shift()));
+ data.ids.push(parseInt(parts.shift(), 10));
data.source.contentType = parts.join('-');
});
}
@@ -289,6 +347,7 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl,
_wpnonce: nonce,
innerAction: 'create-job',
params: {
+ profileId: selectedProfileId,
jobName,
description,
dueDate,
@@ -387,6 +446,13 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl,
),
el('div', {},
+ profiles.length > 1 && el(SelectControl, {
+ label: 'Translation profile',
+ value: selectedProfileId,
+ options: profiles.map(p => ({ label: p.name, value: p.id })),
+ onChange: handleProfileChange
+ }),
+
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' } },
@@ -507,15 +573,19 @@ if (document.getElementById('smartling-app')) {
const container = document.getElementById('smartling-app');
const isBulkSubmitPage = container.dataset.bulkSubmit === 'true';
const contentType = container.dataset.contentType || '';
- const contentId = parseInt(container.dataset.contentId) || 0;
- const locales = JSON.parse(container.dataset.locales || '[]');
+ const contentId = parseInt(container.dataset.contentId, 10) || 0;
+ const profiles = JSON.parse(container.dataset.profiles || '[]');
+ const blogId = parseInt(container.dataset.blogId, 10) || 0;
const ajaxUrl = container.dataset.ajaxUrl || '';
const adminUrl = container.dataset.adminUrl || '';
const nonce = container.dataset.nonce || '';
- render(
- el(JobWizard, { isBulkSubmitPage, contentType, contentId, locales, ajaxUrl, adminUrl, nonce }),
- container
- );
+ // Nothing to offer without an active profile
+ if (profiles.length > 0) {
+ render(
+ el(JobWizard, { isBulkSubmitPage, contentType, contentId, profiles, blogId, ajaxUrl, adminUrl, nonce }),
+ container
+ );
+ }
}
diff --git a/readme.txt b/readme.txt
index 297fba006..7e79c87d4 100755
--- a/readme.txt
+++ b/readme.txt
@@ -63,6 +63,7 @@ Additional information on the Smartling Connector for WordPress can be found [he
== Changelog ==
= 5.8.1 =
+* Added profile selector for sites with multiple active profiles.
* Improved Elementor query support: term and post IDs used by Posts, Loop Grid and Loop Carousel queries are now detected and replaced with the translated ones, only for the query mode the widget actually uses. Query terms are stored by Elementor Pro as term taxonomy IDs and are converted accordingly. Excluded terms and posts are replaced only if they are already translated when the page translation is downloaded; if they are translated later, download the page again to update the query.
* Added the `smartling_target_id` filter, which returns the translated ID of a post, attachment or term for use in custom code.
* Fixed Elementor query terms being dropped from related content when the same taxonomy is also detected elsewhere in the document.
diff --git a/tests/IntegrationTests/tests/SubmissionUploadTest.php b/tests/IntegrationTests/tests/SubmissionUploadTest.php
index e503ea1e4..c3c97708c 100644
--- a/tests/IntegrationTests/tests/SubmissionUploadTest.php
+++ b/tests/IntegrationTests/tests/SubmissionUploadTest.php
@@ -39,6 +39,9 @@ public function testUploadMultipleTargets()
[],
$targetBlogs,
new JobInformation($job['translationJobUid'], false, $jobName, '', '', ''),
+ [],
+ '',
+ $profile->getId(),
));
$submissions = $submissionManager->find([SubmissionEntity::FIELD_SOURCE_ID => $postId]);
$this->assertCount(2, $submissions, 'Expected two new submissions to be created');
@@ -88,6 +91,9 @@ public function testUploadAttachment()
[2 => ['attachment' => [$attachmentId]]],
$targetBlogs,
new JobInformation($job['translationJobUid'], false, $jobName, '', '', ''),
+ [],
+ '',
+ $profile->getId(),
));
$this->assertCount($existingSubmissionCount + 2, $submissionManager->find([1 => 1]));
// findOne returns null on multiple submissions
diff --git a/tests/Models/TranslationRequestTest.php b/tests/Models/TranslationRequestTest.php
index 6084683a9..4553b26f7 100644
--- a/tests/Models/TranslationRequestTest.php
+++ b/tests/Models/TranslationRequestTest.php
@@ -35,6 +35,7 @@ public function testFromArray()
2 => [$targetBlogId => ['attachment' => [5]]],
],
'targetBlogIds' => (string)$targetBlogId,
+ 'profileId' => 9,
]);
$this->assertEquals($sourceId, $x->getContentId());
$this->assertEquals($sourceContentType, $x->getContentType());
@@ -45,6 +46,48 @@ public function testFromArray()
$this->assertEquals($jobName, $x->getJobInformation()->getName());
$this->assertEquals($jobTimeZone, $x->getJobInformation()->getTimeZone());
$this->assertEquals($jobUid, $x->getJobInformation()->getId());
+ $this->assertEquals(9, $x->getProfileId());
+ }
+
+ public function testFromArrayProfileIdIsOptional()
+ {
+ $array = $this->buildArray();
+ unset($array['profileId']);
+ self::assertNull(UserTranslationRequest::fromArray($array)->getProfileId());
+ }
+
+ public function testFromArrayRejectsInvalidProfileId()
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $array = $this->buildArray();
+ $array['profileId'] = 'abc';
+ UserTranslationRequest::fromArray($array);
+ }
+
+ public function testParseProfileIdAcceptsMissingValue()
+ {
+ self::assertNull(UserTranslationRequest::parseProfileId(null));
+ self::assertNull(UserTranslationRequest::parseProfileId(''));
+ }
+
+ public function testParseProfileIdAcceptsPositiveInteger()
+ {
+ self::assertSame(5, UserTranslationRequest::parseProfileId('5'));
+ self::assertSame(5, UserTranslationRequest::parseProfileId(5));
+ }
+
+ /**
+ * @dataProvider invalidProfileIdProvider
+ */
+ public function testParseProfileIdRejectsInvalidValues(mixed $value)
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ UserTranslationRequest::parseProfileId($value);
+ }
+
+ public static function invalidProfileIdProvider(): array
+ {
+ return [['abc'], ['0'], [0], [-3], ['-3'], ['1.5'], [[5]]];
}
public function testFromArrayBulkUploadWithEmptySourceId()
@@ -65,6 +108,7 @@ public function testFromArrayBulkUploadWithEmptySourceId()
'relations' => [],
'targetBlogIds' => (string)$targetBlogId,
'ids' => $ids,
+ 'profileId' => 9,
]);
$this->assertTrue($x->isBulk());
$this->assertEquals($ids, $x->getIds());
@@ -104,6 +148,7 @@ private function buildArray(array $overrides = []): array
'source' => ['id' => [5], 'contentType' => 'post'],
'relations' => [],
'targetBlogIds' => '2',
+ 'profileId' => 9,
], $overrides);
}
}
diff --git a/tests/Services/ContentRelationsDiscoveryServiceTest.php b/tests/Services/ContentRelationsDiscoveryServiceTest.php
index fa8769fba..1ae28bbce 100644
--- a/tests/Services/ContentRelationsDiscoveryServiceTest.php
+++ b/tests/Services/ContentRelationsDiscoveryServiceTest.php
@@ -59,6 +59,7 @@ function apply_filters($a, ...$b) {
use Smartling\Services\ContentRelationsDiscoveryService;
use Smartling\Services\ContentRelationsHandler;
use Smartling\Settings\ConfigurationProfileEntity;
+ use Smartling\Settings\Locale;
use Smartling\Settings\SettingsManager;
use Smartling\Submissions\SubmissionEntity;
use Smartling\Submissions\SubmissionFactory;
@@ -109,7 +110,7 @@ public function testCreateSubmissionsHandler()
});
$settingsManager = $this->createMock(SettingsManager::class);
- $settingsManager->method('getSingleSettingsProfile')->willReturn($profile);
+ $settingsManager->method('resolveRequestedProfile')->willReturn($profile);
$siteHelper = $this->createMock(SiteHelper::class);
$siteHelper->method('getCurrentBlogId')->willReturn($sourceBlogId);
@@ -151,6 +152,7 @@ public function testCreateSubmissionsHandler()
],
'targetBlogIds' => $targetBlogId,
'relations' => [],
+ 'profileId' => 5,
]));
}
@@ -176,7 +178,7 @@ public function testCreateSubmissionsHandlerStampsConfigurationProfileOnExisting
$apiWrapper = $this->createMock(ApiWrapper::class);
$settingsManager = $this->createMock(SettingsManager::class);
- $settingsManager->method('getSingleSettingsProfile')->willReturn($profile);
+ $settingsManager->method('resolveRequestedProfile')->with(5, $sourceBlogId)->willReturn($profile);
$siteHelper = $this->createMock(SiteHelper::class);
$siteHelper->method('getCurrentBlogId')->willReturn($sourceBlogId);
@@ -210,6 +212,124 @@ public function testCreateSubmissionsHandlerStampsConfigurationProfileOnExisting
],
'targetBlogIds' => $targetBlogId,
'relations' => [],
+ 'profileId' => 5,
+ ]));
+ }
+
+ public function testCreateSubmissionsStampsFallbackProfileWhenNoneCouldBeResolved()
+ {
+ $sourceBlogId = 1;
+ $sourceId = 48;
+
+ $activeProfile = $this->createMock(ConfigurationProfileEntity::class);
+ $activeProfile->method('getProjectId')->willReturn('activeProjectUid');
+ $activeProfile->method('getId')->willReturn(3);
+
+ $apiWrapper = $this->createMock(ApiWrapper::class);
+ $apiWrapper->expects($this->once())->method('createAuditLogRecord')->willReturnCallback(
+ function (ConfigurationProfileEntity $configurationProfile) use ($activeProfile): void {
+ $this->assertSame($activeProfile, $configurationProfile);
+ },
+ );
+
+ $settingsManager = $this->createMock(SettingsManager::class);
+ $settingsManager->method('resolveRequestedProfile')->with(null, $sourceBlogId)->willReturn($activeProfile);
+
+ $siteHelper = $this->createMock(SiteHelper::class);
+ $siteHelper->method('getCurrentBlogId')->willReturn($sourceBlogId);
+
+ $contentHelper = $this->createMock(ContentHelper::class);
+ $contentHelper->method('getSiteHelper')->willReturn($siteHelper);
+
+ $submission = $this->createMock(SubmissionEntity::class);
+ $submission->method('getId')->willReturn(17);
+ $submission->expects(self::once())->method('setConfigurationProfileId')->with(3);
+
+ $submissionManager = $this->getMockBuilder(SubmissionManager::class)->disableOriginalConstructor()->getMock();
+ $submissionManager->method('findOne')->willReturn($submission);
+ $submissionManager->method('storeEntity')->willReturnArgument(0);
+
+ $wpProxy = $this->createMock(WordpressFunctionProxyHelper::class);
+ $wpProxy->method('get_current_blog_id')->willReturn($sourceBlogId);
+
+ $x = $this->getContentRelationDiscoveryService($apiWrapper, $contentHelper, $settingsManager, $submissionManager, wpProxy: $wpProxy);
+
+ $x->createSubmissions(UserTranslationRequest::fromArray([
+ 'source' => ['contentType' => 'post', 'id' => [$sourceId]],
+ 'job' =>
+ [
+ 'id' => 'abcdef123456',
+ 'name' => 'Job Name',
+ 'description' => '',
+ 'dueDate' => '',
+ 'timeZone' => 'Europe/Kiev',
+ 'authorize' => 'true',
+ ],
+ 'targetBlogIds' => 2,
+ 'relations' => [],
+ ]));
+ }
+
+ public function testCreateSubmissionsUsesRequestedProfileWhenValidAndActive()
+ {
+ $sourceBlogId = 1;
+ $sourceId = 48;
+ $contentType = 'post';
+ $targetBlogId = 2;
+ $jobName = 'Job Name';
+ $jobUid = 'abcdef123456';
+ $requestedProfileId = 5;
+
+ $sourceLocale = new Locale();
+ $sourceLocale->setBlogId($sourceBlogId);
+ $requestedProfile = $this->createMock(ConfigurationProfileEntity::class);
+ $requestedProfile->method('getSourceLocale')->willReturn($sourceLocale);
+ $requestedProfile->method('getIsActive')->willReturn(1);
+ $requestedProfile->method('getProjectId')->willReturn('requestedProjectUid');
+
+ $apiWrapper = $this->createMock(ApiWrapper::class);
+ $apiWrapper->expects($this->once())->method('createAuditLogRecord')->willReturnCallback(
+ function (ConfigurationProfileEntity $configurationProfile) use ($requestedProfile): void {
+ $this->assertSame($requestedProfile, $configurationProfile);
+ },
+ );
+
+ $settingsManager = $this->createMock(SettingsManager::class);
+ $settingsManager->method('resolveRequestedProfile')->with($requestedProfileId, $sourceBlogId)->willReturn($requestedProfile);
+ $settingsManager->expects(self::never())->method('getSingleSettingsProfile');
+
+ $siteHelper = $this->createMock(SiteHelper::class);
+ $siteHelper->method('getCurrentBlogId')->willReturn($sourceBlogId);
+
+ $contentHelper = $this->createMock(ContentHelper::class);
+ $contentHelper->method('getSiteHelper')->willReturn($siteHelper);
+
+ $submission = $this->createMock(SubmissionEntity::class);
+ $submission->method('getId')->willReturn(17);
+
+ $submissionManager = $this->getMockBuilder(SubmissionManager::class)->disableOriginalConstructor()->getMock();
+ $submissionManager->method('findOne')->willReturn($submission);
+ $submissionManager->method('storeEntity')->willReturnArgument(0);
+
+ $wpProxy = $this->createMock(WordpressFunctionProxyHelper::class);
+ $wpProxy->method('get_current_blog_id')->willReturn($sourceBlogId);
+
+ $x = $this->getContentRelationDiscoveryService($apiWrapper, $contentHelper, $settingsManager, $submissionManager, wpProxy: $wpProxy);
+
+ $x->createSubmissions(UserTranslationRequest::fromArray([
+ 'source' => ['contentType' => $contentType, 'id' => [$sourceId]],
+ 'job' =>
+ [
+ 'id' => $jobUid,
+ 'name' => $jobName,
+ 'description' => '',
+ 'dueDate' => '',
+ 'timeZone' => 'Europe/Kiev',
+ 'authorize' => 'true',
+ ],
+ 'targetBlogIds' => $targetBlogId,
+ 'relations' => [],
+ 'profileId' => $requestedProfileId,
]));
}
@@ -272,6 +392,7 @@ function (ConfigurationProfileEntity $profile, string $jobUid, array $fileUris):
],
'targetBlogIds' => $targetBlogId,
'relations' => [$targetBlogId => ['post' => [17], 'attachment' => [23]]],
+ 'profileId' => 5,
]));
}
public function testBulkSubmitHandler()
@@ -291,7 +412,7 @@ public function testBulkSubmitHandler()
$profile->method('getProjectId')->willReturn($projectUid);
$settingsManager = $this->createMock(SettingsManager::class);
- $settingsManager->method('getSingleSettingsProfile')->willReturn($profile);
+ $settingsManager->method('resolveRequestedProfile')->willReturn($profile);
$siteHelper = $this->createMock(SiteHelper::class);
$siteHelper->method('getCurrentBlogId')->willReturn($sourceBlogId);
@@ -361,6 +482,7 @@ public function testBulkSubmitHandler()
],
'targetBlogIds' => $targetBlogId,
'ids' => $sourceIds,
+ 'profileId' => 5,
]));
}
@@ -387,7 +509,7 @@ public function testBulkSubmitHandlerStampsConfigurationProfile()
$profile->method('getId')->willReturn($profileId);
$settingsManager = $this->createMock(SettingsManager::class);
- $settingsManager->method('getSingleSettingsProfile')->willReturn($profile);
+ $settingsManager->method('resolveRequestedProfile')->with(5, $sourceBlogId)->willReturn($profile);
$siteHelper = $this->createMock(SiteHelper::class);
$siteHelper->method('getCurrentBlogId')->willReturn($sourceBlogId);
@@ -427,6 +549,7 @@ public function testBulkSubmitHandlerStampsConfigurationProfile()
],
'targetBlogIds' => $targetBlogId,
'ids' => $sourceIds,
+ 'profileId' => 5,
]));
}
@@ -468,7 +591,7 @@ public function testExistingMenuItemsGetSubmittedOnExistingMenuBulkSubmit()
$profile->method('getProjectId')->willReturn($projectUid);
$settingsManager = $this->createMock(SettingsManager::class);
- $settingsManager->method('getSingleSettingsProfile')->willReturn($profile);
+ $settingsManager->method('resolveRequestedProfile')->willReturn($profile);
$siteHelper = $this->createMock(SiteHelper::class);
$siteHelper->method('getCurrentBlogId')->willReturn($sourceBlogId);
@@ -561,7 +684,7 @@ public function testJobInfoGetsStoredOnNewSubmissions()
$profile->method('getProjectId')->willReturn($projectUid);
$settingsManager = $this->createMock(SettingsManager::class);
- $settingsManager->method('getSingleSettingsProfile')->willReturn($profile);
+ $settingsManager->method('resolveRequestedProfile')->willReturn($profile);
$siteHelper = $this->createMock(SiteHelper::class);
$siteHelper->method('getCurrentBlogId')->willReturn($sourceBlogId);
@@ -613,6 +736,7 @@ public function testJobInfoGetsStoredOnNewSubmissions()
],
'targetBlogIds' => $targetBlogId,
'relations' => [],
+ 'profileId' => 5,
]));
$this->restoreDependencyInjection();
}
@@ -743,7 +867,7 @@ public function testRelatedItemsSentForTranslation()
$profile->method('getProjectId')->willReturn($projectUid);
$settingsManager = $this->createMock(SettingsManager::class);
- $settingsManager->method('getSingleSettingsProfile')->willReturn($profile);
+ $settingsManager->method('resolveRequestedProfile')->willReturn($profile);
$siteHelper = $this->createMock(SiteHelper::class);
$siteHelper->method('getCurrentBlogId')->willReturn($sourceBlogId);
@@ -804,6 +928,7 @@ public function testRelatedItemsSentForTranslation()
'source' => ['id' => [$sourceId], 'contentType' => $contentType],
'relations' => [$targetBlogId => ['post' => [$depth1AttachmentId], 'attachment' => [$depth2AttachmentId]]],
'targetBlogIds' => (string)$targetBlogId,
+ 'profileId' => 5,
]));
if ($this->exception !== null) {
throw $this->exception;
diff --git a/tests/Services/ContentRelationsHandlerTest.php b/tests/Services/ContentRelationsHandlerTest.php
index f75a2a779..f9c5d80bf 100644
--- a/tests/Services/ContentRelationsHandlerTest.php
+++ b/tests/Services/ContentRelationsHandlerTest.php
@@ -3,6 +3,8 @@
namespace Smartling\Tests\Services;
use PHPUnit\Framework\TestCase;
+use Smartling\Exception\SmartlingDbException;
+use Smartling\Exception\SmartlingHumanReadableException;
use Smartling\Helpers\AjaxSecurityChecker;
use Smartling\Helpers\ArrayHelper;
use Smartling\Helpers\WordpressFunctionProxyHelper;
@@ -103,6 +105,75 @@ public function returnError($key, $message, $responseCode = 400): void
$this->assertSame(403, $x->capturedErrorCode);
}
+ /**
+ * A SmartlingHumanReadableException thrown while resolving the profile or validating target blogs must surface
+ * its own key/message/response code to the client, not be swallowed into a generic failure.
+ */
+ public function testCreateSubmissionsHandlerMapsHumanReadableExceptionToItsOwnKeyAndCode(): void
+ {
+ $service = $this->createMock(ContentRelationsDiscoveryService::class);
+ $service->method('createSubmissions')->willThrowException(
+ new SmartlingHumanReadableException('Invalid target locale for selected profile', 'target.blog.invalid', 400)
+ );
+ $proxy = $this->makeWpProxy();
+
+ $x = new class($service, $proxy, new AjaxSecurityChecker($proxy)) extends ContentRelationsHandler {
+ public ?string $capturedErrorKey = null;
+ public ?string $capturedErrorMessage = null;
+ public ?int $capturedErrorCode = null;
+
+ public function returnResponse(array $data, $responseCode = 200): void
+ {
+ TestCase::fail('Should not return a success response');
+ }
+
+ public function returnError($key, $message, $responseCode = 400): void
+ {
+ $this->capturedErrorKey = $key;
+ $this->capturedErrorMessage = $message;
+ $this->capturedErrorCode = $responseCode;
+ }
+ };
+
+ $x->createSubmissionsHandler($this->buildData(['source' => ['id' => [1], 'contentType' => 'post'], 'targetBlogIds' => '2']));
+
+ $this->assertSame('target.blog.invalid', $x->capturedErrorKey);
+ $this->assertSame('Invalid target locale for selected profile', $x->capturedErrorMessage);
+ $this->assertSame(400, $x->capturedErrorCode);
+ }
+
+ /**
+ * A SmartlingDbException from unrelated code (DB layer, queue, content handlers, etc.) reachable from
+ * createSubmissions() must not be mislabeled as a profile error - its real message must still reach the client.
+ */
+ public function testCreateSubmissionsHandlerPreservesMessageForUnrelatedDbException(): void
+ {
+ $service = $this->createMock(ContentRelationsDiscoveryService::class);
+ $service->method('createSubmissions')->willThrowException(new SmartlingDbException('Queue table is locked'));
+ $proxy = $this->makeWpProxy();
+
+ $x = new class($service, $proxy, new AjaxSecurityChecker($proxy)) extends ContentRelationsHandler {
+ public ?string $capturedErrorKey = null;
+ public ?string $capturedErrorMessage = null;
+
+ public function returnResponse(array $data, $responseCode = 200): void
+ {
+ TestCase::fail('Should not return a success response');
+ }
+
+ public function returnError($key, $message, $responseCode = 400): void
+ {
+ $this->capturedErrorKey = $key;
+ $this->capturedErrorMessage = $message;
+ }
+ };
+
+ $x->createSubmissionsHandler($this->buildData(['source' => ['id' => [1], 'contentType' => 'post'], 'targetBlogIds' => '2']));
+
+ $this->assertSame('content.submission.failed', $x->capturedErrorKey);
+ $this->assertSame('Queue table is locked', $x->capturedErrorMessage);
+ }
+
private function buildData(array $overrides = []): array
{
return array_merge([
@@ -115,6 +186,7 @@ private function buildData(array $overrides = []): array
'timeZone' => 'Europe/Kyiv',
'authorize' => 'true',
],
+ 'profileId' => 5,
], $overrides);
}
}
diff --git a/tests/Smartling/Helpers/MetaFieldProcessor/ReferencedContentProcessorTest.php b/tests/Smartling/Helpers/MetaFieldProcessor/ReferencedContentProcessorTest.php
new file mode 100644
index 000000000..eea8cbe98
--- /dev/null
+++ b/tests/Smartling/Helpers/MetaFieldProcessor/ReferencedContentProcessorTest.php
@@ -0,0 +1,90 @@
+createMock(ContentHelper::class),
+ $this->getMockBuilder(SubmissionManager::class)->disableOriginalConstructor()->getMock(),
+ $translationHelper,
+ '/.*/',
+ 'post',
+ );
+ }
+
+ private function parentSubmission(?int $profileId): SubmissionEntity
+ {
+ $submission = (new SubmissionEntity())
+ ->setSourceBlogId(1)
+ ->setTargetBlogId(2)
+ ->setConfigurationProfileId($profileId);
+ $submission->setJobInfo(new JobEntity('Test job', 'jobUid', 'projectUid'));
+
+ return $submission;
+ }
+
+ /**
+ * Related content created while processing a submission must be stamped with the parent submission's own
+ * profile, not whatever the source blog's default happens to be - otherwise related content silently ends up
+ * under a different Smartling project than the one the user actually requested.
+ */
+ public function testProcessFieldPreTranslationPassesParentSubmissionProfileIdToRelatedContent(): void
+ {
+ $parent = $this->parentSubmission(7);
+
+ $translationHelper = $this->createMock(TranslationHelper::class);
+ $translationHelper->method('isRelatedSubmissionCreationNeeded')->willReturn(true);
+ $translationHelper->expects($this->once())
+ ->method('tryPrepareRelatedContent')
+ ->with(
+ 'post',
+ 1,
+ 42,
+ 2,
+ $this->isInstanceOf(JobEntityWithBatchUid::class),
+ false,
+ 7,
+ )
+ ->willReturn($parent);
+
+ $processor = $this->getProcessor($translationHelper);
+
+ $processor->processFieldPreTranslation($parent, 'some_field', 42, []);
+ }
+
+ public function testProcessFieldPreTranslationPassesNullProfileIdWhenParentHasNone(): void
+ {
+ $parent = $this->parentSubmission(null);
+
+ $translationHelper = $this->createMock(TranslationHelper::class);
+ $translationHelper->method('isRelatedSubmissionCreationNeeded')->willReturn(true);
+ $translationHelper->expects($this->once())
+ ->method('tryPrepareRelatedContent')
+ ->with(
+ 'post',
+ 1,
+ 42,
+ 2,
+ $this->isInstanceOf(JobEntityWithBatchUid::class),
+ false,
+ null,
+ )
+ ->willReturn($parent);
+
+ $processor = $this->getProcessor($translationHelper);
+
+ $processor->processFieldPreTranslation($parent, 'some_field', 42, []);
+ }
+}
diff --git a/tests/Smartling/Settings/SettingsManagerTest.php b/tests/Smartling/Settings/SettingsManagerTest.php
index 1fb5c201b..511e5dd46 100644
--- a/tests/Smartling/Settings/SettingsManagerTest.php
+++ b/tests/Smartling/Settings/SettingsManagerTest.php
@@ -7,6 +7,7 @@
use Smartling\DbAl\SmartlingToCMSDatabaseAccessWrapperInterface;
use Smartling\Exception\SmartlingConfigException;
use Smartling\Exception\SmartlingDbException;
+use Smartling\Exception\SmartlingHumanReadableException;
use Smartling\Settings\ConfigurationProfileEntity;
use Smartling\Settings\Locale;
use Smartling\Settings\SettingsManager;
@@ -178,6 +179,122 @@ public function testGetProfileBySubmissionFallsBackWhenStoredProfileWasDeleted()
self::assertSame($active, $mock->getProfileBySubmission($submission));
}
+ private function resolverMock(): SettingsManager
+ {
+ $mock = $this->createPartialMock(SettingsManager::class, ['getEntityById', 'findEntityByMainLocale', 'getLogger']);
+ $mock->method('getLogger')->willReturn(new NullLogger());
+
+ return $mock;
+ }
+
+ private function profileForBlog(int $id, int $blogId, int $active): ConfigurationProfileEntity
+ {
+ $profile = $this->profileWithId($id);
+ $locale = new Locale();
+ $locale->setBlogId($blogId);
+ $profile->setSourceLocale($locale);
+ $profile->setIsActive($active);
+
+ return $profile;
+ }
+
+ public function testResolveRequestedProfileUsesRequestedWhenValidAndActive()
+ {
+ $requested = $this->profileForBlog(5, 1, 1);
+ $mock = $this->resolverMock();
+ $mock->expects(self::once())->method('getEntityById')->with(5)->willReturn([$requested]);
+ $mock->expects(self::never())->method('findEntityByMainLocale');
+
+ self::assertSame($requested, $mock->resolveRequestedProfile(5, 1));
+ }
+
+ public function testResolveRequestedProfileRejectsUnknownProfile()
+ {
+ $this->expectException(SmartlingHumanReadableException::class);
+ $mock = $this->resolverMock();
+ $mock->method('getEntityById')->with(5)->willReturn([]);
+
+ $mock->resolveRequestedProfile(5, 1);
+ }
+
+ public function testResolveRequestedProfileRejectsProfileOfDifferentBlog()
+ {
+ $this->expectException(SmartlingHumanReadableException::class);
+ $mock = $this->resolverMock();
+ $mock->method('getEntityById')->with(5)->willReturn([$this->profileForBlog(5, 99, 1)]);
+
+ $mock->resolveRequestedProfile(5, 1);
+ }
+
+ public function testResolveRequestedProfileRejectsInactiveProfile()
+ {
+ $this->expectException(SmartlingHumanReadableException::class);
+ $mock = $this->resolverMock();
+ $mock->method('getEntityById')->with(5)->willReturn([$this->profileForBlog(5, 1, 0)]);
+
+ $mock->resolveRequestedProfile(5, 1);
+ }
+
+ public function testResolveRequestedProfileFallsBackToActiveProfileWhenNoneRequested()
+ {
+ $only = $this->profileForBlog(3, 1, 1);
+ $mock = $this->resolverMock();
+ $mock->expects(self::never())->method('getEntityById');
+ $mock->method('findEntityByMainLocale')->with(1)->willReturn([$only]);
+
+ self::assertSame($only, $mock->resolveRequestedProfile(null, 1));
+ }
+
+ public function testResolveRequestedProfileThrowsWhenNoneRequestedAndNoneActive()
+ {
+ $this->expectException(SmartlingHumanReadableException::class);
+ $mock = $this->resolverMock();
+ $mock->method('findEntityByMainLocale')->with(1)->willReturn([]);
+
+ $mock->resolveRequestedProfile(null, 1);
+ }
+
+ public function testAssertTargetBlogIdsBelongToProfileAllowsEnabledLocales()
+ {
+ $profile = $this->profileWithId(1);
+ $profile->setTargetLocales([$this->enabledLocale(2), $this->enabledLocale(3)]);
+ $mock = $this->resolverMock();
+
+ $mock->assertTargetBlogIdsBelongToProfile($profile, [2, 3]);
+ $this->addToAssertionCount(1);
+ }
+
+ public function testAssertTargetBlogIdsBelongToProfileRejectsBlogOutsideProfile()
+ {
+ $this->expectException(SmartlingHumanReadableException::class);
+ $profile = $this->profileWithId(1);
+ $profile->setTargetLocales([$this->enabledLocale(2)]);
+ $mock = $this->resolverMock();
+
+ $mock->assertTargetBlogIdsBelongToProfile($profile, [2, 99]);
+ }
+
+ public function testAssertTargetBlogIdsBelongToProfileRejectsDisabledLocale()
+ {
+ $this->expectException(SmartlingHumanReadableException::class);
+ $profile = $this->profileWithId(1);
+ $disabled = $this->enabledLocale(2);
+ $disabled->setEnabled(false);
+ $profile->setTargetLocales([$disabled]);
+ $mock = $this->resolverMock();
+
+ $mock->assertTargetBlogIdsBelongToProfile($profile, [2]);
+ }
+
+ private function enabledLocale(int $blogId): TargetLocale
+ {
+ $locale = new TargetLocale();
+ $locale->setBlogId($blogId);
+ $locale->setEnabled(true);
+
+ return $locale;
+ }
+
public function testGetEntitiesQueries()
{
$db = $this->createMock(SmartlingToCMSDatabaseAccessWrapperInterface::class);
diff --git a/tests/Smartling/Submissions/SubmissionManagerTest.php b/tests/Smartling/Submissions/SubmissionManagerTest.php
index 325b9849c..c753dbe65 100644
--- a/tests/Smartling/Submissions/SubmissionManagerTest.php
+++ b/tests/Smartling/Submissions/SubmissionManagerTest.php
@@ -171,14 +171,25 @@ public function testGetSubmissionEntityStoresProfileOnNewSubmission()
$this->assertSame(9, $entity->getConfigurationProfileId());
}
- public function testGetSubmissionEntityRefreshesProfileOnExistingSubmission()
+ public function testGetSubmissionEntityKeepsProfileOnExistingSubmission()
{
$settingsManager = $this->createMock(SettingsManager::class);
- $settingsManager->method('getSingleSettingsProfile')->willReturn($this->profileWithId(9));
+ $settingsManager->expects($this->never())->method('getSingleSettingsProfile');
$existing = (new SubmissionEntity())->setSourceBlogId(1)->setConfigurationProfileId(4);
$entity = $this->getManagerForProfileStamping($settingsManager, [$existing])->getSubmissionEntity('post', 1, 5, 2);
+ $this->assertSame(4, $entity->getConfigurationProfileId());
+ }
+
+ public function testGetSubmissionEntityStampsExplicitProfileIdEvenOnExistingSubmission()
+ {
+ $settingsManager = $this->createMock(SettingsManager::class);
+ $settingsManager->expects($this->never())->method('getSingleSettingsProfile');
+ $existing = (new SubmissionEntity())->setSourceBlogId(1)->setConfigurationProfileId(4);
+
+ $entity = $this->getManagerForProfileStamping($settingsManager, [$existing])->getSubmissionEntity('post', 1, 5, 2, null, null, 9);
+
$this->assertSame(9, $entity->getConfigurationProfileId());
}
diff --git a/tests/Smartling/WP/Controller/InstantTranslationControllerTest.php b/tests/Smartling/WP/Controller/InstantTranslationControllerTest.php
index b2516ca64..7b7254f10 100644
--- a/tests/Smartling/WP/Controller/InstantTranslationControllerTest.php
+++ b/tests/Smartling/WP/Controller/InstantTranslationControllerTest.php
@@ -7,6 +7,7 @@
use Smartling\Helpers\AjaxSecurityChecker;
use Smartling\Helpers\FileUriHelper;
use Smartling\Helpers\WordpressFunctionProxyHelper;
+use Smartling\Settings\SettingsManager;
use Smartling\Submissions\SubmissionEntity;
use Smartling\Submissions\SubmissionFactory;
use Smartling\Submissions\SubmissionManager;
@@ -39,6 +40,7 @@ protected function setUp(): void
$this->fileUriHelper,
$this->wpProxy,
$this->ajaxSecurity,
+ $this->createMock(SettingsManager::class),
);
}
@@ -149,7 +151,7 @@ public function testGetOrCreateSubmissionCreatesNew(): void
// Manager should store it
$this->submissionManager->method('storeEntity')->willReturn($newSubmission);
- $result = $method->invoke($this->controller, 1, 2, 'post', 123);
+ $result = $method->invoke($this->controller, 1, 2, 'post', 123, 7);
$this->assertInstanceOf(SubmissionEntity::class, $result);
}
@@ -167,7 +169,7 @@ public function testGetOrCreateSubmissionReusesExisting(): void
$this->submissionManager->method('findOne')->willReturn($existingSubmission);
$this->submissionManager->method('storeEntity')->willReturn($existingSubmission);
- $result = $method->invoke($this->controller, 1, 2, 'post', 123);
+ $result = $method->invoke($this->controller, 1, 2, 'post', 123, 7);
$this->assertInstanceOf(SubmissionEntity::class, $result);
$this->assertSame($existingSubmission, $result);
@@ -196,7 +198,8 @@ public function testBuildSubmissionsWithNoRelations(): void
123,
1,
[2, 3],
- [] // No relations
+ [], // No relations
+ 7
);
// Should create 2 submissions (1 main content × 2 target blogs)
@@ -235,7 +238,8 @@ public function testBuildSubmissionsWithRelations(): void
123,
1,
[2, 3],
- $relations
+ $relations,
+ 7
);
// Should create:
@@ -275,7 +279,8 @@ public function testBuildSubmissionsExcludesMainContentFromRelations(): void
123,
1,
[2],
- $relations
+ $relations,
+ 7
);
// Should create:
@@ -515,4 +520,19 @@ public function testHandlePollStatusDoesNothingWhenUnauthorized(): void
$this->controller->handlePollStatus();
}
+
+ public function testGetOrCreateSubmissionStampsRequestedProfile(): void
+ {
+ $reflection = new \ReflectionClass($this->controller);
+ $method = $reflection->getMethod('getOrCreateSubmission');
+ $method->setAccessible(true);
+
+ $existingSubmission = $this->createMock(SubmissionEntity::class);
+ $existingSubmission->method('setStatus')->willReturnSelf();
+ $existingSubmission->expects($this->once())->method('setConfigurationProfileId')->with(7);
+ $this->submissionManager->method('findOne')->willReturn($existingSubmission);
+ $this->submissionManager->method('storeEntity')->willReturn($existingSubmission);
+
+ $method->invoke($this->controller, 1, 2, 'post', 123, 7);
+ }
}
diff --git a/tests/playwright/job-wizard.spec.js b/tests/playwright/job-wizard.spec.js
index 2a580027d..50b219da7 100644
--- a/tests/playwright/job-wizard.spec.js
+++ b/tests/playwright/job-wizard.spec.js
@@ -19,16 +19,19 @@ test.describe('Job wizard — post edit page', () => {
expect(nonce.length, 'data-nonce must be at least 8 characters').toBeGreaterThanOrEqual(8);
});
- test('#smartling-app has valid JSON in data-locales', async ({ page }) => {
+ test('#smartling-app has valid JSON in data-profiles', async ({ page }) => {
await page.goto(`/wp-admin/post.php?post=${POST_ID}&action=edit`, { waitUntil: 'commit' });
await page.waitForSelector('#smartling-app', { state: 'attached', timeout: 90000 });
- const localesRaw = await page.getAttribute('#smartling-app', 'data-locales');
- expect(localesRaw, 'data-locales attribute must be present').toBeTruthy();
+ const profilesRaw = await page.getAttribute('#smartling-app', 'data-profiles');
+ expect(profilesRaw, 'data-profiles attribute must be present').toBeTruthy();
- let locales;
- expect(() => { locales = JSON.parse(localesRaw); }, 'data-locales must be valid JSON').not.toThrow();
- expect(Array.isArray(locales), 'data-locales must decode to an array').toBe(true);
+ let profiles;
+ expect(() => { profiles = JSON.parse(profilesRaw); }, 'data-profiles must be valid JSON').not.toThrow();
+ expect(Array.isArray(profiles), 'data-profiles must decode to an array').toBe(true);
+ for (const profile of profiles) {
+ expect(Array.isArray(profile.locales), 'each profile must have a locales array').toBe(true);
+ }
});
test('React job wizard renders job tabs', async ({ page }) => {