From abc9cdd0114692ee0e77412d8996832f7497a1e6 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Tue, 6 Oct 2026 13:19:47 +0200 Subject: [PATCH 1/9] treat Elementor query term/post IDs as related content, add loop-grid (WP-1020) Co-Authored-By: Claude Sonnet 5.5 --- docs/ELEMENTOR_DEVELOPMENT.md | 15 +++++ .../Elementor/ElementorQueryRelatedTrait.php | 42 ++++++++++++++ .../Elementor/Elements/LoopCarousel.php | 11 ++-- .../Elementor/Elements/LoopGrid.php | 29 ++++++++++ .../ContentTypes/Elementor/Elements/Posts.php | 12 ++-- .../Elementor/ElementorQueryRelatedTest.php | 55 +++++++++++++++++++ 6 files changed, 149 insertions(+), 15 deletions(-) create mode 100644 inc/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTrait.php create mode 100644 inc/Smartling/ContentTypes/Elementor/Elements/LoopGrid.php create mode 100644 tests/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTest.php diff --git a/docs/ELEMENTOR_DEVELOPMENT.md b/docs/ELEMENTOR_DEVELOPMENT.md index f06706985..b72ba45e4 100644 --- a/docs/ELEMENTOR_DEVELOPMENT.md +++ b/docs/ELEMENTOR_DEVELOPMENT.md @@ -442,3 +442,18 @@ 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 `ElementorQueryRelatedTrait::addQueryRelated($info, $prefix)` 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 +use ElementorQueryRelatedTrait; + +public function getRelated(): RelatedContentInfo +{ + return $this->addQueryRelated(parent::getRelated(), 'post_query_'); +} +``` + +**Example:** `Elements/LoopGrid.php`, `Elements/Posts.php` (prefix `posts_`) diff --git a/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTrait.php b/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTrait.php new file mode 100644 index 000000000..6711b9252 --- /dev/null +++ b/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTrait.php @@ -0,0 +1,42 @@ +addQueryIds($info, $prefix . $suffix, ContentTypeHelper::CONTENT_TYPE_TAXONOMY); + } + foreach (self::QUERY_POST_ID_SUFFIXES as $suffix) { + $this->addQueryIds($info, $prefix . $suffix, ContentTypeHelper::CONTENT_TYPE_POST); + } + + return $info; + } + + private function addQueryIds(RelatedContentInfo $info, string $key, string $contentType): void + { + $ids = $this->settings[$key] ?? []; + if (!is_array($ids)) { + return; + } + foreach ($ids as $index => $id) { + if (is_numeric($id) && (int)$id > 0) { + $info->addContent(new Content((int)$id, $contentType), $this->id, "settings/$key/$index"); + } + } + } +} diff --git a/inc/Smartling/ContentTypes/Elementor/Elements/LoopCarousel.php b/inc/Smartling/ContentTypes/Elementor/Elements/LoopCarousel.php index f7691e15c..bb749e8da 100644 --- a/inc/Smartling/ContentTypes/Elementor/Elements/LoopCarousel.php +++ b/inc/Smartling/ContentTypes/Elementor/Elements/LoopCarousel.php @@ -3,10 +3,13 @@ namespace Smartling\ContentTypes\Elementor\Elements; use Smartling\ContentTypes\ContentTypeHelper; +use Smartling\ContentTypes\Elementor\ElementorQueryRelatedTrait; use Smartling\Models\Content; use Smartling\Models\RelatedContentInfo; class LoopCarousel extends Unknown { + use ElementorQueryRelatedTrait; + public function getType(): string { return 'loop-carousel'; @@ -21,12 +24,6 @@ public function getRelated(): RelatedContentInfo $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 $this->addQueryRelated($return, 'post_query_'); } } diff --git a/inc/Smartling/ContentTypes/Elementor/Elements/LoopGrid.php b/inc/Smartling/ContentTypes/Elementor/Elements/LoopGrid.php new file mode 100644 index 000000000..89a4cc1a1 --- /dev/null +++ b/inc/Smartling/ContentTypes/Elementor/Elements/LoopGrid.php @@ -0,0 +1,29 @@ +getIntSettingByKey($key, $this->settings); + if ($id !== null) { + $return->addContent(new Content($id, ContentTypeHelper::CONTENT_TYPE_POST), $this->id, "settings/$key"); + } + + return $this->addQueryRelated($return, 'post_query_'); + } +} diff --git a/inc/Smartling/ContentTypes/Elementor/Elements/Posts.php b/inc/Smartling/ContentTypes/Elementor/Elements/Posts.php index 5731dc690..0546fa8ac 100644 --- a/inc/Smartling/ContentTypes/Elementor/Elements/Posts.php +++ b/inc/Smartling/ContentTypes/Elementor/Elements/Posts.php @@ -3,11 +3,14 @@ namespace Smartling\ContentTypes\Elementor\Elements; use Smartling\ContentTypes\ContentTypeHelper; +use Smartling\ContentTypes\Elementor\ElementorQueryRelatedTrait; use Smartling\Models\Content; use Smartling\Models\RelatedContentInfo; class Posts extends Unknown { + use ElementorQueryRelatedTrait; + public function getType(): string { return 'posts'; @@ -22,14 +25,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 $this->addQueryRelated($return, 'posts_'); } public function getTranslatableStrings(): array diff --git a/tests/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTest.php b/tests/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTest.php new file mode 100644 index 000000000..03e6f41f2 --- /dev/null +++ b/tests/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTest.php @@ -0,0 +1,55 @@ +assertEquals('loop-grid', (new LoopGrid())->getType()); + } + + public function testLoopWidgetsCollectQueryRelatedContent(): void + { + foreach ([LoopGrid::class, LoopCarousel::class] as $class) { + $list = (new $class(['id' => 'w1', 'settings' => [ + 'template_id' => '7', + 'post_query_include_term_ids' => ['14', '15'], + 'post_query_exclude_term_ids' => ['16'], + 'post_query_posts_ids' => ['100'], + 'post_query_exclude_ids' => ['101'], + ]]))->getRelated()->getRelatedContentList(); + + $this->assertEquals(['14', '15', '16'], array_map('strval', $list[ContentTypeHelper::CONTENT_TYPE_TAXONOMY]), $class); + $this->assertEqualsCanonicalizing(['7', '100', '101'], array_map('strval', $list[ContentTypeHelper::CONTENT_TYPE_POST]), $class); + } + } + + public function testPostsWidgetCollectsQueryRelatedContent(): void + { + $list = (new Posts(['id' => 'w1', 'settings' => [ + 'posts_include_term_ids' => ['3'], + 'posts_exclude_term_ids' => ['4'], + 'posts_posts_ids' => ['50'], + ]]))->getRelated()->getRelatedContentList(); + + $this->assertEquals(['3', '4'], array_map('strval', $list[ContentTypeHelper::CONTENT_TYPE_TAXONOMY])); + $this->assertEquals(['50'], array_map('strval', $list[ContentTypeHelper::CONTENT_TYPE_POST])); + } + + public function testInvalidQueryValuesAreIgnored(): void + { + $list = (new LoopGrid(['id' => 'w1', 'settings' => [ + 'post_query_include_term_ids' => 'not-an-array', + 'post_query_exclude_term_ids' => ['', 'abc', '0'], + ]]))->getRelated()->getRelatedContentList(); + + $this->assertArrayNotHasKey(ContentTypeHelper::CONTENT_TYPE_TAXONOMY, $list); + } +} From c31ef2103de1b71ec5f27a7b47dd1dfb80891312 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Tue, 6 Oct 2026 18:53:53 +0200 Subject: [PATCH 2/9] add smartling_target_id filter to translate ids in custom code (WP-1020) Co-Authored-By: Claude Sonnet 5.5 --- docs/TARGET_ID_FILTER.md | 21 +++++ inc/Smartling/Base/ExportedAPI.php | 13 +++ inc/Smartling/Helpers/TargetIdFilter.php | 60 ++++++++++++ inc/config/register-on-startup.yml | 1 + inc/config/services.yml | 6 ++ .../Smartling/Helpers/TargetIdFilterTest.php | 93 +++++++++++++++++++ 6 files changed, 194 insertions(+) create mode 100644 docs/TARGET_ID_FILTER.md create mode 100644 inc/Smartling/Helpers/TargetIdFilter.php create mode 100644 tests/Smartling/Helpers/TargetIdFilterTest.php diff --git a/docs/TARGET_ID_FILTER.md b/docs/TARGET_ID_FILTER.md new file mode 100644 index 000000000..c26185629 --- /dev/null +++ b/docs/TARGET_ID_FILTER.md @@ -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'); + +// keep the original id when there is no translation +$id = apply_filters('smartling_target_id', 13574, 'attachment', null, true); +``` + +| # | 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` | `false` | return `$sourceId` instead of `null` when no translation is found | +| 5 | `$sourceBlogId` | any | blog the content was translated from | + +Returns `int|null`. `null` is returned when no submission matches, when its target id is not set yet, or when more than one submission matches (pass `$sourceBlogId` or `$contentType` to disambiguate). diff --git a/inc/Smartling/Base/ExportedAPI.php b/inc/Smartling/Base/ExportedAPI.php index 8ec603b84..9d262f0cf 100755 --- a/inc/Smartling/Base/ExportedAPI.php +++ b/inc/Smartling/Base/ExportedAPI.php @@ -224,4 +224,17 @@ 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. + * + * @param int $sourceId id of the content in the source blog, the value returned when nothing is registered + * @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, otherwise null is returned + * @param int|null $sourceBlogId blog the content was translated from, any blog when null + * @return int|null target id + */ + public const FILTER_TARGET_ID = 'smartling_target_id'; } diff --git a/inc/Smartling/Helpers/TargetIdFilter.php b/inc/Smartling/Helpers/TargetIdFilter.php new file mode 100644 index 000000000..def3ad60a --- /dev/null +++ b/inc/Smartling/Helpers/TargetIdFilter.php @@ -0,0 +1,60 @@ + target id mapping stored in submissions to custom code: + * apply_filters('smartling_target_id', 13574, 'attachment') returns the id of the translated attachment in the current blog. + */ +class TargetIdFilter implements WPHookInterface +{ + use LoggerSafeTrait; + + public function __construct( + private SubmissionManager $submissionManager, + private WordpressFunctionProxyHelper $wpProxy, + ) { + } + + public function register(): void + { + $this->wpProxy->add_filter(ExportedAPI::FILTER_TARGET_ID, [$this, 'getTargetId'], 10, 5); + } + + public function getTargetId( + mixed $sourceId, + string $contentType = '', + ?int $targetBlogId = null, + bool $fallbackToSource = false, + ?int $sourceBlogId = null, + ): ?int { + if (!is_numeric($sourceId) || (int)$sourceId <= 0) { + return null; + } + $sourceId = (int)$sourceId; + $parameters = [ + SubmissionEntity::FIELD_SOURCE_ID => $sourceId, + SubmissionEntity::FIELD_TARGET_BLOG_ID => $targetBlogId ?? $this->wpProxy->get_current_blog_id(), + ]; + if ($contentType !== '') { + $parameters[SubmissionEntity::FIELD_CONTENT_TYPE] = $contentType; + } + if ($sourceBlogId !== null) { + $parameters[SubmissionEntity::FIELD_SOURCE_BLOG_ID] = $sourceBlogId; + } + + $submission = $this->submissionManager->findOne($parameters); + if ($submission !== null && $submission->getTargetId() > 0) { + return $submission->getTargetId(); + } + + $this->getLogger()->debug("No target id found for sourceId=$sourceId, contentType=\"$contentType\"" . ($fallbackToSource ? ', falling back to source id' : '')); + + return $fallbackToSource ? $sourceId : null; + } +} diff --git a/inc/config/register-on-startup.yml b/inc/config/register-on-startup.yml index c77f8a03c..bd16c5487 100644 --- a/inc/config/register-on-startup.yml +++ b/inc/config/register-on-startup.yml @@ -24,6 +24,7 @@ services: - [ "addService", [ "@service.submission-cleanup" ]] - [ "addService", [ "@plugin.publishpress-revisions" ]] - [ "addService", [ "@helper.shortcode" ]] + - [ "addService", [ "@helper.target-id-filter" ]] - [ "addService", [ "@helper.gutenberg" ]] - [ "addService", [ "@meta-field.processor.manager" ]] - [ "addService", [ "@service.blog-removal-handler"]] diff --git a/inc/config/services.yml b/inc/config/services.yml index 9aa4ab025..8d6488263 100644 --- a/inc/config/services.yml +++ b/inc/config/services.yml @@ -603,6 +603,12 @@ services: - "@plugin.info" - "@wp.proxy" + helper.target-id-filter: + class: Smartling\Helpers\TargetIdFilter + arguments: + - "@manager.submission" + - "@wp.proxy" + duplicate.submissions.cleaner: class: Smartling\WP\Controller\DuplicateSubmissionsCleaner arguments: diff --git a/tests/Smartling/Helpers/TargetIdFilterTest.php b/tests/Smartling/Helpers/TargetIdFilterTest.php new file mode 100644 index 000000000..6e29f92f5 --- /dev/null +++ b/tests/Smartling/Helpers/TargetIdFilterTest.php @@ -0,0 +1,93 @@ +createMock(SubmissionManager::class); + $expectation = $manager->expects($this->once())->method('findOne'); + if ($expectedParameters !== null) { + $expectation->with($expectedParameters); + } + $expectation->willReturn($found); + + $proxy = $this->createMock(WordpressFunctionProxyHelper::class); + $proxy->method('get_current_blog_id')->willReturn($currentBlogId); + + return new TargetIdFilter($manager, $proxy); + } + + private function makeSubmission(int $targetId): SubmissionEntity + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getTargetId')->willReturn($targetId); + + return $submission; + } + + public function testRegistersFilter(): void + { + $proxy = $this->createMock(WordpressFunctionProxyHelper::class); + $proxy->expects($this->once())->method('add_filter')->with(ExportedAPI::FILTER_TARGET_ID, $this->anything(), 10, 5); + + (new TargetIdFilter($this->createMock(SubmissionManager::class), $proxy))->register(); + } + + public function testReturnsTargetIdForCurrentBlog(): void + { + $filter = $this->makeFilter($this->makeSubmission(1335), [ + SubmissionEntity::FIELD_SOURCE_ID => 13574, + SubmissionEntity::FIELD_TARGET_BLOG_ID => 3, + SubmissionEntity::FIELD_CONTENT_TYPE => 'attachment', + ]); + + $this->assertSame(1335, $filter->getTargetId(13574, 'attachment')); + } + + public function testExplicitBlogsAreUsed(): void + { + $filter = $this->makeFilter($this->makeSubmission(7), [ + SubmissionEntity::FIELD_SOURCE_ID => 5, + SubmissionEntity::FIELD_TARGET_BLOG_ID => 2, + SubmissionEntity::FIELD_SOURCE_BLOG_ID => 1, + ]); + + $this->assertSame(7, $filter->getTargetId('5', '', 2, false, 1)); + } + + public function testNoTranslationReturnsNull(): void + { + $this->assertNull($this->makeFilter(null)->getTargetId(10, 'post')); + } + + public function testNoTranslationFallsBackToSourceId(): void + { + $this->assertSame(10, $this->makeFilter(null)->getTargetId(10, 'post', null, true)); + } + + public function testZeroTargetIdIsNotATranslation(): void + { + $this->assertNull($this->makeFilter($this->makeSubmission(0))->getTargetId(10, 'post')); + $this->assertSame(10, $this->makeFilter($this->makeSubmission(0))->getTargetId(10, 'post', null, true)); + } + + public function testInvalidSourceIdIsNotLookedUp(): void + { + $manager = $this->createMock(SubmissionManager::class); + $manager->expects($this->never())->method('findOne'); + $filter = new TargetIdFilter($manager, $this->createMock(WordpressFunctionProxyHelper::class)); + + $this->assertNull($filter->getTargetId('abc', 'post', null, true)); + $this->assertNull($filter->getTargetId(0, 'post')); + $this->assertNull($filter->getTargetId(null, 'post')); + } +} From d411f3dde7ed4680a967610e0eee1e11f1c7948a Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Tue, 6 Oct 2026 19:09:31 +0200 Subject: [PATCH 3/9] default smartling_target_id fallbackToSource to true (WP-1020) Co-Authored-By: Claude Sonnet 5.5 --- docs/TARGET_ID_FILTER.md | 8 ++++---- inc/Smartling/Base/ExportedAPI.php | 2 +- inc/Smartling/Helpers/TargetIdFilter.php | 2 +- tests/Smartling/Helpers/TargetIdFilterTest.php | 12 ++++++------ 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/TARGET_ID_FILTER.md b/docs/TARGET_ID_FILTER.md index c26185629..36cc1f1ef 100644 --- a/docs/TARGET_ID_FILTER.md +++ b/docs/TARGET_ID_FILTER.md @@ -6,8 +6,8 @@ Returns the id of the translated counterpart of a piece of content, using the so // attachment 13574 in the source blog => attachment in the current blog $id = apply_filters('smartling_target_id', 13574, 'attachment'); -// keep the original id when there is no translation -$id = apply_filters('smartling_target_id', 13574, 'attachment', null, true); +// 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 | @@ -15,7 +15,7 @@ $id = apply_filters('smartling_target_id', 13574, 'attachment', null, true); | 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` | `false` | return `$sourceId` instead of `null` when no translation is found | +| 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`. `null` is returned when no submission matches, when its target id is not set yet, or when more than one submission matches (pass `$sourceBlogId` or `$contentType` to disambiguate). +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). diff --git a/inc/Smartling/Base/ExportedAPI.php b/inc/Smartling/Base/ExportedAPI.php index 9d262f0cf..e7b909788 100755 --- a/inc/Smartling/Base/ExportedAPI.php +++ b/inc/Smartling/Base/ExportedAPI.php @@ -232,7 +232,7 @@ interface ExportedAPI * @param int $sourceId id of the content in the source blog, the value returned when nothing is registered * @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, otherwise null is returned + * @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 */ diff --git a/inc/Smartling/Helpers/TargetIdFilter.php b/inc/Smartling/Helpers/TargetIdFilter.php index def3ad60a..827c355cf 100644 --- a/inc/Smartling/Helpers/TargetIdFilter.php +++ b/inc/Smartling/Helpers/TargetIdFilter.php @@ -30,7 +30,7 @@ public function getTargetId( mixed $sourceId, string $contentType = '', ?int $targetBlogId = null, - bool $fallbackToSource = false, + bool $fallbackToSource = true, ?int $sourceBlogId = null, ): ?int { if (!is_numeric($sourceId) || (int)$sourceId <= 0) { diff --git a/tests/Smartling/Helpers/TargetIdFilterTest.php b/tests/Smartling/Helpers/TargetIdFilterTest.php index 6e29f92f5..8d3bf0507 100644 --- a/tests/Smartling/Helpers/TargetIdFilterTest.php +++ b/tests/Smartling/Helpers/TargetIdFilterTest.php @@ -66,18 +66,18 @@ public function testExplicitBlogsAreUsed(): void public function testNoTranslationReturnsNull(): void { - $this->assertNull($this->makeFilter(null)->getTargetId(10, 'post')); + $this->assertNull($this->makeFilter(null)->getTargetId(10, 'post', null, false)); } - public function testNoTranslationFallsBackToSourceId(): void + public function testNoTranslationFallsBackToSourceIdByDefault(): void { - $this->assertSame(10, $this->makeFilter(null)->getTargetId(10, 'post', null, true)); + $this->assertSame(10, $this->makeFilter(null)->getTargetId(10, 'post')); } public function testZeroTargetIdIsNotATranslation(): void { - $this->assertNull($this->makeFilter($this->makeSubmission(0))->getTargetId(10, 'post')); - $this->assertSame(10, $this->makeFilter($this->makeSubmission(0))->getTargetId(10, 'post', null, true)); + $this->assertNull($this->makeFilter($this->makeSubmission(0))->getTargetId(10, 'post', null, false)); + $this->assertSame(10, $this->makeFilter($this->makeSubmission(0))->getTargetId(10, 'post')); } public function testInvalidSourceIdIsNotLookedUp(): void @@ -86,7 +86,7 @@ public function testInvalidSourceIdIsNotLookedUp(): void $manager->expects($this->never())->method('findOne'); $filter = new TargetIdFilter($manager, $this->createMock(WordpressFunctionProxyHelper::class)); - $this->assertNull($filter->getTargetId('abc', 'post', null, true)); + $this->assertNull($filter->getTargetId('abc', 'post')); $this->assertNull($filter->getTargetId(0, 'post')); $this->assertNull($filter->getTargetId(null, 'post')); } From d6e1cbe91df37aea3e577fa355749c933ecad1ce Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Tue, 6 Oct 2026 19:11:57 +0200 Subject: [PATCH 4/9] replace ElementorQueryRelatedTrait with composed ElementorQueryRelated (WP-1020) Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 1 + docs/ELEMENTOR_DEVELOPMENT.md | 6 +-- .../Elementor/ElementorQueryRelated.php | 42 +++++++++++++++++++ .../Elementor/ElementorQueryRelatedTrait.php | 42 ------------------- .../Elementor/Elements/LoopCarousel.php | 6 +-- .../Elementor/Elements/LoopGrid.php | 6 +-- .../ContentTypes/Elementor/Elements/Posts.php | 6 +-- 7 files changed, 51 insertions(+), 58 deletions(-) create mode 100644 inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php delete mode 100644 inc/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTrait.php diff --git a/CLAUDE.md b/CLAUDE.md index bfd7dbf41..dd99e4f79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 traits in favor of composition ### Content Processing - All content goes through serialization/deserialization pipeline diff --git a/docs/ELEMENTOR_DEVELOPMENT.md b/docs/ELEMENTOR_DEVELOPMENT.md index b72ba45e4..45ac2d94f 100644 --- a/docs/ELEMENTOR_DEVELOPMENT.md +++ b/docs/ELEMENTOR_DEVELOPMENT.md @@ -445,14 +445,12 @@ foreach ($this->settings['post_query_include_term_ids'] ?? [] as $index => $term ### 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 `ElementorQueryRelatedTrait::addQueryRelated($info, $prefix)` in `getRelated()` to register term IDs (taxonomy) and post IDs (post) as related content; `setRelations()` then rewrites each array entry with the translated ID. +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 -use ElementorQueryRelatedTrait; - public function getRelated(): RelatedContentInfo { - return $this->addQueryRelated(parent::getRelated(), 'post_query_'); + return (new ElementorQueryRelated())->addRelated(parent::getRelated(), $this->settings, $this->id, 'post_query_'); } ``` diff --git a/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php b/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php new file mode 100644 index 000000000..e3dfd3e3e --- /dev/null +++ b/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php @@ -0,0 +1,42 @@ +addIds($info, $settings, $containerId, $prefix . $suffix, ContentTypeHelper::CONTENT_TYPE_TAXONOMY); + } + foreach (self::POST_ID_SUFFIXES as $suffix) { + $this->addIds($info, $settings, $containerId, $prefix . $suffix, ContentTypeHelper::CONTENT_TYPE_POST); + } + + return $info; + } + + private function addIds(RelatedContentInfo $info, array $settings, string $containerId, string $key, string $contentType): void + { + $ids = $settings[$key] ?? []; + if (!is_array($ids)) { + return; + } + foreach ($ids as $index => $id) { + if (is_numeric($id) && (int)$id > 0) { + $info->addContent(new Content((int)$id, $contentType), $containerId, "settings/$key/$index"); + } + } + } +} diff --git a/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTrait.php b/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTrait.php deleted file mode 100644 index 6711b9252..000000000 --- a/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTrait.php +++ /dev/null @@ -1,42 +0,0 @@ -addQueryIds($info, $prefix . $suffix, ContentTypeHelper::CONTENT_TYPE_TAXONOMY); - } - foreach (self::QUERY_POST_ID_SUFFIXES as $suffix) { - $this->addQueryIds($info, $prefix . $suffix, ContentTypeHelper::CONTENT_TYPE_POST); - } - - return $info; - } - - private function addQueryIds(RelatedContentInfo $info, string $key, string $contentType): void - { - $ids = $this->settings[$key] ?? []; - if (!is_array($ids)) { - return; - } - foreach ($ids as $index => $id) { - if (is_numeric($id) && (int)$id > 0) { - $info->addContent(new Content((int)$id, $contentType), $this->id, "settings/$key/$index"); - } - } - } -} diff --git a/inc/Smartling/ContentTypes/Elementor/Elements/LoopCarousel.php b/inc/Smartling/ContentTypes/Elementor/Elements/LoopCarousel.php index bb749e8da..e420c972a 100644 --- a/inc/Smartling/ContentTypes/Elementor/Elements/LoopCarousel.php +++ b/inc/Smartling/ContentTypes/Elementor/Elements/LoopCarousel.php @@ -3,13 +3,11 @@ namespace Smartling\ContentTypes\Elementor\Elements; use Smartling\ContentTypes\ContentTypeHelper; -use Smartling\ContentTypes\Elementor\ElementorQueryRelatedTrait; +use Smartling\ContentTypes\Elementor\ElementorQueryRelated; use Smartling\Models\Content; use Smartling\Models\RelatedContentInfo; class LoopCarousel extends Unknown { - use ElementorQueryRelatedTrait; - public function getType(): string { return 'loop-carousel'; @@ -24,6 +22,6 @@ public function getRelated(): RelatedContentInfo $return->addContent(new Content($id, ContentTypeHelper::CONTENT_TYPE_POST), $this->id, "settings/$key"); } - return $this->addQueryRelated($return, 'post_query_'); + return (new ElementorQueryRelated())->addRelated($return, $this->settings, $this->id, 'post_query_'); } } diff --git a/inc/Smartling/ContentTypes/Elementor/Elements/LoopGrid.php b/inc/Smartling/ContentTypes/Elementor/Elements/LoopGrid.php index 89a4cc1a1..57fac85e5 100644 --- a/inc/Smartling/ContentTypes/Elementor/Elements/LoopGrid.php +++ b/inc/Smartling/ContentTypes/Elementor/Elements/LoopGrid.php @@ -3,13 +3,11 @@ namespace Smartling\ContentTypes\Elementor\Elements; use Smartling\ContentTypes\ContentTypeHelper; -use Smartling\ContentTypes\Elementor\ElementorQueryRelatedTrait; +use Smartling\ContentTypes\Elementor\ElementorQueryRelated; use Smartling\Models\Content; use Smartling\Models\RelatedContentInfo; class LoopGrid extends Unknown { - use ElementorQueryRelatedTrait; - public function getType(): string { return 'loop-grid'; @@ -24,6 +22,6 @@ public function getRelated(): RelatedContentInfo $return->addContent(new Content($id, ContentTypeHelper::CONTENT_TYPE_POST), $this->id, "settings/$key"); } - return $this->addQueryRelated($return, 'post_query_'); + return (new ElementorQueryRelated())->addRelated($return, $this->settings, $this->id, 'post_query_'); } } diff --git a/inc/Smartling/ContentTypes/Elementor/Elements/Posts.php b/inc/Smartling/ContentTypes/Elementor/Elements/Posts.php index 0546fa8ac..b24aaa895 100644 --- a/inc/Smartling/ContentTypes/Elementor/Elements/Posts.php +++ b/inc/Smartling/ContentTypes/Elementor/Elements/Posts.php @@ -3,14 +3,12 @@ namespace Smartling\ContentTypes\Elementor\Elements; use Smartling\ContentTypes\ContentTypeHelper; -use Smartling\ContentTypes\Elementor\ElementorQueryRelatedTrait; +use Smartling\ContentTypes\Elementor\ElementorQueryRelated; use Smartling\Models\Content; use Smartling\Models\RelatedContentInfo; class Posts extends Unknown { - use ElementorQueryRelatedTrait; - public function getType(): string { return 'posts'; @@ -25,7 +23,7 @@ public function getRelated(): RelatedContentInfo $return->addContent(new Content($id, ContentTypeHelper::CONTENT_TYPE_UNKNOWN), $this->id, "settings/$key"); } - return $this->addQueryRelated($return, 'posts_'); + return (new ElementorQueryRelated())->addRelated($return, $this->settings, $this->id, 'posts_'); } public function getTranslatableStrings(): array From 989b44d0547cb47048c1905df359bc6dd6b31184 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Wed, 7 Oct 2026 22:09:23 +0200 Subject: [PATCH 5/9] harden smartling_target_id filter, make Elementor exclude_* remap-only (WP-1020) - TargetIdFilter: tolerate any argument type, never throw, cache lookups in the object cache, log misses and ambiguity at notice, return invalid source id unchanged when falling back - Elementor query exclude_term_ids/exclude_ids are remapped to translated ids but no longer submitted for translation (Content::isRemapOnly) - validate Elementor query ids as integers, move loop widget related content into ElementorQueryRelated::addLoopRelated - add tests Co-Authored-By: Claude Sonnet 5.5 --- inc/Smartling/Base/ExportedAPI.php | 7 +- .../Elementor/ElementorQueryRelated.php | 48 ++++-- .../Elementor/Elements/LoopCarousel.php | 11 +- .../Elementor/Elements/LoopGrid.php | 11 +- inc/Smartling/Helpers/TargetIdFilter.php | 93 ++++++++--- inc/Smartling/Models/Content.php | 10 ++ inc/Smartling/Models/RelatedContentInfo.php | 3 + inc/config/services.yml | 1 + .../Elementor/ElementorQueryRelatedTest.php | 55 ++++++- .../Smartling/Helpers/TargetIdFilterTest.php | 150 ++++++++++++++++-- 10 files changed, 317 insertions(+), 72 deletions(-) diff --git a/inc/Smartling/Base/ExportedAPI.php b/inc/Smartling/Base/ExportedAPI.php index e7b909788..d2e8de852 100755 --- a/inc/Smartling/Base/ExportedAPI.php +++ b/inc/Smartling/Base/ExportedAPI.php @@ -229,12 +229,15 @@ interface ExportedAPI * 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. * - * @param int $sourceId id of the content in the source blog, the value returned when nothing is registered + * 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 + * @return int|null target id, null when nothing is found and $fallbackToSource is false */ public const FILTER_TARGET_ID = 'smartling_target_id'; } diff --git a/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php b/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php index e3dfd3e3e..d24b089e9 100644 --- a/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php +++ b/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php @@ -3,40 +3,70 @@ 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 { - private const TERM_ID_SUFFIXES = ['include_term_ids', 'exclude_term_ids']; - private const POST_ID_SUFFIXES = ['posts_ids', 'exclude_ids']; + use LoggerSafeTrait; + + private const TERM_ID_SUFFIXES = ['include_term_ids' => false, 'exclude_term_ids' => true]; + private const POST_ID_SUFFIXES = ['posts_ids' => false, 'exclude_ids' => true]; public function addRelated(RelatedContentInfo $info, array $settings, string $containerId, string $prefix): RelatedContentInfo { - foreach (self::TERM_ID_SUFFIXES as $suffix) { - $this->addIds($info, $settings, $containerId, $prefix . $suffix, ContentTypeHelper::CONTENT_TYPE_TAXONOMY); + foreach (self::TERM_ID_SUFFIXES as $suffix => $remapOnly) { + $this->addIds($info, $settings, $containerId, $prefix . $suffix, ContentTypeHelper::CONTENT_TYPE_TAXONOMY, $remapOnly); } - foreach (self::POST_ID_SUFFIXES as $suffix) { - $this->addIds($info, $settings, $containerId, $prefix . $suffix, ContentTypeHelper::CONTENT_TYPE_POST); + foreach (self::POST_ID_SUFFIXES as $suffix => $remapOnly) { + $this->addIds($info, $settings, $containerId, $prefix . $suffix, ContentTypeHelper::CONTENT_TYPE_POST, $remapOnly); } return $info; } - private function addIds(RelatedContentInfo $info, array $settings, string $containerId, string $key, string $contentType): void + /** + * 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) { - if (is_numeric($id) && (int)$id > 0) { - $info->addContent(new Content((int)$id, $contentType), $containerId, "settings/$key/$index"); + $id = $this->toId($id); + if ($id !== null) { + $info->addContent(new Content($id, $contentType, $remapOnly), $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; + } } diff --git a/inc/Smartling/ContentTypes/Elementor/Elements/LoopCarousel.php b/inc/Smartling/ContentTypes/Elementor/Elements/LoopCarousel.php index e420c972a..322ff8907 100644 --- a/inc/Smartling/ContentTypes/Elementor/Elements/LoopCarousel.php +++ b/inc/Smartling/ContentTypes/Elementor/Elements/LoopCarousel.php @@ -2,9 +2,7 @@ namespace Smartling\ContentTypes\Elementor\Elements; -use Smartling\ContentTypes\ContentTypeHelper; use Smartling\ContentTypes\Elementor\ElementorQueryRelated; -use Smartling\Models\Content; use Smartling\Models\RelatedContentInfo; class LoopCarousel extends Unknown { @@ -15,13 +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"); - } - - return (new ElementorQueryRelated())->addRelated($return, $this->settings, $this->id, 'post_query_'); + return (new ElementorQueryRelated())->addLoopRelated(parent::getRelated(), $this->settings, $this->id); } } diff --git a/inc/Smartling/ContentTypes/Elementor/Elements/LoopGrid.php b/inc/Smartling/ContentTypes/Elementor/Elements/LoopGrid.php index 57fac85e5..2bd1d9500 100644 --- a/inc/Smartling/ContentTypes/Elementor/Elements/LoopGrid.php +++ b/inc/Smartling/ContentTypes/Elementor/Elements/LoopGrid.php @@ -2,9 +2,7 @@ namespace Smartling\ContentTypes\Elementor\Elements; -use Smartling\ContentTypes\ContentTypeHelper; use Smartling\ContentTypes\Elementor\ElementorQueryRelated; -use Smartling\Models\Content; use Smartling\Models\RelatedContentInfo; class LoopGrid extends Unknown { @@ -15,13 +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"); - } - - return (new ElementorQueryRelated())->addRelated($return, $this->settings, $this->id, 'post_query_'); + return (new ElementorQueryRelated())->addLoopRelated(parent::getRelated(), $this->settings, $this->id); } } diff --git a/inc/Smartling/Helpers/TargetIdFilter.php b/inc/Smartling/Helpers/TargetIdFilter.php index 827c355cf..78f311130 100644 --- a/inc/Smartling/Helpers/TargetIdFilter.php +++ b/inc/Smartling/Helpers/TargetIdFilter.php @@ -10,14 +10,22 @@ /** * Exposes the source id => target id mapping stored in submissions to custom code: * apply_filters('smartling_target_id', 13574, 'attachment') returns the id of the translated attachment in the current blog. + * + * The filter is called from front end code, so it never throws and tolerates input of any type. + * Results are cached in the object cache (per request, unless a persistent object cache is used). */ class TargetIdFilter implements WPHookInterface { use LoggerSafeTrait; + private const CACHE_EXPIRATION = 60; + private const CACHE_KEY_PREFIX = 'target_id_'; + private const NOT_FOUND = 0; + public function __construct( private SubmissionManager $submissionManager, private WordpressFunctionProxyHelper $wpProxy, + private Cache $cache, ) { } @@ -26,35 +34,78 @@ public function register(): void $this->wpProxy->add_filter(ExportedAPI::FILTER_TARGET_ID, [$this, 'getTargetId'], 10, 5); } + /** + * Any submission that has a target id counts as a translation, regardless of its status: + * the target id is set when the target content is created, which can happen before translations are + * downloaded, and it stays set for failed or cancelled submissions of content that already exists in the target blog. + * + * @return mixed target id, or $sourceId unchanged if nothing is found and $fallbackToSource is true, null otherwise + */ public function getTargetId( mixed $sourceId, - string $contentType = '', - ?int $targetBlogId = null, - bool $fallbackToSource = true, - ?int $sourceBlogId = null, - ): ?int { - if (!is_numeric($sourceId) || (int)$sourceId <= 0) { - return null; + mixed $contentType = '', + mixed $targetBlogId = null, + mixed $fallbackToSource = true, + mixed $sourceBlogId = null, + ): mixed { + $fallbackToSource = $fallbackToSource === null ? true : filter_var($fallbackToSource, FILTER_VALIDATE_BOOLEAN); + $fallback = $fallbackToSource ? $sourceId : null; + $id = filter_var($sourceId, FILTER_VALIDATE_INT); + if (!is_int($id) || $id <= 0) { + return $fallback; } - $sourceId = (int)$sourceId; - $parameters = [ - SubmissionEntity::FIELD_SOURCE_ID => $sourceId, - SubmissionEntity::FIELD_TARGET_BLOG_ID => $targetBlogId ?? $this->wpProxy->get_current_blog_id(), - ]; - if ($contentType !== '') { - $parameters[SubmissionEntity::FIELD_CONTENT_TYPE] = $contentType; + + try { + $parameters = [ + SubmissionEntity::FIELD_SOURCE_ID => $id, + SubmissionEntity::FIELD_TARGET_BLOG_ID => $this->toPositiveInt($targetBlogId) ?? $this->wpProxy->get_current_blog_id(), + ]; + if (is_string($contentType) && $contentType !== '') { + $parameters[SubmissionEntity::FIELD_CONTENT_TYPE] = $contentType; + } + $sourceBlogId = $this->toPositiveInt($sourceBlogId); + if ($sourceBlogId !== null) { + $parameters[SubmissionEntity::FIELD_SOURCE_BLOG_ID] = $sourceBlogId; + } + + $targetId = $this->lookup($parameters); + } catch (\Throwable $e) { + $this->getLogger()->error("Unable to get target id for sourceId=$id: " . $e->getMessage()); + + return $fallback; } - if ($sourceBlogId !== null) { - $parameters[SubmissionEntity::FIELD_SOURCE_BLOG_ID] = $sourceBlogId; + + return $targetId > 0 ? $targetId : $fallback; + } + + private function lookup(array $parameters): int + { + $key = self::CACHE_KEY_PREFIX . md5(json_encode($parameters, JSON_THROW_ON_ERROR)); + $cached = $this->cache->get($key); + if (is_int($cached)) { + return $cached; } - $submission = $this->submissionManager->findOne($parameters); - if ($submission !== null && $submission->getTargetId() > 0) { - return $submission->getTargetId(); + $submissions = $this->submissionManager->find($parameters, 2); + $targetId = self::NOT_FOUND; + $context = json_encode($parameters); + if (count($submissions) === 1) { + $targetId = max(self::NOT_FOUND, current($submissions)->getTargetId()); + } + if ($targetId === self::NOT_FOUND) { + $this->getLogger()->notice(count($submissions) > 1 + ? "Found more than one submission, target id is ambiguous, searchParams=$context" + : "No target id found, searchParams=$context"); } + $this->cache->set($key, $targetId, self::CACHE_EXPIRATION); - $this->getLogger()->debug("No target id found for sourceId=$sourceId, contentType=\"$contentType\"" . ($fallbackToSource ? ', falling back to source id' : '')); + return $targetId; + } + + private function toPositiveInt(mixed $value): ?int + { + $result = filter_var($value, FILTER_VALIDATE_INT); - return $fallbackToSource ? $sourceId : null; + return is_int($result) && $result > 0 ? $result : null; } } diff --git a/inc/Smartling/Models/Content.php b/inc/Smartling/Models/Content.php index bbe4a89b5..35fdca55d 100644 --- a/inc/Smartling/Models/Content.php +++ b/inc/Smartling/Models/Content.php @@ -6,9 +6,19 @@ class Content { public function __construct( private int $contentId, private string $contentType, + private bool $remapOnly = false, ) { } + /** + * Remap-only content has its id replaced with the translated one when it is already translated, + * but is never sent for translation by itself + */ + public function isRemapOnly(): bool + { + return $this->remapOnly; + } + public function getId(): int { return $this->contentId; diff --git a/inc/Smartling/Models/RelatedContentInfo.php b/inc/Smartling/Models/RelatedContentInfo.php index 291622919..f4f8fd65c 100644 --- a/inc/Smartling/Models/RelatedContentInfo.php +++ b/inc/Smartling/Models/RelatedContentInfo.php @@ -46,6 +46,9 @@ public function getRelatedContentList(): array $return = []; foreach ($flat as $item) { assert($item instanceof Content); + if ($item->isRemapOnly()) { + continue; + } if (!array_key_exists($item->getType(), $return)) { $return[$item->getType()] = []; } diff --git a/inc/config/services.yml b/inc/config/services.yml index 8d6488263..59df001ee 100644 --- a/inc/config/services.yml +++ b/inc/config/services.yml @@ -608,6 +608,7 @@ services: arguments: - "@manager.submission" - "@wp.proxy" + - "@site.cache" duplicate.submissions.cleaner: class: Smartling\WP\Controller\DuplicateSubmissionsCleaner diff --git a/tests/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTest.php b/tests/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTest.php index 03e6f41f2..712ec7e58 100644 --- a/tests/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTest.php +++ b/tests/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTest.php @@ -26,8 +26,8 @@ public function testLoopWidgetsCollectQueryRelatedContent(): void 'post_query_exclude_ids' => ['101'], ]]))->getRelated()->getRelatedContentList(); - $this->assertEquals(['14', '15', '16'], array_map('strval', $list[ContentTypeHelper::CONTENT_TYPE_TAXONOMY]), $class); - $this->assertEqualsCanonicalizing(['7', '100', '101'], array_map('strval', $list[ContentTypeHelper::CONTENT_TYPE_POST]), $class); + $this->assertEquals(['14', '15'], array_map('strval', $list[ContentTypeHelper::CONTENT_TYPE_TAXONOMY]), $class); + $this->assertEqualsCanonicalizing(['7', '100'], array_map('strval', $list[ContentTypeHelper::CONTENT_TYPE_POST]), $class); } } @@ -39,7 +39,7 @@ public function testPostsWidgetCollectsQueryRelatedContent(): void 'posts_posts_ids' => ['50'], ]]))->getRelated()->getRelatedContentList(); - $this->assertEquals(['3', '4'], array_map('strval', $list[ContentTypeHelper::CONTENT_TYPE_TAXONOMY])); + $this->assertEquals(['3'], array_map('strval', $list[ContentTypeHelper::CONTENT_TYPE_TAXONOMY])); $this->assertEquals(['50'], array_map('strval', $list[ContentTypeHelper::CONTENT_TYPE_POST])); } @@ -52,4 +52,53 @@ public function testInvalidQueryValuesAreIgnored(): void $this->assertArrayNotHasKey(ContentTypeHelper::CONTENT_TYPE_TAXONOMY, $list); } + + public function testExcludedContentIsRemapOnly(): void + { + $info = (new LoopGrid(['id' => 'w1', 'settings' => [ + 'template_id' => '7', + 'post_query_include_term_ids' => ['14'], + 'post_query_exclude_term_ids' => ['16'], + 'post_query_posts_ids' => ['100'], + 'post_query_exclude_ids' => ['101'], + ]]))->getRelated(); + + $own = $info->getOwnRelatedContent('w1'); + $this->assertCount(5, $own); + $remapOnly = []; + foreach ($own as $path => $content) { + if ($content->isRemapOnly()) { + $remapOnly[] = $path; + } + } + $this->assertEqualsCanonicalizing(['settings/post_query_exclude_term_ids/0', 'settings/post_query_exclude_ids/0'], $remapOnly); + } + + public function testOnlyExcludedContentSubmitsNothing(): void + { + $list = (new LoopGrid(['id' => 'w1', 'settings' => [ + 'post_query_exclude_term_ids' => ['16'], + 'post_query_exclude_ids' => ['101'], + ]]))->getRelated()->getRelatedContentList(); + + $this->assertSame([], $list); + } + + public function testNonIntegerIdsAreIgnored(): void + { + $list = (new LoopGrid(['id' => 'w1', 'settings' => [ + 'post_query_posts_ids' => ['1e3', '1.5', ' ', '-4', '12'], + ]]))->getRelated()->getRelatedContentList(); + + $this->assertEquals(['12'], array_map('strval', $list[ContentTypeHelper::CONTENT_TYPE_POST])); + } + + public function testInvalidTemplateIdIsIgnored(): void + { + foreach ([LoopGrid::class, LoopCarousel::class] as $class) { + foreach ([['template_id' => 'abc'], ['template_id' => '0'], ['template_id' => ['1']], []] as $settings) { + $this->assertSame([], (new $class(['id' => 'w1', 'settings' => $settings]))->getRelated()->getRelatedContentList(), $class); + } + } + } } diff --git a/tests/Smartling/Helpers/TargetIdFilterTest.php b/tests/Smartling/Helpers/TargetIdFilterTest.php index 8d3bf0507..4f8f91c2c 100644 --- a/tests/Smartling/Helpers/TargetIdFilterTest.php +++ b/tests/Smartling/Helpers/TargetIdFilterTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use Smartling\Base\ExportedAPI; +use Smartling\Helpers\Cache; use Smartling\Helpers\TargetIdFilter; use Smartling\Helpers\WordpressFunctionProxyHelper; use Smartling\Submissions\SubmissionEntity; @@ -11,19 +12,48 @@ class TargetIdFilterTest extends TestCase { - private function makeFilter(?SubmissionEntity $found, ?array $expectedParameters = null, int $currentBlogId = 3): TargetIdFilter + private function makeCache(): Cache + { + return new class implements Cache { + public array $data = []; + + public function delete(string $key): bool + { + unset($this->data[$key]); + + return true; + } + + public function get(string $key): mixed + { + return $this->data[$key] ?? false; + } + + public function set(string $key, mixed $data, ?int $expire = null): bool + { + $this->data[$key] = $data; + + return true; + } + }; + } + + /** + * @param SubmissionEntity[] $found + */ + private function makeFilter(array $found, ?array $expectedParameters = null, int $currentBlogId = 3, ?Cache $cache = null, int $calls = 1): TargetIdFilter { $manager = $this->createMock(SubmissionManager::class); - $expectation = $manager->expects($this->once())->method('findOne'); + $expectation = $manager->expects($this->exactly($calls))->method('find'); if ($expectedParameters !== null) { - $expectation->with($expectedParameters); + $expectation->with($expectedParameters, 2); } $expectation->willReturn($found); $proxy = $this->createMock(WordpressFunctionProxyHelper::class); $proxy->method('get_current_blog_id')->willReturn($currentBlogId); - return new TargetIdFilter($manager, $proxy); + return new TargetIdFilter($manager, $proxy, $cache ?? $this->makeCache()); } private function makeSubmission(int $targetId): SubmissionEntity @@ -34,17 +64,25 @@ private function makeSubmission(int $targetId): SubmissionEntity return $submission; } + private function makeUnusedFilter(): TargetIdFilter + { + $manager = $this->createMock(SubmissionManager::class); + $manager->expects($this->never())->method('find'); + + return new TargetIdFilter($manager, $this->createMock(WordpressFunctionProxyHelper::class), $this->makeCache()); + } + public function testRegistersFilter(): void { $proxy = $this->createMock(WordpressFunctionProxyHelper::class); $proxy->expects($this->once())->method('add_filter')->with(ExportedAPI::FILTER_TARGET_ID, $this->anything(), 10, 5); - (new TargetIdFilter($this->createMock(SubmissionManager::class), $proxy))->register(); + (new TargetIdFilter($this->createMock(SubmissionManager::class), $proxy, $this->makeCache()))->register(); } public function testReturnsTargetIdForCurrentBlog(): void { - $filter = $this->makeFilter($this->makeSubmission(1335), [ + $filter = $this->makeFilter([$this->makeSubmission(1335)], [ SubmissionEntity::FIELD_SOURCE_ID => 13574, SubmissionEntity::FIELD_TARGET_BLOG_ID => 3, SubmissionEntity::FIELD_CONTENT_TYPE => 'attachment', @@ -55,7 +93,7 @@ public function testReturnsTargetIdForCurrentBlog(): void public function testExplicitBlogsAreUsed(): void { - $filter = $this->makeFilter($this->makeSubmission(7), [ + $filter = $this->makeFilter([$this->makeSubmission(7)], [ SubmissionEntity::FIELD_SOURCE_ID => 5, SubmissionEntity::FIELD_TARGET_BLOG_ID => 2, SubmissionEntity::FIELD_SOURCE_BLOG_ID => 1, @@ -64,30 +102,108 @@ public function testExplicitBlogsAreUsed(): void $this->assertSame(7, $filter->getTargetId('5', '', 2, false, 1)); } + public function testSourceBlogIdIsCombinedWithContentType(): void + { + $filter = $this->makeFilter([$this->makeSubmission(7)], [ + SubmissionEntity::FIELD_SOURCE_ID => 5, + SubmissionEntity::FIELD_TARGET_BLOG_ID => 3, + SubmissionEntity::FIELD_CONTENT_TYPE => 'post', + SubmissionEntity::FIELD_SOURCE_BLOG_ID => 1, + ]); + + $this->assertSame(7, $filter->getTargetId(5, 'post', null, true, 1)); + } + public function testNoTranslationReturnsNull(): void { - $this->assertNull($this->makeFilter(null)->getTargetId(10, 'post', null, false)); + $this->assertNull($this->makeFilter([])->getTargetId(10, 'post', null, false)); } public function testNoTranslationFallsBackToSourceIdByDefault(): void { - $this->assertSame(10, $this->makeFilter(null)->getTargetId(10, 'post')); + $this->assertSame(10, $this->makeFilter([])->getTargetId(10, 'post')); + } + + public function testAmbiguousResultIsNotATranslation(): void + { + $submissions = [$this->makeSubmission(1), $this->makeSubmission(2)]; + + $this->assertNull($this->makeFilter($submissions)->getTargetId(10, '', null, false)); + $this->assertSame(10, $this->makeFilter($submissions)->getTargetId(10)); } public function testZeroTargetIdIsNotATranslation(): void { - $this->assertNull($this->makeFilter($this->makeSubmission(0))->getTargetId(10, 'post', null, false)); - $this->assertSame(10, $this->makeFilter($this->makeSubmission(0))->getTargetId(10, 'post')); + $this->assertNull($this->makeFilter([$this->makeSubmission(0)])->getTargetId(10, 'post', null, false)); + $this->assertSame(10, $this->makeFilter([$this->makeSubmission(0)])->getTargetId(10, 'post')); } - public function testInvalidSourceIdIsNotLookedUp(): void + public function testInvalidSourceIdIsReturnedUnchangedWithFallback(): void { - $manager = $this->createMock(SubmissionManager::class); - $manager->expects($this->never())->method('findOne'); - $filter = new TargetIdFilter($manager, $this->createMock(WordpressFunctionProxyHelper::class)); + $filter = $this->makeUnusedFilter(); - $this->assertNull($filter->getTargetId('abc', 'post')); - $this->assertNull($filter->getTargetId(0, 'post')); + $this->assertSame('abc', $filter->getTargetId('abc', 'post')); + $this->assertSame(0, $filter->getTargetId(0, 'post')); + $this->assertSame('', $filter->getTargetId('', 'post')); $this->assertNull($filter->getTargetId(null, 'post')); + $this->assertSame(-5, $filter->getTargetId(-5)); + } + + public function testInvalidSourceIdReturnsNullWithoutFallback(): void + { + $filter = $this->makeUnusedFilter(); + + $this->assertNull($filter->getTargetId('abc', 'post', null, false)); + $this->assertNull($filter->getTargetId(0, 'post', null, false)); + $this->assertNull($filter->getTargetId(1.5, 'post', null, false)); + $this->assertNull($filter->getTargetId('1e3', 'post', null, false)); + } + + public function testUnexpectedArgumentTypesDoNotThrow(): void + { + $filter = $this->makeFilter([$this->makeSubmission(9)], [ + SubmissionEntity::FIELD_SOURCE_ID => 5, + SubmissionEntity::FIELD_TARGET_BLOG_ID => 3, + ]); + + $this->assertSame(9, $filter->getTargetId(5, null, 'abc', 'yes', [])); + } + + public function testLookupFailureFallsBack(): void + { + $manager = $this->createMock(SubmissionManager::class); + $manager->method('find')->willThrowException(new \RuntimeException('db is down')); + $proxy = $this->createMock(WordpressFunctionProxyHelper::class); + $proxy->method('get_current_blog_id')->willReturn(1); + $filter = new TargetIdFilter($manager, $proxy, $this->makeCache()); + + $this->assertSame(10, $filter->getTargetId(10)); + $this->assertNull($filter->getTargetId(10, '', null, false)); + } + + public function testResultsAreCached(): void + { + $filter = $this->makeFilter([$this->makeSubmission(55)]); + + $this->assertSame(55, $filter->getTargetId(10, 'post')); + $this->assertSame(55, $filter->getTargetId(10, 'post')); + } + + public function testMissesAreCached(): void + { + $filter = $this->makeFilter([]); + + $this->assertSame(10, $filter->getTargetId(10, 'post')); + $this->assertNull($filter->getTargetId(10, 'post', null, false)); + } + + public function testCacheIsSharedBetweenInstancesAndKeyedByParameters(): void + { + $cache = $this->makeCache(); + $this->makeFilter([$this->makeSubmission(55)], null, 3, $cache)->getTargetId(10, 'post'); + + $this->assertSame(55, $this->makeFilter([], null, 3, $cache, 0)->getTargetId(10, 'post')); + $this->assertSame(10, $this->makeFilter([], null, 3, $cache, 1)->getTargetId(10, 'page')); + $this->assertSame(10, $this->makeFilter([], null, 4, $cache, 1)->getTargetId(10, 'post')); } } From 00e32dcdc2c928190104bd2fa31913d8a5db732f Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Thu, 8 Oct 2026 14:39:34 +0200 Subject: [PATCH 6/9] update claude.md --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index dd99e4f79..888c63678 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,7 +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 traits in favor of composition +- Avoid adding new traits in favor of composition ### Content Processing - All content goes through serialization/deserialization pipeline From 40e4c583565b6d463b93db867d89b6e0270d1654 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Thu, 8 Oct 2026 19:32:30 +0200 Subject: [PATCH 7/9] address PR #637 review: collect only active Elementor query modes, merge taxonomy references, document remap-only (WP-1020) Co-Authored-By: Claude Sonnet 5.5 --- docs/ELEMENTOR_DEVELOPMENT.md | 11 ++++ .../Elementor/ElementorQueryRelated.php | 29 ++++++++--- .../ContentRelationsDiscoveryService.php | 2 +- readme.txt | 3 ++ .../ContentRelationsDiscoveryServiceTest.php | 16 ++++++ .../Elementor/ElementorQueryRelatedTest.php | 52 +++++++++++++++++++ .../Elementor/LoopCarouselTest.php | 1 + .../ContentTypes/Elementor/PostsTest.php | 5 +- 8 files changed, 109 insertions(+), 10 deletions(-) diff --git a/docs/ELEMENTOR_DEVELOPMENT.md b/docs/ELEMENTOR_DEVELOPMENT.md index 45ac2d94f..e65f5b4f0 100644 --- a/docs/ELEMENTOR_DEVELOPMENT.md +++ b/docs/ELEMENTOR_DEVELOPMENT.md @@ -455,3 +455,14 @@ public function getRelated(): RelatedContentInfo ``` **Example:** `Elements/LoopGrid.php`, `Elements/Posts.php` (prefix `posts_`) + +**Only the active query mode is collected.** Elementor keeps the values of hidden controls (e.g. a manual selection left over after switching to "by category") but ignores them, so each list is collected only when its mode setting selects it: + +| Setting | Collected when | +|---------|----------------| +| `{prefix}include_term_ids` | `{prefix}include` contains `terms` | +| `{prefix}exclude_term_ids` | `{prefix}exclude` contains `terms` | +| `{prefix}posts_ids` | `{prefix}post_type` is `by_id` | +| `{prefix}exclude_ids` | `{prefix}exclude` contains `manual_selection` | + +**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. diff --git a/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php b/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php index d24b089e9..08b7c1646 100644 --- a/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php +++ b/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php @@ -19,21 +19,36 @@ class ElementorQueryRelated { use LoggerSafeTrait; - private const TERM_ID_SUFFIXES = ['include_term_ids' => false, 'exclude_term_ids' => true]; - private const POST_ID_SUFFIXES = ['posts_ids' => false, 'exclude_ids' => true]; + /** + * Elementor keeps the values of hidden controls but ignores them unless the query mode matches, so the ids are + * only collected when the mode setting (relative to the widget prefix) has the expected value. + * suffix => [content type, remap only, mode setting suffix, expected mode value] + */ + private const QUERY_SETTINGS = [ + 'include_term_ids' => [ContentTypeHelper::CONTENT_TYPE_TAXONOMY, false, 'include', 'terms'], + 'exclude_term_ids' => [ContentTypeHelper::CONTENT_TYPE_TAXONOMY, true, 'exclude', 'terms'], + 'posts_ids' => [ContentTypeHelper::CONTENT_TYPE_POST, false, 'post_type', 'by_id'], + 'exclude_ids' => [ContentTypeHelper::CONTENT_TYPE_POST, true, 'exclude', 'manual_selection'], + ]; public function addRelated(RelatedContentInfo $info, array $settings, string $containerId, string $prefix): RelatedContentInfo { - foreach (self::TERM_ID_SUFFIXES as $suffix => $remapOnly) { - $this->addIds($info, $settings, $containerId, $prefix . $suffix, ContentTypeHelper::CONTENT_TYPE_TAXONOMY, $remapOnly); - } - foreach (self::POST_ID_SUFFIXES as $suffix => $remapOnly) { - $this->addIds($info, $settings, $containerId, $prefix . $suffix, ContentTypeHelper::CONTENT_TYPE_POST, $remapOnly); + foreach (self::QUERY_SETTINGS as $suffix => [$contentType, $remapOnly, $modeSuffix, $mode]) { + if ($this->isModeActive($settings[$prefix . $modeSuffix] ?? null, $mode)) { + $this->addIds($info, $settings, $containerId, $prefix . $suffix, $contentType, $remapOnly); + } else { + $this->getLogger()->debug("Skipping {$prefix}{$suffix}, {$prefix}{$modeSuffix} does not select \"$mode\", containerId=$containerId"); + } } 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 */ diff --git a/inc/Smartling/Services/ContentRelationsDiscoveryService.php b/inc/Smartling/Services/ContentRelationsDiscoveryService.php index 07ca71fc1..2d9c7570b 100644 --- a/inc/Smartling/Services/ContentRelationsDiscoveryService.php +++ b/inc/Smartling/Services/ContentRelationsDiscoveryService.php @@ -574,7 +574,7 @@ public function normalizeReferences(array $references): array if (isset($references['taxonomies'])) { foreach ($references['taxonomies'] as $taxonomy => $ids) { - $result[$taxonomy] = $ids; + $result[$taxonomy] = array_merge($result[$taxonomy] ?? [], $ids); } } diff --git a/readme.txt b/readme.txt index 00e36070e..67a3b608b 100755 --- a/readme.txt +++ b/readme.txt @@ -63,6 +63,9 @@ Additional information on the Smartling Connector for WordPress can be found [he == Changelog == = 5.8.0 = +* 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 active query mode). 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. * Added support for PublishPress Revisions: translations of a revision are kept and linked to the original post when the revision is published. A revision is translated into a separate post in each target site, whose status on download follows the "Translation publishing mode" profile setting. If the original post already had a translation, that previous translation is no longer linked and keeps its status, so remove or replace it when publishing the new one. * Publishing a draft or changing its slug no longer marks its translation as outdated. Submissions created before this release can be marked as outdated once on the next save. * Improved the Visual Configurator: rules can now be scoped to a specific Elementor widget type and to particular object properties, with recursive path matching so custom widgets nested at any depth are picked up. The rule editor shows a live preview of matching content, and rules can be exported to and imported from JSON. diff --git a/tests/Services/ContentRelationsDiscoveryServiceTest.php b/tests/Services/ContentRelationsDiscoveryServiceTest.php index ca8b89905..fa8769fba 100644 --- a/tests/Services/ContentRelationsDiscoveryServiceTest.php +++ b/tests/Services/ContentRelationsDiscoveryServiceTest.php @@ -874,6 +874,22 @@ public function testAcfReferencesArraysDetected() { ], [], '', []))); } + public function testNormalizeReferencesMergesTaxonomyReferencesWithDetectedTerms() + { + $wpProxy = $this->createMock(WordpressFunctionProxyHelper::class); + $wpProxy->method('getTerm')->willReturn(['taxonomy' => 'category']); + + $x = $this->getContentRelationDiscoveryService(wpProxy: $wpProxy); + + $this->assertEquals( + ['category' => [5, 7]], + $x->normalizeReferences([ + 'taxonomy' => [5], + 'taxonomies' => ['category' => [7]], + ]), + ); + } + public function testGetRelations() { $contentType = 'post'; diff --git a/tests/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTest.php b/tests/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTest.php index 712ec7e58..b0b2debd6 100644 --- a/tests/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTest.php +++ b/tests/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTest.php @@ -20,6 +20,9 @@ public function testLoopWidgetsCollectQueryRelatedContent(): void foreach ([LoopGrid::class, LoopCarousel::class] as $class) { $list = (new $class(['id' => 'w1', 'settings' => [ 'template_id' => '7', + 'post_query_post_type' => 'by_id', + 'post_query_include' => ['terms'], + 'post_query_exclude' => ['terms', 'manual_selection'], 'post_query_include_term_ids' => ['14', '15'], 'post_query_exclude_term_ids' => ['16'], 'post_query_posts_ids' => ['100'], @@ -34,6 +37,9 @@ public function testLoopWidgetsCollectQueryRelatedContent(): void public function testPostsWidgetCollectsQueryRelatedContent(): void { $list = (new Posts(['id' => 'w1', 'settings' => [ + 'posts_post_type' => 'by_id', + 'posts_include' => ['terms'], + 'posts_exclude' => ['terms'], 'posts_include_term_ids' => ['3'], 'posts_exclude_term_ids' => ['4'], 'posts_posts_ids' => ['50'], @@ -46,6 +52,8 @@ public function testPostsWidgetCollectsQueryRelatedContent(): void public function testInvalidQueryValuesAreIgnored(): void { $list = (new LoopGrid(['id' => 'w1', 'settings' => [ + 'post_query_include' => ['terms'], + 'post_query_exclude' => ['terms'], 'post_query_include_term_ids' => 'not-an-array', 'post_query_exclude_term_ids' => ['', 'abc', '0'], ]]))->getRelated()->getRelatedContentList(); @@ -57,6 +65,9 @@ public function testExcludedContentIsRemapOnly(): void { $info = (new LoopGrid(['id' => 'w1', 'settings' => [ 'template_id' => '7', + 'post_query_post_type' => 'by_id', + 'post_query_include' => ['terms'], + 'post_query_exclude' => ['terms', 'manual_selection'], 'post_query_include_term_ids' => ['14'], 'post_query_exclude_term_ids' => ['16'], 'post_query_posts_ids' => ['100'], @@ -77,6 +88,7 @@ public function testExcludedContentIsRemapOnly(): void public function testOnlyExcludedContentSubmitsNothing(): void { $list = (new LoopGrid(['id' => 'w1', 'settings' => [ + 'post_query_exclude' => ['terms', 'manual_selection'], 'post_query_exclude_term_ids' => ['16'], 'post_query_exclude_ids' => ['101'], ]]))->getRelated()->getRelatedContentList(); @@ -87,6 +99,7 @@ public function testOnlyExcludedContentSubmitsNothing(): void public function testNonIntegerIdsAreIgnored(): void { $list = (new LoopGrid(['id' => 'w1', 'settings' => [ + 'post_query_post_type' => 'by_id', 'post_query_posts_ids' => ['1e3', '1.5', ' ', '-4', '12'], ]]))->getRelated()->getRelatedContentList(); @@ -101,4 +114,43 @@ public function testInvalidTemplateIdIsIgnored(): void } } } + + public function testIdsOfInactiveQueryModesAreIgnored(): void + { + $settings = [ + 'post_query_include_term_ids' => ['14'], + 'post_query_exclude_term_ids' => ['16'], + 'post_query_posts_ids' => ['100'], + 'post_query_exclude_ids' => ['101'], + ]; + $modes = [ + 'missing modes' => [], + 'other post type' => ['post_query_post_type' => 'product', 'post_query_include' => [], 'post_query_exclude' => ['current_post']], + 'empty modes' => ['post_query_post_type' => '', 'post_query_include' => '', 'post_query_exclude' => null], + ]; + foreach ($modes as $name => $modeSettings) { + foreach ([LoopGrid::class, LoopCarousel::class] as $class) { + $this->assertSame([], (new $class(['id' => 'w1', 'settings' => $settings + $modeSettings]))->getRelated()->getRelatedContentList(), "$class: $name"); + $this->assertSame([], (new $class(['id' => 'w1', 'settings' => $settings + $modeSettings]))->getRelated()->getOwnRelatedContent('w1'), "$class: $name"); + } + } + } + + public function testEachSettingFollowsItsOwnMode(): void + { + $info = (new Posts(['id' => 'w1', 'settings' => [ + 'posts_post_type' => 'by_id', + 'posts_include' => ['authors'], + 'posts_exclude' => ['manual_selection'], + 'posts_include_term_ids' => ['3'], + 'posts_exclude_term_ids' => ['4'], + 'posts_posts_ids' => ['50'], + 'posts_exclude_ids' => ['51'], + ]]))->getRelated(); + + $this->assertEqualsCanonicalizing( + ['settings/posts_posts_ids/0', 'settings/posts_exclude_ids/0'], + array_keys($info->getOwnRelatedContent('w1')), + ); + } } diff --git a/tests/Smartling/ContentTypes/Elementor/LoopCarouselTest.php b/tests/Smartling/ContentTypes/Elementor/LoopCarouselTest.php index 0149a18d2..2f867d2c8 100644 --- a/tests/Smartling/ContentTypes/Elementor/LoopCarouselTest.php +++ b/tests/Smartling/ContentTypes/Elementor/LoopCarouselTest.php @@ -42,6 +42,7 @@ public function testTermIdRelatedContent(): void { $relatedList = (new LoopCarousel([ 'settings' => [ + 'post_query_include' => ['terms'], 'post_query_include_term_ids' => ['14', '15', '16'] ] ]))->getRelated()->getRelatedContentList(); diff --git a/tests/Smartling/ContentTypes/Elementor/PostsTest.php b/tests/Smartling/ContentTypes/Elementor/PostsTest.php index d1d58abba..73f423d6a 100644 --- a/tests/Smartling/ContentTypes/Elementor/PostsTest.php +++ b/tests/Smartling/ContentTypes/Elementor/PostsTest.php @@ -75,7 +75,7 @@ public function testGetRelatedNoTemplateReturnsEmpty(): void public function testGetRelatedReturnsTermIds(): void { - $related = $this->makeWidget(['posts_include_term_ids' => [42, 99]])->getRelated(); + $related = $this->makeWidget(['posts_include' => ['terms'], 'posts_include_term_ids' => [42, 99]])->getRelated(); $this->assertEquals( [ContentTypeHelper::CONTENT_TYPE_TAXONOMY => [42, 99]], @@ -85,7 +85,7 @@ public function testGetRelatedReturnsTermIds(): void public function testGetRelatedSkipsNonNumericTermIds(): void { - $related = $this->makeWidget(['posts_include_term_ids' => ['invalid', 42]])->getRelated(); + $related = $this->makeWidget(['posts_include' => ['terms'], 'posts_include_term_ids' => ['invalid', 42]])->getRelated(); $this->assertEquals( [ContentTypeHelper::CONTENT_TYPE_TAXONOMY => [42]], @@ -97,6 +97,7 @@ public function testGetRelatedReturnsBothTemplateAndTermIds(): void { $related = $this->makeWidget([ 'custom_skin_template' => '9165', + 'posts_include' => ['terms'], 'posts_include_term_ids' => ["42", 99], ])->getRelated(); From 347aaeece42079518efd7fb6cf2dbfef9e3915a9 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Thu, 8 Oct 2026 20:41:41 +0200 Subject: [PATCH 8/9] ignore Elementor query term lists and exclude_ids in manual selection mode (WP-1020) Co-Authored-By: Claude Sonnet 5.5 --- docs/ELEMENTOR_DEVELOPMENT.md | 10 ++-- .../Elementor/ElementorQueryRelated.php | 30 ++++++----- .../Elementor/ElementorQueryRelatedTest.php | 51 +++++++++++++------ 3 files changed, 58 insertions(+), 33 deletions(-) diff --git a/docs/ELEMENTOR_DEVELOPMENT.md b/docs/ELEMENTOR_DEVELOPMENT.md index e65f5b4f0..e84b691dc 100644 --- a/docs/ELEMENTOR_DEVELOPMENT.md +++ b/docs/ELEMENTOR_DEVELOPMENT.md @@ -456,13 +456,13 @@ public function getRelated(): RelatedContentInfo **Example:** `Elements/LoopGrid.php`, `Elements/Posts.php` (prefix `posts_`) -**Only the active query mode is collected.** Elementor keeps the values of hidden controls (e.g. a manual selection left over after switching to "by category") but ignores them, so each list is collected only when its mode setting selects it: +**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}include_term_ids` | `{prefix}include` contains `terms` | -| `{prefix}exclude_term_ids` | `{prefix}exclude` contains `terms` | -| `{prefix}posts_ids` | `{prefix}post_type` is `by_id` | -| `{prefix}exclude_ids` | `{prefix}exclude` contains `manual_selection` | +| `{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` | **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. diff --git a/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php b/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php index 08b7c1646..2bfe4929a 100644 --- a/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php +++ b/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php @@ -19,26 +19,32 @@ class ElementorQueryRelated { use LoggerSafeTrait; + private const MANUAL_SELECTION = 'by_id'; + /** - * Elementor keeps the values of hidden controls but ignores them unless the query mode matches, so the ids are - * only collected when the mode setting (relative to the widget prefix) has the expected value. - * suffix => [content type, remap only, mode setting suffix, expected mode value] + * 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. + * 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, 'include', 'terms'], - 'exclude_term_ids' => [ContentTypeHelper::CONTENT_TYPE_TAXONOMY, true, 'exclude', 'terms'], - 'posts_ids' => [ContentTypeHelper::CONTENT_TYPE_POST, false, 'post_type', 'by_id'], - 'exclude_ids' => [ContentTypeHelper::CONTENT_TYPE_POST, true, 'exclude', 'manual_selection'], + '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 { - foreach (self::QUERY_SETTINGS as $suffix => [$contentType, $remapOnly, $modeSuffix, $mode]) { - if ($this->isModeActive($settings[$prefix . $modeSuffix] ?? null, $mode)) { - $this->addIds($info, $settings, $containerId, $prefix . $suffix, $contentType, $remapOnly); - } else { - $this->getLogger()->debug("Skipping {$prefix}{$suffix}, {$prefix}{$modeSuffix} does not select \"$mode\", containerId=$containerId"); + $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; diff --git a/tests/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTest.php b/tests/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTest.php index b0b2debd6..9794a2ed7 100644 --- a/tests/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTest.php +++ b/tests/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTest.php @@ -18,9 +18,9 @@ public function testLoopGridType(): void public function testLoopWidgetsCollectQueryRelatedContent(): void { foreach ([LoopGrid::class, LoopCarousel::class] as $class) { - $list = (new $class(['id' => 'w1', 'settings' => [ + $byTerms = (new $class(['id' => 'w1', 'settings' => [ 'template_id' => '7', - 'post_query_post_type' => 'by_id', + 'post_query_post_type' => 'product', 'post_query_include' => ['terms'], 'post_query_exclude' => ['terms', 'manual_selection'], 'post_query_include_term_ids' => ['14', '15'], @@ -29,15 +29,28 @@ public function testLoopWidgetsCollectQueryRelatedContent(): void 'post_query_exclude_ids' => ['101'], ]]))->getRelated()->getRelatedContentList(); - $this->assertEquals(['14', '15'], array_map('strval', $list[ContentTypeHelper::CONTENT_TYPE_TAXONOMY]), $class); - $this->assertEqualsCanonicalizing(['7', '100'], array_map('strval', $list[ContentTypeHelper::CONTENT_TYPE_POST]), $class); + $this->assertEquals(['14', '15'], array_map('strval', $byTerms[ContentTypeHelper::CONTENT_TYPE_TAXONOMY]), $class); + $this->assertEquals(['7'], array_map('strval', $byTerms[ContentTypeHelper::CONTENT_TYPE_POST]), $class); + + $byId = (new $class(['id' => 'w1', 'settings' => [ + 'template_id' => '7', + 'post_query_post_type' => 'by_id', + 'post_query_include' => ['terms'], + 'post_query_exclude' => ['terms', 'manual_selection'], + 'post_query_include_term_ids' => ['14'], + 'post_query_posts_ids' => ['100'], + 'post_query_exclude_ids' => ['101'], + ]]))->getRelated()->getRelatedContentList(); + + $this->assertArrayNotHasKey(ContentTypeHelper::CONTENT_TYPE_TAXONOMY, $byId, $class); + $this->assertEqualsCanonicalizing(['7', '100'], array_map('strval', $byId[ContentTypeHelper::CONTENT_TYPE_POST]), $class); } } public function testPostsWidgetCollectsQueryRelatedContent(): void { - $list = (new Posts(['id' => 'w1', 'settings' => [ - 'posts_post_type' => 'by_id', + $byTerms = (new Posts(['id' => 'w1', 'settings' => [ + 'posts_post_type' => 'post', 'posts_include' => ['terms'], 'posts_exclude' => ['terms'], 'posts_include_term_ids' => ['3'], @@ -45,8 +58,18 @@ public function testPostsWidgetCollectsQueryRelatedContent(): void 'posts_posts_ids' => ['50'], ]]))->getRelated()->getRelatedContentList(); - $this->assertEquals(['3'], array_map('strval', $list[ContentTypeHelper::CONTENT_TYPE_TAXONOMY])); - $this->assertEquals(['50'], array_map('strval', $list[ContentTypeHelper::CONTENT_TYPE_POST])); + $this->assertEquals(['3'], array_map('strval', $byTerms[ContentTypeHelper::CONTENT_TYPE_TAXONOMY])); + $this->assertArrayNotHasKey(ContentTypeHelper::CONTENT_TYPE_POST, $byTerms); + + $byId = (new Posts(['id' => 'w1', 'settings' => [ + 'posts_post_type' => 'by_id', + 'posts_include' => ['terms'], + 'posts_include_term_ids' => ['3'], + 'posts_posts_ids' => ['50'], + ]]))->getRelated()->getRelatedContentList(); + + $this->assertArrayNotHasKey(ContentTypeHelper::CONTENT_TYPE_TAXONOMY, $byId); + $this->assertEquals(['50'], array_map('strval', $byId[ContentTypeHelper::CONTENT_TYPE_POST])); } public function testInvalidQueryValuesAreIgnored(): void @@ -65,17 +88,16 @@ public function testExcludedContentIsRemapOnly(): void { $info = (new LoopGrid(['id' => 'w1', 'settings' => [ 'template_id' => '7', - 'post_query_post_type' => 'by_id', + 'post_query_post_type' => 'product', 'post_query_include' => ['terms'], 'post_query_exclude' => ['terms', 'manual_selection'], 'post_query_include_term_ids' => ['14'], 'post_query_exclude_term_ids' => ['16'], - 'post_query_posts_ids' => ['100'], 'post_query_exclude_ids' => ['101'], ]]))->getRelated(); $own = $info->getOwnRelatedContent('w1'); - $this->assertCount(5, $own); + $this->assertCount(4, $own); $remapOnly = []; foreach ($own as $path => $content) { if ($content->isRemapOnly()) { @@ -139,7 +161,7 @@ public function testIdsOfInactiveQueryModesAreIgnored(): void public function testEachSettingFollowsItsOwnMode(): void { $info = (new Posts(['id' => 'w1', 'settings' => [ - 'posts_post_type' => 'by_id', + 'posts_post_type' => 'post', 'posts_include' => ['authors'], 'posts_exclude' => ['manual_selection'], 'posts_include_term_ids' => ['3'], @@ -148,9 +170,6 @@ public function testEachSettingFollowsItsOwnMode(): void 'posts_exclude_ids' => ['51'], ]]))->getRelated(); - $this->assertEqualsCanonicalizing( - ['settings/posts_posts_ids/0', 'settings/posts_exclude_ids/0'], - array_keys($info->getOwnRelatedContent('w1')), - ); + $this->assertEquals(['settings/posts_exclude_ids/0'], array_keys($info->getOwnRelatedContent('w1'))); } } From faf59505afa0f8479ba71ee0030158243b024bb8 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Thu, 8 Oct 2026 20:44:36 +0200 Subject: [PATCH 9/9] convert Elementor query term_taxonomy_ids to term_ids and back (WP-1020) Co-Authored-By: Claude Sonnet 5.5 --- docs/ELEMENTOR_DEVELOPMENT.md | 2 + .../Elementor/ElementAbstract.php | 7 ++ .../Elementor/ElementorQueryRelated.php | 3 +- .../ExternalContentElementorInterface.php | 10 +++ .../ExternalContentElementorAbstract.php | 59 ++++++++++++++-- .../Helpers/WordpressFunctionProxyHelper.php | 7 ++ inc/Smartling/Models/Content.php | 19 +++++ inc/Smartling/Models/RelatedContentInfo.php | 31 ++++++++ tests/Mocks/WordpressFunctionsMockHelper.php | 2 +- .../ExternalContentElementor3Test.php | 70 +++++++++++++++++++ .../Models/RelatedContentInfoTest.php | 38 ++++++++++ 11 files changed, 242 insertions(+), 6 deletions(-) diff --git a/docs/ELEMENTOR_DEVELOPMENT.md b/docs/ELEMENTOR_DEVELOPMENT.md index e84b691dc..af32c0a1d 100644 --- a/docs/ELEMENTOR_DEVELOPMENT.md +++ b/docs/ELEMENTOR_DEVELOPMENT.md @@ -465,4 +465,6 @@ public function getRelated(): RelatedContentInfo | `{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. diff --git a/inc/Smartling/ContentTypes/Elementor/ElementAbstract.php b/inc/Smartling/ContentTypes/Elementor/ElementAbstract.php index aef003315..7dd7987f9 100644 --- a/inc/Smartling/ContentTypes/Elementor/ElementAbstract.php +++ b/inc/Smartling/ContentTypes/Elementor/ElementAbstract.php @@ -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; diff --git a/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php b/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php index 2bfe4929a..cf1044956 100644 --- a/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php +++ b/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php @@ -25,6 +25,7 @@ class ElementorQueryRelated * 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 = [ @@ -79,7 +80,7 @@ private function addIds(RelatedContentInfo $info, array $settings, string $conta foreach ($ids as $index => $id) { $id = $this->toId($id); if ($id !== null) { - $info->addContent(new Content($id, $contentType, $remapOnly), $containerId, "settings/$key/$index"); + $info->addContent(new Content($id, $contentType, $remapOnly, $contentType === ContentTypeHelper::CONTENT_TYPE_TAXONOMY), $containerId, "settings/$key/$index"); } } } diff --git a/inc/Smartling/ContentTypes/Elementor/ExternalContentElementorInterface.php b/inc/Smartling/ContentTypes/Elementor/ExternalContentElementorInterface.php index 57075ffbd..d27d46a6e 100644 --- a/inc/Smartling/ContentTypes/Elementor/ExternalContentElementorInterface.php +++ b/inc/Smartling/ContentTypes/Elementor/ExternalContentElementorInterface.php @@ -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; } diff --git a/inc/Smartling/ContentTypes/ExternalContentElementorAbstract.php b/inc/Smartling/ContentTypes/ExternalContentElementorAbstract.php index 6da75b2b1..06150abc6 100644 --- a/inc/Smartling/ContentTypes/ExternalContentElementorAbstract.php +++ b/inc/Smartling/ContentTypes/ExternalContentElementorAbstract.php @@ -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; @@ -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) { @@ -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 = []; @@ -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() ) ); } @@ -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( diff --git a/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php b/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php index b265e7582..abbb2aea9 100644 --- a/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php +++ b/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php @@ -176,6 +176,13 @@ public function get_term() return get_term(...func_get_args()); } + public function getTermByTaxonomyId(int $termTaxonomyId): array|null + { + $term = get_term_by('term_taxonomy_id', $termTaxonomyId, '', ARRAY_A); + + return is_array($term) ? $term : null; + } + public function getTerm(int $termId): array|null|\WP_Error { return get_term($termId, '', ARRAY_A); diff --git a/inc/Smartling/Models/Content.php b/inc/Smartling/Models/Content.php index 35fdca55d..3de6825ac 100644 --- a/inc/Smartling/Models/Content.php +++ b/inc/Smartling/Models/Content.php @@ -7,9 +7,28 @@ public function __construct( private int $contentId, private string $contentType, private bool $remapOnly = false, + private bool $termTaxonomyId = false, ) { } + /** + * The setting this content was found in stores a term_taxonomy_id instead of a term_id (e.g. Elementor Pro queries). + * Until converted, getId() returns the term_taxonomy_id; the converted content has the term_id and keeps the flag, + * so the translated term_id can be converted back before it is written. + */ + public function isTermTaxonomyId(): bool + { + return $this->termTaxonomyId; + } + + public function withId(int $id): self + { + $result = clone $this; + $result->contentId = $id; + + return $result; + } + /** * Remap-only content has its id replaced with the translated one when it is already translated, * but is never sent for translation by itself diff --git a/inc/Smartling/Models/RelatedContentInfo.php b/inc/Smartling/Models/RelatedContentInfo.php index f4f8fd65c..4c5e8359a 100644 --- a/inc/Smartling/Models/RelatedContentInfo.php +++ b/inc/Smartling/Models/RelatedContentInfo.php @@ -61,6 +61,37 @@ public function getRelatedContentList(): array return $return; } + /** + * @param callable(Content): ?Content $callback returns the content to keep, null removes it + */ + public function mapContent(callable $callback): self + { + $result = clone $this; + $result->info = $this->mapContentRecursive($this->info, $callback); + + return $result; + } + + private function mapContentRecursive(array $info, callable $callback): array + { + $result = []; + foreach ($info as $key => $item) { + if ($item instanceof Content) { + $item = $callback($item); + if ($item !== null) { + $result[$key] = $item; + } + } elseif (is_array($item)) { + $mapped = $this->mapContentRecursive($item, $callback); + if ($mapped !== []) { + $result[$key] = $mapped; + } + } + } + + return $result; + } + public function include(self $info, string $containerId): self { $result = clone $this; diff --git a/tests/Mocks/WordpressFunctionsMockHelper.php b/tests/Mocks/WordpressFunctionsMockHelper.php index eef6a4e50..1d6372363 100644 --- a/tests/Mocks/WordpressFunctionsMockHelper.php +++ b/tests/Mocks/WordpressFunctionsMockHelper.php @@ -209,7 +209,7 @@ function get_term($term, $taxonomy = '', $output = 'OBJECT', $filter = 'raw') 'name' => 'Fake Name', 'slug' => 'fake-name', 'term_group' => 0, - 'term_taxonomy_id' => 0, + 'term_taxonomy_id' => is_numeric($term) ? (int)$term + 1000 : 0, 'taxonomy' => $taxonomy, 'description' => '', 'parent' => 0, diff --git a/tests/Smartling/ContentTypes/ExternalContentElementor3Test.php b/tests/Smartling/ContentTypes/ExternalContentElementor3Test.php index 7c2aca857..a2cffd324 100644 --- a/tests/Smartling/ContentTypes/ExternalContentElementor3Test.php +++ b/tests/Smartling/ContentTypes/ExternalContentElementor3Test.php @@ -443,6 +443,76 @@ public function testSetContentFieldsRelationsChange() ); } + private function getQueryElementorData(array $termTaxonomyIds): string + { + return json_encode([[ + 'id' => 'w1', + 'elType' => 'widget', + 'widgetType' => 'loop-carousel', + 'settings' => [ + 'post_query_post_type' => 'product', + 'post_query_include' => ['terms'], + 'post_query_include_term_ids' => $termTaxonomyIds, + ], + 'elements' => [], + ]], JSON_THROW_ON_ERROR); + } + + public function testQueryTermTaxonomyIdsAreConvertedToTermIdsForRelatedContent() + { + $proxy = $this->createMock(WordpressFunctionProxyHelper::class); + $proxy->method('getPostMeta')->willReturn($this->getQueryElementorData(['101', '999'])); + $proxy->method('getTermByTaxonomyId')->willReturnMap([ + [101, ['term_id' => 11, 'term_taxonomy_id' => 101, 'taxonomy' => 'product_category']], + [999, null], + ]); + + $this->assertEquals( + [ContentTypeHelper::CONTENT_TYPE_TAXONOMY => [11]], + $this->getExternalContentElementor($proxy)->getRelatedContent('', 0), + ); + } + + public function testQueryTermIdIsWrittenBackAsTermTaxonomyId() + { + $sourceTermTaxonomyId = 101; + $sourceTermId = 11; + $targetTermId = 21; + $targetTermTaxonomyId = 201; + + $proxy = $this->createMock(WordpressFunctionProxyHelper::class); + $proxy->method('get_plugins')->willReturn(['elementor/elementor.php' => 'elementor']); + $proxy->method('is_plugin_active')->willReturn(true); + $proxy->method('getTermByTaxonomyId')->with($sourceTermTaxonomyId)->willReturn(['term_id' => $sourceTermId, 'taxonomy' => 'product_category']); + $proxy->method('getTerm')->willReturnMap([ + [$sourceTermId, ['term_id' => $sourceTermId, 'term_taxonomy_id' => $sourceTermTaxonomyId, 'taxonomy' => 'product_category']], + [$targetTermId, ['term_id' => $targetTermId, 'term_taxonomy_id' => $targetTermTaxonomyId, 'taxonomy' => 'product_category']], + ]); + + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getSourceBlogId')->willReturn(1); + $submission->method('getTargetBlogId')->willReturn(2); + + $relatedSubmission = $this->createMock(SubmissionEntity::class); + $relatedSubmission->method('getTargetId')->willReturn($targetTermId); + $submissionManager = $this->createMock(SubmissionManager::class); + $submissionManager->expects($this->once())->method('findOne')->with([ + SubmissionEntity::FIELD_SOURCE_BLOG_ID => 1, + SubmissionEntity::FIELD_SOURCE_ID => $sourceTermId, + SubmissionEntity::FIELD_TARGET_BLOG_ID => 2, + SubmissionEntity::FIELD_CONTENT_TYPE => 'product_category', + ])->willReturn($relatedSubmission); + + $original = ['entity' => ['post_content' => ''], 'meta' => [ + ExternalContentElementor3::META_FIELD_NAME => $this->getQueryElementorData(['101']), + ]]; + + $result = $this->getExternalContentElementor($proxy, $submissionManager) + ->setContentFields($original, $original, $submission)['meta'][ExternalContentElementor3::META_FIELD_NAME]; + + $this->assertSame(['201'], json_decode($result, true)[0]['settings']['post_query_include_term_ids']); + } + private function getExternalContentElementor(?WordpressFunctionProxyHelper $proxy = null, ?SubmissionManager $submissionManager = null): ExternalContentElementor3 { $contentTypeHelper = $this->createMock(ContentTypeHelper::class); diff --git a/tests/Smartling/Models/RelatedContentInfoTest.php b/tests/Smartling/Models/RelatedContentInfoTest.php index 807ce0441..8b470fdc6 100644 --- a/tests/Smartling/Models/RelatedContentInfoTest.php +++ b/tests/Smartling/Models/RelatedContentInfoTest.php @@ -63,4 +63,42 @@ public function testIncludeNested() '43b3287' => [$path => new Content(233, ContentTypeHelper::POST_TYPE_ATTACHMENT)], ]], $parentContentInfo->include($contentInfo1, '2171630')->getInfo()); } + + public function testContentWithIdKeepsFlagsAndDoesNotChangeOriginal() + { + $content = new Content(5, 'taxonomy', true, true); + $changed = $content->withId(9); + + $this->assertSame(5, $content->getId()); + $this->assertSame(9, $changed->getId()); + $this->assertSame('taxonomy', $changed->getType()); + $this->assertTrue($changed->isRemapOnly()); + $this->assertTrue($changed->isTermTaxonomyId()); + } + + public function testMapContentReplacesAndRemovesContent() + { + $info = new RelatedContentInfo(); + $info->addContent(new Content(1, 'post'), 'a', 'settings/x'); + $info->addContent(new Content(2, 'post'), 'a', 'settings/y'); + $info->addContent(new Content(3, 'post'), 'b', 'settings/z'); + $nested = (new RelatedContentInfo())->include($info, 'container'); + + $mapped = $nested->mapContent(static fn(Content $c): ?Content => match ($c->getId()) { + 1 => $c->withId(10), + 2 => null, + default => $c, + }); + + $this->assertEquals(['post' => [10, 3]], $mapped->getRelatedContentList()); + $this->assertEquals(['post' => [1, 2, 3]], $nested->getRelatedContentList(), 'original is unchanged'); + } + + public function testMapContentDropsEmptyContainers() + { + $info = new RelatedContentInfo(); + $info->addContent(new Content(1, 'post'), 'a', 'settings/x'); + + $this->assertSame([], $info->mapContent(static fn(): ?Content => null)->getInfo()); + } }