diff --git a/CLAUDE.md b/CLAUDE.md index bfd7dbf41..888c63678 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 adding new 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 f06706985..af32c0a1d 100644 --- a/docs/ELEMENTOR_DEVELOPMENT.md +++ b/docs/ELEMENTOR_DEVELOPMENT.md @@ -442,3 +442,29 @@ foreach ($this->settings['post_query_include_term_ids'] ?? [] as $index => $term - `inc/Smartling/Models/Content.php` — Content reference model - `tests/Smartling/ContentTypes/Elementor/` — Test examples - `tests/Smartling/ContentTypes/ExternalContentElementor4Test.php` — Elementor 4 handler tests + +### Pattern 5: Query Settings (Elementor 3) + +Widgets that run a `WP_Query` (Posts, Loop Grid, Loop Carousel) store the query as prefixed settings, e.g. `post_query_include_term_ids`, `post_query_exclude_term_ids`, `post_query_posts_ids`, `post_query_exclude_ids`. Use `ElementorQueryRelated::addRelated($info, $settings, $containerId, $prefix)` (composition, no trait) in `getRelated()` to register term IDs (taxonomy) and post IDs (post) as related content; `setRelations()` then rewrites each array entry with the translated ID. + +```php +public function getRelated(): RelatedContentInfo +{ + return (new ElementorQueryRelated())->addRelated(parent::getRelated(), $this->settings, $this->id, 'post_query_'); +} +``` + +**Example:** `Elements/LoopGrid.php`, `Elements/Posts.php` (prefix `posts_`) + +**Only the active query mode is collected.** Elementor Pro (`Elementor_Post_Query`, checked against 3.33.2) keeps the values of hidden controls but ignores them unless the query mode matches, so each list is collected only when it is in use: + +| Setting | Collected when | +|---------|----------------| +| `{prefix}posts_ids` | `{prefix}post_type` is `by_id` (manual selection; the only list used in this mode) | +| `{prefix}include_term_ids` | `{prefix}post_type` is not `by_id` and `{prefix}include` contains `terms` | +| `{prefix}exclude_term_ids` | `{prefix}post_type` is not `by_id` and `{prefix}exclude` contains `terms` | +| `{prefix}exclude_ids` | `{prefix}post_type` is not `by_id` and `{prefix}exclude` contains `manual_selection` | + +**Query term lists hold `term_taxonomy_id`s, not `term_id`s** (Elementor Pro resolves them with `get_term_by('term_taxonomy_id')` and queries `'field' => 'term_taxonomy_id'`). `ElementorQueryRelated` marks them with `Content::isTermTaxonomyId()`. `ExternalContentElementorAbstract::getData()` converts them to term IDs in the source blog, so related content discovery, submissions and the taxonomy lookup in `setRelations()` all work with `term_id`s like everywhere else. `setRelations()` converts the translated term ID back to its `term_taxonomy_id` in the target blog before writing it. Terms that cannot be resolved are skipped (and logged) instead of being submitted as a different term. Use the same flag for any other setting that stores a `term_taxonomy_id`. + +**Excluded content is remap-only.** `exclude_term_ids` and `exclude_ids` are never submitted for translation (`Content::isRemapOnly()`), because excluding something must not cause it to be translated. Their IDs are replaced with the translated ones only if the content **is already translated when the translation of the page is applied**. If an excluded term or post is translated later, the page keeps the source ID until the page is downloaded again (e.g. by re-submitting it). Support should expect this when a query still shows an excluded item after a later translation. diff --git a/docs/TARGET_ID_FILTER.md b/docs/TARGET_ID_FILTER.md new file mode 100644 index 000000000..36cc1f1ef --- /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'); + +// return null instead of the original id when there is no translation +$id = apply_filters('smartling_target_id', 13574, 'attachment', null, false); +``` + +| # | Argument | Default | Notes | +|---|----------|---------|-------| +| 1 | `$sourceId` | | id in the source blog; returned as is when the connector is inactive | +| 2 | `$contentType` | `''` | submission content type (`attachment`, `post`, `page`, `category`, ...); empty ignores the type | +| 3 | `$targetBlogId` | current blog | blog to get the id for | +| 4 | `$fallbackToSource` | `true` | return `$sourceId` when no translation is found; `false` returns `null` instead | +| 5 | `$sourceBlogId` | any | blog the content was translated from | + +Returns `int|null`. When no submission matches, its target id is not set yet, or more than one submission matches, `$sourceId` is returned, or `null` if `$fallbackToSource` is `false` (pass `$sourceBlogId` or `$contentType` to disambiguate multiple matches). diff --git a/inc/Smartling/Base/ExportedAPI.php b/inc/Smartling/Base/ExportedAPI.php index 8ec603b84..d2e8de852 100755 --- a/inc/Smartling/Base/ExportedAPI.php +++ b/inc/Smartling/Base/ExportedAPI.php @@ -224,4 +224,20 @@ interface ExportedAPI * @param ConfigurationProfileEntity[] */ public const FILTER_ACTIVE_PROFILES = 'smartling_filter_active_profiles'; + + /** + * Translates an id of the content that has been submitted for translation, so custom code does not have to + * hardcode ids that differ between blogs. + * + * Never throws, any argument type is tolerated. Results are cached for a short time. + * A submission with a target id counts as a translation regardless of its status. + * + * @param mixed $sourceId id of the content in the source blog, returned unchanged when $fallbackToSource is true and nothing is found or the value is not a positive integer + * @param string $contentType submission content type (e.g. attachment, post, page, category), empty to ignore + * @param int|null $targetBlogId blog to get the id for, current blog when null + * @param bool $fallbackToSource return $sourceId when no translation is found (default), otherwise null is returned + * @param int|null $sourceBlogId blog the content was translated from, any blog when null + * @return int|null target id, null when nothing is found and $fallbackToSource is false + */ + public const FILTER_TARGET_ID = 'smartling_target_id'; } 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 new file mode 100644 index 000000000..cf1044956 --- /dev/null +++ b/inc/Smartling/ContentTypes/Elementor/ElementorQueryRelated.php @@ -0,0 +1,94 @@ + 'term_taxonomy_id' in the query). + * suffix => [content type, remap only, manual selection mode, mode setting suffix, expected mode value] + */ + private const QUERY_SETTINGS = [ + 'include_term_ids' => [ContentTypeHelper::CONTENT_TYPE_TAXONOMY, false, false, 'include', 'terms'], + 'exclude_term_ids' => [ContentTypeHelper::CONTENT_TYPE_TAXONOMY, true, false, 'exclude', 'terms'], + 'posts_ids' => [ContentTypeHelper::CONTENT_TYPE_POST, false, true, null, null], + 'exclude_ids' => [ContentTypeHelper::CONTENT_TYPE_POST, true, false, 'exclude', 'manual_selection'], + ]; + + public function addRelated(RelatedContentInfo $info, array $settings, string $containerId, string $prefix): RelatedContentInfo + { + $isManualSelection = ($settings[$prefix . 'post_type'] ?? null) === self::MANUAL_SELECTION; + foreach (self::QUERY_SETTINGS as $suffix => [$contentType, $remapOnly, $manualSelection, $modeSuffix, $mode]) { + if ($manualSelection !== $isManualSelection + || ($mode !== null && !$this->isModeActive($settings[$prefix . $modeSuffix] ?? null, $mode)) + ) { + $this->getLogger()->debug("Skipping {$prefix}{$suffix}, it is not used by the query mode of the widget, containerId=$containerId"); + continue; + } + $this->addIds($info, $settings, $containerId, $prefix . $suffix, $contentType, $remapOnly); + } + + return $info; + } + + private function isModeActive(mixed $modeSetting, string $mode): bool + { + return is_array($modeSetting) ? in_array($mode, $modeSetting, true) : $modeSetting === $mode; + } + + /** + * Related content of loop widgets (loop-grid, loop-carousel): the item template and the query + */ + public function addLoopRelated(RelatedContentInfo $info, array $settings, string $containerId): RelatedContentInfo + { + $key = 'template_id'; + $id = $this->toId($settings[$key] ?? null); + if ($id !== null) { + $info->addContent(new Content($id, ContentTypeHelper::CONTENT_TYPE_POST), $containerId, "settings/$key"); + } + + return $this->addRelated($info, $settings, $containerId, 'post_query_'); + } + + private function addIds(RelatedContentInfo $info, array $settings, string $containerId, string $key, string $contentType, bool $remapOnly): void + { + $ids = $settings[$key] ?? []; + if (!is_array($ids)) { + $this->getLogger()->debug("Ignoring non-array value of setting $key, containerId=$containerId"); + return; + } + foreach ($ids as $index => $id) { + $id = $this->toId($id); + if ($id !== null) { + $info->addContent(new Content($id, $contentType, $remapOnly, $contentType === ContentTypeHelper::CONTENT_TYPE_TAXONOMY), $containerId, "settings/$key/$index"); + } + } + } + + private function toId(mixed $value): ?int + { + $id = filter_var($value, FILTER_VALIDATE_INT); + + return is_int($id) && $id > 0 ? $id : null; + } +} diff --git a/inc/Smartling/ContentTypes/Elementor/Elements/LoopCarousel.php b/inc/Smartling/ContentTypes/Elementor/Elements/LoopCarousel.php index f7691e15c..322ff8907 100644 --- a/inc/Smartling/ContentTypes/Elementor/Elements/LoopCarousel.php +++ b/inc/Smartling/ContentTypes/Elementor/Elements/LoopCarousel.php @@ -2,8 +2,7 @@ namespace Smartling\ContentTypes\Elementor\Elements; -use Smartling\ContentTypes\ContentTypeHelper; -use Smartling\Models\Content; +use Smartling\ContentTypes\Elementor\ElementorQueryRelated; use Smartling\Models\RelatedContentInfo; class LoopCarousel extends Unknown { @@ -14,19 +13,6 @@ public function getType(): string public function getRelated(): RelatedContentInfo { - $return = parent::getRelated(); - $key = "template_id"; - $id = $this->getIntSettingByKey($key, $this->settings); - if ($id !== null) { - $return->addContent(new Content($id, ContentTypeHelper::CONTENT_TYPE_POST), $this->id, "settings/$key"); - } - - foreach ($this->settings['post_query_include_term_ids'] ?? [] as $index => $termId) { - if (is_numeric($termId)) { - $return->addContent(new Content((int)$termId, ContentTypeHelper::CONTENT_TYPE_TAXONOMY), $this->id, "settings/post_query_include_term_ids/$index"); - } - } - - return $return; + return (new ElementorQueryRelated())->addLoopRelated(parent::getRelated(), $this->settings, $this->id); } } diff --git a/inc/Smartling/ContentTypes/Elementor/Elements/LoopGrid.php b/inc/Smartling/ContentTypes/Elementor/Elements/LoopGrid.php new file mode 100644 index 000000000..2bd1d9500 --- /dev/null +++ b/inc/Smartling/ContentTypes/Elementor/Elements/LoopGrid.php @@ -0,0 +1,18 @@ +addLoopRelated(parent::getRelated(), $this->settings, $this->id); + } +} diff --git a/inc/Smartling/ContentTypes/Elementor/Elements/Posts.php b/inc/Smartling/ContentTypes/Elementor/Elements/Posts.php index 5731dc690..b24aaa895 100644 --- a/inc/Smartling/ContentTypes/Elementor/Elements/Posts.php +++ b/inc/Smartling/ContentTypes/Elementor/Elements/Posts.php @@ -3,6 +3,7 @@ namespace Smartling\ContentTypes\Elementor\Elements; use Smartling\ContentTypes\ContentTypeHelper; +use Smartling\ContentTypes\Elementor\ElementorQueryRelated; use Smartling\Models\Content; use Smartling\Models\RelatedContentInfo; @@ -22,14 +23,7 @@ public function getRelated(): RelatedContentInfo $return->addContent(new Content($id, ContentTypeHelper::CONTENT_TYPE_UNKNOWN), $this->id, "settings/$key"); } - $key = 'posts_include_term_ids'; - foreach ($this->settings[$key] ?? [] as $index => $termId) { - if (is_numeric($termId)) { - $return->addContent(new Content((int)$termId, ContentTypeHelper::CONTENT_TYPE_TAXONOMY), $this->id, "settings/$key/$index"); - } - } - - return $return; + return (new ElementorQueryRelated())->addRelated($return, $this->settings, $this->id, 'posts_'); } public function getTranslatableStrings(): array 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/TargetIdFilter.php b/inc/Smartling/Helpers/TargetIdFilter.php new file mode 100644 index 000000000..78f311130 --- /dev/null +++ b/inc/Smartling/Helpers/TargetIdFilter.php @@ -0,0 +1,111 @@ + 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, + ) { + } + + 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, + 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; + } + + 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; + } + + 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; + } + + $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); + + return $targetId; + } + + private function toPositiveInt(mixed $value): ?int + { + $result = filter_var($value, FILTER_VALIDATE_INT); + + return is_int($result) && $result > 0 ? $result : null; + } +} 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 bbe4a89b5..3de6825ac 100644 --- a/inc/Smartling/Models/Content.php +++ b/inc/Smartling/Models/Content.php @@ -6,9 +6,38 @@ class Content { 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 + */ + 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..4c5e8359a 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()] = []; } @@ -58,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/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/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..59df001ee 100644 --- a/inc/config/services.yml +++ b/inc/config/services.yml @@ -603,6 +603,13 @@ services: - "@plugin.info" - "@wp.proxy" + helper.target-id-filter: + class: Smartling\Helpers\TargetIdFilter + arguments: + - "@manager.submission" + - "@wp.proxy" + - "@site.cache" + duplicate.submissions.cleaner: class: Smartling\WP\Controller\DuplicateSubmissionsCleaner arguments: 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/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/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 new file mode 100644 index 000000000..9794a2ed7 --- /dev/null +++ b/tests/Smartling/ContentTypes/Elementor/ElementorQueryRelatedTest.php @@ -0,0 +1,175 @@ +assertEquals('loop-grid', (new LoopGrid())->getType()); + } + + public function testLoopWidgetsCollectQueryRelatedContent(): void + { + foreach ([LoopGrid::class, LoopCarousel::class] as $class) { + $byTerms = (new $class(['id' => 'w1', 'settings' => [ + 'template_id' => '7', + 'post_query_post_type' => 'product', + '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'], + 'post_query_exclude_ids' => ['101'], + ]]))->getRelated()->getRelatedContentList(); + + $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 + { + $byTerms = (new Posts(['id' => 'w1', 'settings' => [ + 'posts_post_type' => 'post', + 'posts_include' => ['terms'], + 'posts_exclude' => ['terms'], + 'posts_include_term_ids' => ['3'], + 'posts_exclude_term_ids' => ['4'], + 'posts_posts_ids' => ['50'], + ]]))->getRelated()->getRelatedContentList(); + + $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 + { + $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(); + + $this->assertArrayNotHasKey(ContentTypeHelper::CONTENT_TYPE_TAXONOMY, $list); + } + + public function testExcludedContentIsRemapOnly(): void + { + $info = (new LoopGrid(['id' => 'w1', 'settings' => [ + 'template_id' => '7', + '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_exclude_ids' => ['101'], + ]]))->getRelated(); + + $own = $info->getOwnRelatedContent('w1'); + $this->assertCount(4, $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' => ['terms', 'manual_selection'], + '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_post_type' => 'by_id', + '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); + } + } + } + + 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' => 'post', + '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->assertEquals(['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(); 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/Helpers/TargetIdFilterTest.php b/tests/Smartling/Helpers/TargetIdFilterTest.php new file mode 100644 index 000000000..4f8f91c2c --- /dev/null +++ b/tests/Smartling/Helpers/TargetIdFilterTest.php @@ -0,0 +1,209 @@ +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->exactly($calls))->method('find'); + if ($expectedParameters !== null) { + $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, $cache ?? $this->makeCache()); + } + + private function makeSubmission(int $targetId): SubmissionEntity + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getTargetId')->willReturn($targetId); + + 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, $this->makeCache()))->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 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([])->getTargetId(10, 'post', null, false)); + } + + public function testNoTranslationFallsBackToSourceIdByDefault(): void + { + $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')); + } + + public function testInvalidSourceIdIsReturnedUnchangedWithFallback(): void + { + $filter = $this->makeUnusedFilter(); + + $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')); + } +} 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()); + } }