Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ This is the **Smartling Connector** WordPress plugin - a translation and localiz
- Dependency injection throughout the codebase
- Extensive use of interfaces for testability
- WordPress hooks for extensibility
- Avoid adding new traits in favor of composition

### Content Processing
- All content goes through serialization/deserialization pipeline
Expand Down
26 changes: 26 additions & 0 deletions docs/ELEMENTOR_DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,3 +442,29 @@ foreach ($this->settings['post_query_include_term_ids'] ?? [] as $index => $term
- `inc/Smartling/Models/Content.php` — Content reference model
- `tests/Smartling/ContentTypes/Elementor/` — Test examples
- `tests/Smartling/ContentTypes/ExternalContentElementor4Test.php` — Elementor 4 handler tests

### Pattern 5: Query Settings (Elementor 3)

Widgets that run a `WP_Query` (Posts, Loop Grid, Loop Carousel) store the query as prefixed settings, e.g. `post_query_include_term_ids`, `post_query_exclude_term_ids`, `post_query_posts_ids`, `post_query_exclude_ids`. Use `ElementorQueryRelated::addRelated($info, $settings, $containerId, $prefix)` (composition, no trait) in `getRelated()` to register term IDs (taxonomy) and post IDs (post) as related content; `setRelations()` then rewrites each array entry with the translated ID.

```php
public function getRelated(): RelatedContentInfo
{
return (new ElementorQueryRelated())->addRelated(parent::getRelated(), $this->settings, $this->id, 'post_query_');
}
```

**Example:** `Elements/LoopGrid.php`, `Elements/Posts.php` (prefix `posts_`)

**Only the active query mode is collected.** Elementor Pro (`Elementor_Post_Query`, checked against 3.33.2) keeps the values of hidden controls but ignores them unless the query mode matches, so each list is collected only when it is in use:

| Setting | Collected when |
|---------|----------------|
| `{prefix}posts_ids` | `{prefix}post_type` is `by_id` (manual selection; the only list used in this mode) |
| `{prefix}include_term_ids` | `{prefix}post_type` is not `by_id` and `{prefix}include` contains `terms` |
| `{prefix}exclude_term_ids` | `{prefix}post_type` is not `by_id` and `{prefix}exclude` contains `terms` |
| `{prefix}exclude_ids` | `{prefix}post_type` is not `by_id` and `{prefix}exclude` contains `manual_selection` |

**Query term lists hold `term_taxonomy_id`s, not `term_id`s** (Elementor Pro resolves them with `get_term_by('term_taxonomy_id')` and queries `'field' => 'term_taxonomy_id'`). `ElementorQueryRelated` marks them with `Content::isTermTaxonomyId()`. `ExternalContentElementorAbstract::getData()` converts them to term IDs in the source blog, so related content discovery, submissions and the taxonomy lookup in `setRelations()` all work with `term_id`s like everywhere else. `setRelations()` converts the translated term ID back to its `term_taxonomy_id` in the target blog before writing it. Terms that cannot be resolved are skipped (and logged) instead of being submitted as a different term. Use the same flag for any other setting that stores a `term_taxonomy_id`.

**Excluded content is remap-only.** `exclude_term_ids` and `exclude_ids` are never submitted for translation (`Content::isRemapOnly()`), because excluding something must not cause it to be translated. Their IDs are replaced with the translated ones only if the content **is already translated when the translation of the page is applied**. If an excluded term or post is translated later, the page keeps the source ID until the page is downloaded again (e.g. by re-submitting it). Support should expect this when a query still shows an excluded item after a later translation.
21 changes: 21 additions & 0 deletions docs/TARGET_ID_FILTER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# `smartling_target_id` filter

Returns the id of the translated counterpart of a piece of content, using the source/target mapping the connector stores in submissions. Use it in custom code (theme, snippets) instead of hardcoding ids that differ between blogs.

```php
// attachment 13574 in the source blog => attachment in the current blog
$id = apply_filters('smartling_target_id', 13574, 'attachment');

// return null instead of the original id when there is no translation
$id = apply_filters('smartling_target_id', 13574, 'attachment', null, false);
```

| # | Argument | Default | Notes |
|---|----------|---------|-------|
| 1 | `$sourceId` | | id in the source blog; returned as is when the connector is inactive |
| 2 | `$contentType` | `''` | submission content type (`attachment`, `post`, `page`, `category`, ...); empty ignores the type |
| 3 | `$targetBlogId` | current blog | blog to get the id for |
| 4 | `$fallbackToSource` | `true` | return `$sourceId` when no translation is found; `false` returns `null` instead |
| 5 | `$sourceBlogId` | any | blog the content was translated from |

Returns `int|null`. When no submission matches, its target id is not set yet, or more than one submission matches, `$sourceId` is returned, or `null` if `$fallbackToSource` is `false` (pass `$sourceBlogId` or `$contentType` to disambiguate multiple matches).
16 changes: 16 additions & 0 deletions inc/Smartling/Base/ExportedAPI.php
Original file line number Diff line number Diff line change
Expand Up @@ -224,4 +224,20 @@ interface ExportedAPI
* @param ConfigurationProfileEntity[]
*/
public const FILTER_ACTIVE_PROFILES = 'smartling_filter_active_profiles';

/**
* Translates an id of the content that has been submitted for translation, so custom code does not have to
* hardcode ids that differ between blogs.
*
* Never throws, any argument type is tolerated. Results are cached for a short time.
* A submission with a target id counts as a translation regardless of its status.
*
* @param mixed $sourceId id of the content in the source blog, returned unchanged when $fallbackToSource is true and nothing is found or the value is not a positive integer
* @param string $contentType submission content type (e.g. attachment, post, page, category), empty to ignore
* @param int|null $targetBlogId blog to get the id for, current blog when null
* @param bool $fallbackToSource return $sourceId when no translation is found (default), otherwise null is returned
* @param int|null $sourceBlogId blog the content was translated from, any blog when null
* @return int|null target id, null when nothing is found and $fallbackToSource is false
*/
public const FILTER_TARGET_ID = 'smartling_target_id';
}
7 changes: 7 additions & 0 deletions inc/Smartling/ContentTypes/Elementor/ElementAbstract.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ public function setRelations(
$submission->getTargetBlogId(),
$contentType,
);
if ($targetId !== null && $content->isTermTaxonomyId()) {
$termId = $targetId;
$targetId = $externalContentElementor->getTermTaxonomyId($submission->getTargetBlogId(), $termId);
if ($targetId === null) {
$this->getLogger()->notice("Unable to get term_taxonomy_id for translated termId=$termId, path=$path, leaving original value");
}
}
if ($targetId !== null) {
if (is_string($this->getSettingByKey($path, $this->raw ?? []))) {
$targetId = (string)$targetId;
Expand Down
94 changes: 94 additions & 0 deletions inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

namespace Smartling\ContentTypes\Elementor;

use Smartling\ContentTypes\ContentTypeHelper;
use Smartling\Helpers\LoggerSafeTrait;
use Smartling\Models\Content;
use Smartling\Models\RelatedContentInfo;

/**
* Elementor widgets that run a WP_Query store the query in settings keyed by a widget specific prefix
* (e.g. "posts_" for the Posts widget, "post_query_" for loop widgets).
*
* Included terms and posts are sent for translation as related content. Excluded ones are remap-only:
* their ids are replaced with the translated ones if they are already translated, but excluding something
* does not cause it to be submitted.
*/
class ElementorQueryRelated
{
use LoggerSafeTrait;

private const MANUAL_SELECTION = 'by_id';

/**
* Elementor Pro (Elementor_Post_Query) keeps the values of hidden controls but ignores them unless the query mode
* matches: with {prefix}post_type = by_id only posts_ids is used, otherwise posts_ids is ignored and the other lists
* need their own mode ({prefix}include / {prefix}exclude) to contain the expected value.
* Term lists hold term_taxonomy_ids (get_term_by('term_taxonomy_id') and 'field' => 'term_taxonomy_id' in the query).
* suffix => [content type, remap only, manual selection mode, mode setting suffix, expected mode value]
*/
private const QUERY_SETTINGS = [
'include_term_ids' => [ContentTypeHelper::CONTENT_TYPE_TAXONOMY, false, false, 'include', 'terms'],
'exclude_term_ids' => [ContentTypeHelper::CONTENT_TYPE_TAXONOMY, true, false, 'exclude', 'terms'],
'posts_ids' => [ContentTypeHelper::CONTENT_TYPE_POST, false, true, null, null],
'exclude_ids' => [ContentTypeHelper::CONTENT_TYPE_POST, true, false, 'exclude', 'manual_selection'],
];

public function addRelated(RelatedContentInfo $info, array $settings, string $containerId, string $prefix): RelatedContentInfo
{
$isManualSelection = ($settings[$prefix . 'post_type'] ?? null) === self::MANUAL_SELECTION;
foreach (self::QUERY_SETTINGS as $suffix => [$contentType, $remapOnly, $manualSelection, $modeSuffix, $mode]) {
if ($manualSelection !== $isManualSelection
|| ($mode !== null && !$this->isModeActive($settings[$prefix . $modeSuffix] ?? null, $mode))
) {
$this->getLogger()->debug("Skipping {$prefix}{$suffix}, it is not used by the query mode of the widget, containerId=$containerId");
continue;
}
$this->addIds($info, $settings, $containerId, $prefix . $suffix, $contentType, $remapOnly);
}

return $info;
}

private function isModeActive(mixed $modeSetting, string $mode): bool
{
return is_array($modeSetting) ? in_array($mode, $modeSetting, true) : $modeSetting === $mode;
}

/**
* Related content of loop widgets (loop-grid, loop-carousel): the item template and the query
*/
public function addLoopRelated(RelatedContentInfo $info, array $settings, string $containerId): RelatedContentInfo
{
$key = 'template_id';
$id = $this->toId($settings[$key] ?? null);
if ($id !== null) {
$info->addContent(new Content($id, ContentTypeHelper::CONTENT_TYPE_POST), $containerId, "settings/$key");
}

return $this->addRelated($info, $settings, $containerId, 'post_query_');
}

private function addIds(RelatedContentInfo $info, array $settings, string $containerId, string $key, string $contentType, bool $remapOnly): void
{
$ids = $settings[$key] ?? [];
if (!is_array($ids)) {
$this->getLogger()->debug("Ignoring non-array value of setting $key, containerId=$containerId");
return;
}
foreach ($ids as $index => $id) {
$id = $this->toId($id);
if ($id !== null) {
$info->addContent(new Content($id, $contentType, $remapOnly, $contentType === ContentTypeHelper::CONTENT_TYPE_TAXONOMY), $containerId, "settings/$key/$index");
}
}
}

private function toId(mixed $value): ?int
{
$id = filter_var($value, FILTER_VALIDATE_INT);

return is_int($id) && $id > 0 ? $id : null;
}
}
18 changes: 2 additions & 16 deletions inc/Smartling/ContentTypes/Elementor/Elements/LoopCarousel.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

namespace Smartling\ContentTypes\Elementor\Elements;

use Smartling\ContentTypes\ContentTypeHelper;
use Smartling\Models\Content;
use Smartling\ContentTypes\Elementor\ElementorQueryRelated;
use Smartling\Models\RelatedContentInfo;

class LoopCarousel extends Unknown {
Expand All @@ -14,19 +13,6 @@ public function getType(): string

public function getRelated(): RelatedContentInfo
{
$return = parent::getRelated();
$key = "template_id";
$id = $this->getIntSettingByKey($key, $this->settings);
if ($id !== null) {
$return->addContent(new Content($id, ContentTypeHelper::CONTENT_TYPE_POST), $this->id, "settings/$key");
}

foreach ($this->settings['post_query_include_term_ids'] ?? [] as $index => $termId) {
if (is_numeric($termId)) {
$return->addContent(new Content((int)$termId, ContentTypeHelper::CONTENT_TYPE_TAXONOMY), $this->id, "settings/post_query_include_term_ids/$index");
}
}

return $return;
return (new ElementorQueryRelated())->addLoopRelated(parent::getRelated(), $this->settings, $this->id);
}
}
18 changes: 18 additions & 0 deletions inc/Smartling/ContentTypes/Elementor/Elements/LoopGrid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace Smartling\ContentTypes\Elementor\Elements;

use Smartling\ContentTypes\Elementor\ElementorQueryRelated;
use Smartling\Models\RelatedContentInfo;

class LoopGrid extends Unknown {
public function getType(): string
{
return 'loop-grid';
}

public function getRelated(): RelatedContentInfo
{
return (new ElementorQueryRelated())->addLoopRelated(parent::getRelated(), $this->settings, $this->id);
}
}
10 changes: 2 additions & 8 deletions inc/Smartling/ContentTypes/Elementor/Elements/Posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Smartling\ContentTypes\Elementor\Elements;

use Smartling\ContentTypes\ContentTypeHelper;
use Smartling\ContentTypes\Elementor\ElementorQueryRelated;
use Smartling\Models\Content;
use Smartling\Models\RelatedContentInfo;

Expand All @@ -22,14 +23,7 @@ public function getRelated(): RelatedContentInfo
$return->addContent(new Content($id, ContentTypeHelper::CONTENT_TYPE_UNKNOWN), $this->id, "settings/$key");
}

$key = 'posts_include_term_ids';
foreach ($this->settings[$key] ?? [] as $index => $termId) {
if (is_numeric($termId)) {
$return->addContent(new Content((int)$termId, ContentTypeHelper::CONTENT_TYPE_TAXONOMY), $this->id, "settings/$key/$index");
}
}

return $return;
return (new ElementorQueryRelated())->addRelated($return, $this->settings, $this->id, 'posts_');
}

