Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
df19959
make UserTranslationRequest.profileId a required field (WP-1022)
vsolovei-smartling Oct 6, 2026
36084e4
resolve the user-requested profile in createSubmissions() (WP-1022)
vsolovei-smartling Oct 6, 2026
512a55a
honor user-requested profile in smartling_job_api_proxy (WP-1022)
vsolovei-smartling Oct 6, 2026
0e82d57
let users pick the translation profile in the job wizard UI (WP-1022)
vsolovei-smartling Oct 6, 2026
71d7e48
auto-refresh the download widget after queuing an upload (WP-1022)
vsolovei-smartling Oct 6, 2026
59ecdc8
resolve requested profile centrally and harden profile selection (WP-…
vsolovei-smartling Oct 7, 2026
559dc45
always stamp the resolved profile on submissions (WP-1022)
vsolovei-smartling Oct 7, 2026
151c345
move parameter down, inline variable (WP-1022)
vsolovei-smartling Oct 7, 2026
e2fd2ba
unify profile resolution, fix job list race, hide profile errors from…
vsolovei-smartling Oct 7, 2026
5735049
use promoted properties (WP-1022)
vsolovei-smartling Oct 7, 2026
5847c8d
address review: profile resolution, validation, exception mapping (WP…
vsolovei-smartling Oct 8, 2026
ab8892e
address review: widget refresh and profile-switch UI fixes (WP-1022)
vsolovei-smartling Oct 8, 2026
50715fc
address review: drop widget auto-refresh, fix profile inference and r…
vsolovei-smartling Oct 9, 2026
ad3e9a7
Merge branch 'master' into WP-1022-choose-profile-on-request
vsolovei-smartling Oct 10, 2026
7c91702
add changelog entry (WP-1022)
vsolovei-smartling Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions inc/Smartling/Base/SmartlingCoreExportApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,16 @@ 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',
$sourceBlogId,
$sourceId,
$targetBlogId,
$jobInfo,
$clone
$clone,
$profileId,
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ public function processFieldPreTranslation(
$targetBlogId,
JobEntityWithBatchUid::fromJob($submission->getJobInfo(), ''),
$submission->isCloned(),
$submission->getConfigurationProfileId(),
);
}

Expand Down
21 changes: 12 additions & 9 deletions inc/Smartling/Helpers/TranslationHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -96,7 +96,8 @@ public function prepareSubmissionEntity(string $contentType, int $sourceBlog, in
$sourceEntity,
$targetBlog,
$this->multilangProxy,
$targetEntity
$targetEntity,
$profileId,
);
}

Expand All @@ -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.');
Expand All @@ -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));
Expand Down Expand Up @@ -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;
}
Expand All @@ -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()
Expand Down
41 changes: 32 additions & 9 deletions inc/Smartling/Models/UserTranslationRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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;
Expand All @@ -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),
);
}

Expand Down Expand Up @@ -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');
Expand Down
41 changes: 29 additions & 12 deletions inc/Smartling/Services/ContentRelationsDiscoveryService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] targetBlogIds aren't checked against the resolved profile's enabled target locales. A blog outside profile B (stale tab, crafted request, or a locale picked in the side widget, which now merges all profiles) is accepted. getSmartlingLocaleIdBySettingsProfile() then returns '', so the upload fails later, away from the request. Can we reject it here with a 400? The same applies to create-job (ContentEditJobController.php:161) and instant translation (InstantTranslationController.php:58).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added SettingsManager::assertTargetBlogIdsBelongToProfile(), called from createSubmissions() (covers both the bulk and non-bulk paths), ContentEditJobController's create-job case, InstantTranslationController::handleRequestTranslation(), and the legacy widget's ajaxUploadHandler(). All four now reject with a 400 before doing any work instead of silently producing an empty Smartling locale.

(5847c8d)

$this->settingsManager->assertTargetBlogIdsBelongToProfile($profile, $request->getTargetBlogIds());
$job = $request->getJobInformation();
$jobInfo = new JobEntity($job->getName(), $job->getId(), $profile->getProjectId());

Expand Down Expand Up @@ -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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] (also applies to :110 and InstantTranslationController.php:323) Re-stamping an existing submission unconditionally means content that is IN_PROGRESS in project A and resubmitted under B loses its link to A's file and job. Status checks and download then go to B. Should we block or warn when the stored profile differs and the submission isn't completed or failed yet?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a warning (not a block, since this is a resubmission the user explicitly asked for) when an existing submission is still IN_PROGRESS under a different profile - logs the submission id and old/new profile ids before the restamp. Same in InstantTranslationController::getOrCreateSubmission(). Happy to make this hard-block instead if that's the preference.

(5847c8d)

$submission = $this->storeWithJobInfo($submission, $jobInfo, $request->getDescription());
$fileUris[] = $submission->getFileUri();
Expand All @@ -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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] Parent submissions are stamped with the requested profile here, but related content created later still gets the default profile. SubmissionManager::getSubmissionEntity() → stampConfigurationProfile() → getSingleSettingsProfile() (SubmissionManager.php ~489) runs for new and existing submissions. It's reached from TranslationHelper::tryPrepareRelatedContent() via ReferencedContentProcessor (referenced posts/terms in meta), SmartlingCoreExportApi::sendAttachmentForTranslation() (Gutenberg/Elementor images) and SmartlingCoreDownloadTrait::downloadTranslation().

With profile B chosen, children get profile A's credentials but B's job (JobEntityWithBatchUid::fromJob($submission->getJobInfo())), and existing B submissions are silently re-stamped to A. This was harmless while "active" meant "requested". Now that several profiles can be active, it isn't. Suggest: pass the parent's profile id into related-content creation, and in getSubmissionEntity() stamp only when getConfigurationProfileId() === null.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. SubmissionManager::getSubmissionEntity() now only stamps the source blog's default profile when none is set yet; an explicit profile id always wins. Threaded that through TranslationHelper (prepareSubmission/prepareSubmissionEntity/tryPrepareRelatedContent/getExistingSubmissionOrCreateNew), and ReferencedContentProcessor::processFieldPreTranslation() now passes the parent submission's own profile id.

Checked sendAttachmentForTranslation() and SmartlingCoreDownloadTrait::downloadTranslation(): neither has any caller anywhere in the codebase currently (grepped inc/) - the only live path creating related-content submissions is ReferencedContentProcessor during upload serialization. Added the optional profile id param to sendAttachmentForTranslation() too for signature consistency, but didn't invent a call site for downloadTranslation() since nothing reaches it today.

(5847c8d, tests in SubmissionManagerTest/ReferencedContentProcessorTest)


foreach ($sources as $source) {
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions inc/Smartling/Services/ContentRelationsHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down
21 changes: 21 additions & 0 deletions inc/Smartling/Settings/ConfigurationProfileEntity.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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()))),
];
}
}
63 changes: 63 additions & 0 deletions inc/Smartling/Settings/SettingsManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading