Skip to content
Merged
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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "smartling/wordpress-connector",
"license": "GPL-2.0-or-later",
"version": "5.8.0",
"version": "5.8.1",
"description": "",
"type": "wordpress-plugin",
"repositories": [
Expand Down
28 changes: 28 additions & 0 deletions docs/ELEMENTOR_DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,3 +442,31 @@ 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:

| `{prefix}post_type` | Lists used |
|---------------------|------------|
| `by_id` (manual selection) | only `posts_ids` |
| `current_query` | none, the main query is used as is |
| `related` | `exclude_term_ids` and `exclude_ids` (the terms of the current post are used instead of `include_term_ids`) |
| anything else | `include_term_ids`, `exclude_term_ids`, `exclude_ids` (never `posts_ids`) |

In the modes where a list is used it is collected only when its own mode setting selects it: `{prefix}include` must contain `terms` for `include_term_ids`, `{prefix}exclude` must contain `terms` for `exclude_term_ids` and `manual_selection` for `exclude_ids`.

**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
112 changes: 112 additions & 0 deletions inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?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 POST_TYPE_MANUAL_SELECTION = 'by_id';
private const POST_TYPE_CURRENT_QUERY = 'current_query';
private const POST_TYPE_RELATED = 'related';

/**
* Elementor Pro (Elementor_Post_Query, checked against 3.33.2) keeps the values of hidden controls but ignores them
* unless the query mode matches:
* - {prefix}post_type = by_id (manual selection): only posts_ids is used, everything else is skipped
* - {prefix}post_type = current_query: the main query is used as is, none of the settings is used
* - {prefix}post_type = related: include_term_ids is not used, the terms of the current post are
* - 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, only used for this post type, ignored for these post types,
* mode setting suffix, expected mode value]
*/
private const QUERY_SETTINGS = [
'include_term_ids' => [
ContentTypeHelper::CONTENT_TYPE_TAXONOMY, false, null,
[self::POST_TYPE_MANUAL_SELECTION, self::POST_TYPE_CURRENT_QUERY, self::POST_TYPE_RELATED], 'include', 'terms',
],
'exclude_term_ids' => [
ContentTypeHelper::CONTENT_TYPE_TAXONOMY, true, null,
[self::POST_TYPE_MANUAL_SELECTION, self::POST_TYPE_CURRENT_QUERY], 'exclude', 'terms',
],
'posts_ids' => [ContentTypeHelper::CONTENT_TYPE_POST, false, self::POST_TYPE_MANUAL_SELECTION, [], null, null],
'exclude_ids' => [
ContentTypeHelper::CONTENT_TYPE_POST, true, null,
[self::POST_TYPE_MANUAL_SELECTION, self::POST_TYPE_CURRENT_QUERY], 'exclude', 'manual_selection',
],
];

public function addRelated(RelatedContentInfo $info, array $settings, string $containerId, string $prefix): RelatedContentInfo
{
$postType = $settings[$prefix . 'post_type'] ?? null;
foreach (self::QUERY_SETTINGS as $suffix => [$contentType, $remapOnly, $onlyPostType, $ignoredPostTypes, $modeSuffix, $mode]) {
if (($onlyPostType !== null && $postType !== $onlyPostType)
|| in_array($postType, $ignoredPostTypes, true)
|| ($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;
}
Loading
Loading