public function getTranslatableStrings(): array
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,14 @@ public function getTargetId(
int $targetBlogId,
string $contentType = ContentTypeHelper::POST_TYPE_ATTACHMENT,
): ?int;

/**
* @return int|null term_id of the term with the given term_taxonomy_id in the current blog
*/
public function getTermId(int $termTaxonomyId): ?int;

/**
* @return int|null term_taxonomy_id of the term in the given blog
*/
public function getTermTaxonomyId(int $blogId, int $termId): ?int;
}
59 changes: 55 additions & 4 deletions inc/Smartling/ContentTypes/ExternalContentElementorAbstract.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Smartling\Helpers\SiteHelper;
use Smartling\Helpers\UserHelper;
use Smartling\Helpers\WordpressFunctionProxyHelper;
use Smartling\Models\Content;
use Smartling\Models\ExternalData;
use Smartling\Models\RelatedContentInfo;
use Smartling\Submissions\SubmissionEntity;
Expand Down Expand Up @@ -72,6 +73,22 @@ public function getWpProxy(): WordpressFunctionProxyHelper
return $this->wpProxy;
}

public function getTermId(int $termTaxonomyId): ?int
{
$term = $this->wpProxy->getTermByTaxonomyId($termTaxonomyId);

return is_array($term) && isset($term['term_id']) ? (int)$term['term_id'] : null;
}

public function getTermTaxonomyId(int $blogId, int $termId): ?int
{
$term = $this->siteHelper->withBlog($blogId, fn() => $this->wpProxy->getTerm($termId));

return is_array($term) && isset($term['term_taxonomy_id']) && (int)$term['term_taxonomy_id'] > 0
? (int)$term['term_taxonomy_id']
: null;
}

public function afterMetaWritten(SubmissionEntity $submission): void
{
if ($submission->getTargetId() === 0) {
Expand Down Expand Up @@ -131,7 +148,7 @@ public function getSupportLevel(string $contentType, ?int $contentId = null): st
return Pluggable::NOT_SUPPORTED;
}

private function getData(array $data): ExternalData
private function getData(array $data, ?int $sourceBlogId = null): ExternalData
{
$related = new RelatedContentInfo();
$strings = [];
Expand All @@ -142,14 +159,48 @@ private function getData(array $data): ExternalData
$strings[] = $element->getTranslatableStrings();
}

return new ExternalData(strings: $strings, relatedContentInfo: $related);
return new ExternalData(strings: $strings, relatedContentInfo: $this->convertTermTaxonomyIds($related, $sourceBlogId));
}

/**
* Some settings (Elementor Pro queries) store term_taxonomy_ids, while submissions and the rest of the
* plugin work with term_ids. Content that cannot be resolved to a term is dropped, because the id would be
* submitted and remapped as a different term otherwise.
*/
private function convertTermTaxonomyIds(RelatedContentInfo $related, ?int $sourceBlogId): RelatedContentInfo
{
$hasTermTaxonomyIds = false;
$related->mapContent(static function (Content $content) use (&$hasTermTaxonomyIds): Content {
$hasTermTaxonomyIds = $hasTermTaxonomyIds || $content->isTermTaxonomyId();

return $content;
});
if (!$hasTermTaxonomyIds) {
return $related;
}

$convert = fn() => $related->mapContent(function (Content $content): ?Content {
if (!$content->isTermTaxonomyId()) {
return $content;
}
$termId = $this->getTermId($content->getId());
if ($termId === null) {
$this->getLogger()->notice("Unable to find term for term_taxonomy_id={$content->getId()}, skipping");

return null;
}

return $content->withId($termId);
});

return $sourceBlogId === null ? $convert() : $this->siteHelper->withBlog($sourceBlogId, $convert);
}

public function getContentFields(SubmissionEntity $submission, bool $raw): array
{
return $this->fieldsFilterHelper->flattenArray(
(new ArrayHelper())->add(
...$this->getData($this->readMeta($submission->getSourceId()))->getStrings()
...$this->getData($this->readMeta($submission->getSourceId()), $submission->getSourceBlogId())->getStrings()
)
);
}
Expand Down Expand Up @@ -177,7 +228,7 @@ public function getRelatedContent(string $contentType, int $contentId): array
private function mergeElementorData(array $original, array $strings, SubmissionEntity $submission): array
{
$result = [];
$relatedContentInfo = $this->getData($original)->getRelatedContentInfo();
$relatedContentInfo = $this->getData($original, $submission->getSourceBlogId())->getRelatedContentInfo();
foreach ($original as $array) {
$element = $this->elementFactory->fromArray($array);
$result[] = $element->setTargetContent(
Expand Down
Loading
Loading