From b0f9969dd6633956b446288daa3c0ce69dca82e6 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Tue, 29 Sep 2026 14:56:52 +0200 Subject: [PATCH 1/5] add widget scoping, conditions, preview and import/export to Visual Configurator (WP-1018) Rules can be limited to an Elementor widgetType and to text properties of enclosing objects, and use recursive $..key paths so custom widgets nested at any depth can be ingested (e.g. sovos accordions, skipping stale placeholder data). Extended rules are matched in PHP by JsonLeafMatcher because JSONPath filters with recursive descent are unreliable in the bundled library. The rule editor shows a live preview of matches. Rules can be exported to and imported from JSON; import only adds rules and never changes existing ones. Co-Authored-By: Claude Sonnet 5.5 --- .../ContentTypes/ExternalContentJsonRules.php | 66 ++++- inc/Smartling/Tuner/JsonFieldRule.php | 94 ++++++- inc/Smartling/Tuner/JsonFieldRulesManager.php | 43 ++++ inc/Smartling/Tuner/JsonLeafMatcher.php | 117 +++++++++ .../WP/Controller/VisualConfiguratorPage.php | 146 ++++++++++- js/visual-configurator.js | 240 +++++++++++++++++- .../ExternalContentJsonRulesTest.php | 72 ++++++ tests/Smartling/Tuner/JsonFieldRuleTest.php | 40 +++ .../Tuner/JsonFieldRulesManagerTest.php | 47 ++++ tests/Smartling/Tuner/JsonLeafMatcherTest.php | 123 +++++++++ .../Controller/VisualConfiguratorPageTest.php | 186 ++++++++++++++ 11 files changed, 1154 insertions(+), 20 deletions(-) create mode 100644 inc/Smartling/Tuner/JsonLeafMatcher.php create mode 100644 tests/Smartling/Tuner/JsonLeafMatcherTest.php diff --git a/inc/Smartling/ContentTypes/ExternalContentJsonRules.php b/inc/Smartling/ContentTypes/ExternalContentJsonRules.php index 05fafedf3..0435fc78c 100644 --- a/inc/Smartling/ContentTypes/ExternalContentJsonRules.php +++ b/inc/Smartling/ContentTypes/ExternalContentJsonRules.php @@ -9,6 +9,7 @@ use Smartling\Submissions\SubmissionEntity; use Smartling\Tuner\JsonFieldRule; use Smartling\Tuner\JsonFieldRulesManager; +use Smartling\Tuner\JsonLeafMatcher; use Smartling\Vendor\JsonPath\JsonObject; class ExternalContentJsonRules implements ContentTypeModifyingInterface @@ -17,11 +18,15 @@ class ExternalContentJsonRules implements ContentTypeModifyingInterface public const PLUGIN_ID = 'json-rules'; + private JsonLeafMatcher $matcher; + public function __construct( private JsonFieldRulesManager $rulesManager, private ReplacerFactory $replacerFactory, private WordpressFunctionProxyHelper $wpProxy, + ?JsonLeafMatcher $matcher = null, ) { + $this->matcher = $matcher ?? new JsonLeafMatcher(); } public function getMaxVersion(): string @@ -75,6 +80,14 @@ public function getContentFields(SubmissionEntity $submission, bool $raw): array if ($this->parseReplacer($rule->getReplacerId())[0] !== ReplacerFactory::REPLACER_TRANSLATE) { continue; } + if ($rule->isExtended()) { + foreach ($this->matcher->match($json, $rule) as $leaf) { + if (is_string($leaf['value']) && $leaf['value'] !== '') { + $result[$this->buildLeafKey($metaKey, $leaf['segments'])] = $leaf['value']; + } + } + continue; + } $matches = $this->safeGet($json, $rule->getPropertyPath()); foreach ($matches as $index => $value) { if (is_string($value) && $value !== '') { @@ -86,6 +99,16 @@ public function getContentFields(SubmissionEntity $submission, bool $raw): array return $result; } + /** + * @return array values matched by the rule in document order, used for previews + */ + public function getMatchedValues(array $json, JsonFieldRule $rule): array + { + return $rule->isExtended() + ? array_column($this->matcher->match($json, $rule), 'value') + : array_values($this->safeGet($json, $rule->getPropertyPath())); + } + public function getRelatedContent(string $contentType, int $contentId): array { $result = []; @@ -101,7 +124,10 @@ public function getRelatedContent(string $contentType, int $contentId): array continue; } $referencedType = $hint !== '' ? $hint : ContentTypeHelper::CONTENT_TYPE_UNKNOWN; - foreach ($this->safeGet($json, $rule->getPropertyPath()) as $value) { + $values = $rule->isExtended() + ? array_column($this->matcher->match($json, $rule), 'value') + : $this->safeGet($json, $rule->getPropertyPath()); + foreach ($values as $value) { if (is_numeric($value) && (int)$value > 0) { $result[$referencedType][] = (int)$value; } @@ -205,6 +231,19 @@ private function safeGet(array $json, string $path): array private function applyTranslateRule(JsonObject $jsonObject, JsonFieldRule $rule, string $metaKey, array $translations): bool { + if ($rule->isExtended()) { + $data = &$jsonObject->getValue(); + $changed = false; + foreach ($this->matcher->match($data, $rule) as $leaf) { + $key = $this->buildLeafKey($metaKey, $leaf['segments']); + if (array_key_exists($key, $translations)) { + $changed = $this->matcher->setValue($data, $leaf['segments'], $translations[$key]) || $changed; + } + } + unset($data); + + return $changed; + } $objects = $jsonObject->getJsonObjects($rule->getPropertyPath()); if ($objects === false || $objects === null) { return false; @@ -233,6 +272,23 @@ private function applyRelatedRule(JsonObject $jsonObject, JsonFieldRule $rule, S $this->getLogger()->notice("Unable to resolve replacer {$rule->getReplacerId()}: " . $e->getMessage()); return false; } + if ($rule->isExtended()) { + $data = &$jsonObject->getValue(); + $changed = false; + foreach ($this->matcher->match($data, $rule) as $leaf) { + $original = $leaf['value']; + if (!is_numeric($original) || (int)$original <= 0) { + continue; + } + $replaced = $replacer->processAttributeOnDownload($original, $original, $submission); + if ($replaced !== $original) { + $changed = $this->matcher->setValue($data, $leaf['segments'], $replaced) || $changed; + } + } + unset($data); + + return $changed; + } $objects = $jsonObject->getJsonObjects($rule->getPropertyPath()); if ($objects === false || $objects === null) { return false; @@ -267,6 +323,14 @@ private function parseReplacer(string $replacerId): array return [$parts[0], $parts[1] ?? '']; } + /** + * @param array $segments + */ + private function buildLeafKey(string $metaKey, array $segments): string + { + return $metaKey . '|' . $this->matcher->pathToString($segments); + } + private function buildKey(string $metaKey, string $path, int $index): string { return $metaKey . '|' . $path . '|' . $index; diff --git a/inc/Smartling/Tuner/JsonFieldRule.php b/inc/Smartling/Tuner/JsonFieldRule.php index 24c4aaf57..e53a8196a 100644 --- a/inc/Smartling/Tuner/JsonFieldRule.php +++ b/inc/Smartling/Tuner/JsonFieldRule.php @@ -4,11 +4,30 @@ final class JsonFieldRule { + public const MAX_CONDITIONS = 5; + public const MAX_STRING_LENGTH = 256; + private const EXTENDED_PATH_PATTERN = '~^\$\.\.[A-Za-z_][\w-]*(\.[A-Za-z_][\w-]*)*$~'; + private const WIDGET_TYPE_PATTERN = '~^[A-Za-z0-9_-]{1,64}$~'; + + /** + * @param array $conditions + */ public function __construct( private string $metaKey, private string $propertyPath, private string $replacerId, + private string $widgetType = '', + private array $conditions = [], ) { + if ($this->widgetType !== '' && preg_match(self::WIDGET_TYPE_PATTERN, $this->widgetType) !== 1) { + throw new \InvalidArgumentException('Invalid widgetType'); + } + $this->conditions = self::normalizeConditions($this->conditions); + if ($this->isExtended() && preg_match(self::EXTENDED_PATH_PATTERN, $this->propertyPath) !== 1) { + throw new \InvalidArgumentException( + 'Rules with a widget or conditions must use a path in the form $..key.subkey', + ); + } } public function getMetaKey(): string @@ -26,13 +45,50 @@ public function getReplacerId(): string return $this->replacerId; } + public function getWidgetType(): string + { + return $this->widgetType; + } + + /** + * @return array + */ + public function getConditions(): array + { + return $this->conditions; + } + + /** + * Extended rules are evaluated by JsonLeafMatcher, the rest by JSONPath + */ + public function isExtended(): bool + { + return $this->widgetType !== '' || $this->conditions !== []; + } + + /** + * @return string[] object keys the path ends with, e.g. $..a.b => [a, b] + */ + public function getKeySuffix(): array + { + return explode('.', substr($this->propertyPath, 3)); + } + public function toArray(): array { - return [ + $result = [ 'metaKey' => $this->metaKey, 'propertyPath' => $this->propertyPath, 'replacerId' => $this->replacerId, ]; + if ($this->widgetType !== '') { + $result['widgetType'] = $this->widgetType; + } + if ($this->conditions !== []) { + $result['conditions'] = $this->conditions; + } + + return $result; } public static function fromArray(array $data): self @@ -47,6 +103,42 @@ public static function fromArray(array $data): self (string)$data['metaKey'], (string)$data['propertyPath'], (string)$data['replacerId'], + (string)($data['widgetType'] ?? ''), + is_array($data['conditions'] ?? null) ? $data['conditions'] : [], ); } + + /** + * @return array + */ + private static function normalizeConditions(array $conditions): array + { + if (count($conditions) > self::MAX_CONDITIONS) { + throw new \InvalidArgumentException('Too many conditions, maximum is ' . self::MAX_CONDITIONS); + } + $result = []; + foreach ($conditions as $condition) { + if (!is_array($condition) + || !isset($condition['ancestor'], $condition['key'], $condition['value']) + || !is_numeric($condition['ancestor']) + || !is_string($condition['key']) + || !is_string($condition['value']) + ) { + throw new \InvalidArgumentException('Invalid condition, expected ancestor, key and value'); + } + $ancestor = (int)$condition['ancestor']; + if ($ancestor < 0 || $ancestor > 20) { + throw new \InvalidArgumentException('Condition ancestor must be between 0 and 20'); + } + if ($condition['key'] === '' + || strlen($condition['key']) > self::MAX_STRING_LENGTH + || strlen($condition['value']) > self::MAX_STRING_LENGTH + ) { + throw new \InvalidArgumentException('Condition key or value is empty or too long'); + } + $result[] = ['ancestor' => $ancestor, 'key' => $condition['key'], 'value' => $condition['value']]; + } + + return $result; + } } diff --git a/inc/Smartling/Tuner/JsonFieldRulesManager.php b/inc/Smartling/Tuner/JsonFieldRulesManager.php index 155a3199e..b7064c178 100644 --- a/inc/Smartling/Tuner/JsonFieldRulesManager.php +++ b/inc/Smartling/Tuner/JsonFieldRulesManager.php @@ -9,6 +9,7 @@ class JsonFieldRulesManager extends CustomizationManagerAbstract use LoggerSafeTrait; public const STORAGE_KEY = 'CUSTOM_JSON_FIELD_RULES'; + public const EXPORT_FORMAT_VERSION = 1; public function __construct() { @@ -43,4 +44,46 @@ public function listItems(): array } return $result; } + + /** + * Portable representation: rule ids are internal and intentionally left out + * + * @return array{version: int, rules: array} + */ + public function export(): array + { + return [ + 'version' => self::EXPORT_FORMAT_VERSION, + 'rules' => array_values(array_map( + static fn(JsonFieldRule $rule): array => $rule->toArray(), + $this->listItems(), + )), + ]; + } + + /** + * Additive only: existing rules are never modified or removed, rules identical to an existing one are skipped. + * Does not persist, call saveData() afterwards. + * + * @param JsonFieldRule[] $rules + * @return array{added: int, skipped: int} + */ + public function import(array $rules): array + { + $added = 0; + $skipped = 0; + $known = array_map(static fn(JsonFieldRule $rule): array => $rule->toArray(), array_values($this->listItems())); + foreach ($rules as $rule) { + $data = $rule->toArray(); + /** @noinspection TypeUnsafeArraySearchInspection comparing arrays, key order irrelevant */ + if (in_array($data, $known) || $this->add($data) === '') { + $skipped++; + continue; + } + $known[] = $data; + $added++; + } + + return ['added' => $added, 'skipped' => $skipped]; + } } diff --git a/inc/Smartling/Tuner/JsonLeafMatcher.php b/inc/Smartling/Tuner/JsonLeafMatcher.php new file mode 100644 index 000000000..57910fae9 --- /dev/null +++ b/inc/Smartling/Tuner/JsonLeafMatcher.php @@ -0,0 +1,117 @@ +, value: mixed}> + */ + public function match(array $json, JsonFieldRule $rule): array + { + $result = []; + $this->walk($json, $rule, [], [], $result); + + return $result; + } + + /** + * @param array $segments + */ + public function setValue(array &$json, array $segments, mixed $value): bool + { + $node = &$json; + foreach ($segments as $segment) { + if (!is_array($node) || !array_key_exists($segment, $node)) { + return false; + } + $node = &$node[$segment]; + } + $node = $value; + + return true; + } + + /** + * @param array $segments + */ + public function pathToString(array $segments): string + { + $path = '$'; + foreach ($segments as $segment) { + if (is_int($segment)) { + $path .= "[$segment]"; + } elseif (preg_match('~^[A-Za-z_]\w*$~', $segment) === 1) { + $path .= ".$segment"; + } else { + $path .= "['" . str_replace(['\\', "'"], ['\\\\', "\\'"], $segment) . "']"; + } + } + + return $path; + } + + /** + * @param array $segments + * @param array $objects object ancestors, outermost first + */ + private function walk(mixed $node, JsonFieldRule $rule, array $segments, array $objects, array &$result): void + { + if (!is_array($node)) { + if ($segments !== [] && $this->leafMatches($segments, $objects, $rule)) { + $result[] = ['segments' => $segments, 'value' => $node]; + } + return; + } + $isObject = !array_is_list($node); + if ($isObject) { + $objects[] = $node; + } + foreach ($node as $key => $child) { + $this->walk($child, $rule, [...$segments, $key], $objects, $result); + } + } + + /** + * @param array $segments + * @param array $objects + */ + private function leafMatches(array $segments, array $objects, JsonFieldRule $rule): bool + { + $suffix = $rule->getKeySuffix(); + $keys = array_values(array_filter($segments, 'is_string')); + if (array_slice($keys, -count($suffix)) !== $suffix) { + return false; + } + if ($rule->getWidgetType() !== '' && !$this->isInsideWidget($objects, $rule->getWidgetType())) { + return false; + } + foreach ($rule->getConditions() as $condition) { + $index = count($objects) - 1 - $condition['ancestor']; + $value = $index >= 0 ? ($objects[$index][$condition['key']] ?? null) : null; + if (!is_string($value) || $value !== $condition['value']) { + return false; + } + } + + return true; + } + + /** + * @param array $objects + */ + private function isInsideWidget(array $objects, string $widgetType): bool + { + foreach ($objects as $object) { + if (($object['elType'] ?? null) === 'widget' && ($object['widgetType'] ?? null) === $widgetType) { + return true; + } + } + + return false; + } +} diff --git a/inc/Smartling/WP/Controller/VisualConfiguratorPage.php b/inc/Smartling/WP/Controller/VisualConfiguratorPage.php index 70bf5f5c2..f2f0a669a 100644 --- a/inc/Smartling/WP/Controller/VisualConfiguratorPage.php +++ b/inc/Smartling/WP/Controller/VisualConfiguratorPage.php @@ -2,6 +2,7 @@ namespace Smartling\WP\Controller; +use Smartling\ContentTypes\ExternalContentJsonRules; use Smartling\Helpers\LoggerSafeTrait; use Smartling\Helpers\PluginInfo; use Smartling\Helpers\SmartlingUserCapabilities; @@ -21,13 +22,24 @@ class VisualConfiguratorPage extends ControllerAbstract implements WPHookInterfa public const ACTION_SAVE_RULE = 'smartling_visual_configurator_save_rule'; public const ACTION_DELETE_RULE = 'smartling_visual_configurator_delete_rule'; public const ACTION_RESOLVE_TYPE = 'smartling_visual_configurator_resolve_type'; + public const ACTION_PREVIEW = 'smartling_visual_configurator_preview'; + public const ACTION_EXPORT = 'smartling_visual_configurator_export'; + public const ACTION_IMPORT = 'smartling_visual_configurator_import'; + private const MAX_IMPORT_BYTES = 1048576; + private const MAX_IMPORT_RULES = 500; + private const PREVIEW_LIMIT = 15; + private const PREVIEW_VALUE_LENGTH = 120; + + private ExternalContentJsonRules $evaluator; public function __construct( private JsonFieldRulesManager $rulesManager, private ReplacerFactory $replacerFactory, private PluginInfo $pluginInfo, private WordpressFunctionProxyHelper $wpProxy, + ?ExternalContentJsonRules $evaluator = null, ) { + $this->evaluator = $evaluator ?? new ExternalContentJsonRules($rulesManager, $replacerFactory, $wpProxy); } public function register(): void @@ -39,6 +51,9 @@ public function register(): void $this->wpProxy->add_action('wp_ajax_' . self::ACTION_SAVE_RULE, [$this, 'ajaxSaveRule']); $this->wpProxy->add_action('wp_ajax_' . self::ACTION_DELETE_RULE, [$this, 'ajaxDeleteRule']); $this->wpProxy->add_action('wp_ajax_' . self::ACTION_RESOLVE_TYPE, [$this, 'ajaxResolveType']); + $this->wpProxy->add_action('wp_ajax_' . self::ACTION_PREVIEW, [$this, 'ajaxPreview']); + $this->wpProxy->add_action('wp_ajax_' . self::ACTION_EXPORT, [$this, 'ajaxExport']); + $this->wpProxy->add_action('wp_ajax_' . self::ACTION_IMPORT, [$this, 'ajaxImport']); } public function menu(): void @@ -83,6 +98,9 @@ public function enqueue(string $hook): void 'save' => self::ACTION_SAVE_RULE, 'delete' => self::ACTION_DELETE_RULE, 'resolveType' => self::ACTION_RESOLVE_TYPE, + 'preview' => self::ACTION_PREVIEW, + 'export' => self::ACTION_EXPORT, + 'import' => self::ACTION_IMPORT, ], ]); wp_enqueue_style('wp-components'); @@ -107,18 +125,12 @@ public function ajaxSaveRule(): void return; } try { - $payload = $this->readRulePayload(); + $data = $this->readRule()->toArray(); } catch (\InvalidArgumentException $e) { $this->wpProxy->wp_send_json_error(['message' => $e->getMessage()], 400); return; } - $data = (new JsonFieldRule( - $payload['metaKey'], - $payload['propertyPath'], - $payload['replacerId'], - ))->toArray(); - $id = isset($_POST['id']) && is_string($_POST['id']) ? $this->wpProxy->sanitize_text_field($this->wpProxy->wp_unslash($_POST['id'])) : ''; @@ -196,9 +208,9 @@ private function verifyNonceAndCapabilities(): bool } /** - * @return array{metaKey:string,propertyPath:string,replacerId:string} + * @throws \InvalidArgumentException */ - private function readRulePayload(): array + private function readRule(): JsonFieldRule { $get = function (string $key): string { if (!isset($_POST[$key]) || !is_string($_POST[$key])) { @@ -219,6 +231,120 @@ private function readRulePayload(): array if (strlen($payload['propertyPath']) > 512) { throw new \InvalidArgumentException('propertyPath exceeds maximum length of 512 characters'); } - return $payload; + $widgetType = isset($_POST['widgetType']) && is_string($_POST['widgetType']) + ? $this->wpProxy->sanitize_text_field($this->wpProxy->wp_unslash($_POST['widgetType'])) + : ''; + $conditions = []; + if (isset($_POST['conditions']) && is_string($_POST['conditions']) && $_POST['conditions'] !== '') { + $decoded = json_decode($this->wpProxy->wp_unslash($_POST['conditions']), true); + if (!is_array($decoded)) { + throw new \InvalidArgumentException('conditions must be a JSON list'); + } + $conditions = $decoded; + } + + return $this->buildRule($payload + ['widgetType' => $widgetType, 'conditions' => $conditions]); + } + + /** + * Validates everything about a rule, including that the replacer exists + * + * @throws \InvalidArgumentException + */ + private function buildRule(array $data): JsonFieldRule + { + $rule = JsonFieldRule::fromArray($data); + try { + $this->replacerFactory->getReplacer($rule->getReplacerId()); + } catch (\Throwable) { + throw new \InvalidArgumentException("Unknown rule type: {$rule->getReplacerId()}"); + } + + return $rule; + } + + public function ajaxPreview(): void + { + if (!$this->verifyNonceAndCapabilities()) { + return; + } + $id = isset($_POST['id']) ? (int)$_POST['id'] : 0; + if ($id <= 0) { + $this->wpProxy->wp_send_json_error(['message' => 'Missing or invalid id'], 400); + return; + } + try { + $rule = $this->readRule(); + } catch (\InvalidArgumentException $e) { + $this->wpProxy->wp_send_json_error(['message' => $e->getMessage()], 400); + return; + } + $value = $this->wpProxy->getPostMeta($id, $rule->getMetaKey(), true); + $json = is_string($value) ? json_decode($value, true) : null; + if (!is_array($json)) { + $this->wpProxy->wp_send_json_error(['message' => 'Meta field is missing or is not JSON'], 404); + return; + } + $matches = $this->evaluator->getMatchedValues($json, $rule); + $values = []; + foreach (array_slice($matches, 0, self::PREVIEW_LIMIT) as $match) { + $string = is_scalar($match) ? (string)$match : ''; + $values[] = mb_strlen($string) > self::PREVIEW_VALUE_LENGTH + ? mb_substr($string, 0, self::PREVIEW_VALUE_LENGTH) . '…' + : $string; + } + $this->wpProxy->wp_send_json_success(['count' => count($matches), 'values' => $values]); + } + + public function ajaxExport(): void + { + if (!$this->verifyNonceAndCapabilities()) { + return; + } + $this->rulesManager->loadData(); + $this->wpProxy->wp_send_json_success(['export' => $this->rulesManager->export()]); + } + + public function ajaxImport(): void + { + if (!$this->verifyNonceAndCapabilities()) { + return; + } + $raw = isset($_POST['payload']) && is_string($_POST['payload']) ? $this->wpProxy->wp_unslash($_POST['payload']) : ''; + if ($raw === '' || strlen($raw) > self::MAX_IMPORT_BYTES) { + $this->wpProxy->wp_send_json_error(['message' => 'Import file is missing or too large'], 400); + return; + } + $payload = json_decode($raw, true); + if (!is_array($payload) || !is_array($payload['rules'] ?? null)) { + $this->wpProxy->wp_send_json_error(['message' => 'Not a valid rules export file'], 400); + return; + } + if ((int)($payload['version'] ?? 0) > JsonFieldRulesManager::EXPORT_FORMAT_VERSION) { + $this->wpProxy->wp_send_json_error(['message' => 'Export file was created by a newer version of the connector'], 400); + return; + } + if (count($payload['rules']) > self::MAX_IMPORT_RULES) { + $this->wpProxy->wp_send_json_error(['message' => 'Too many rules, maximum is ' . self::MAX_IMPORT_RULES], 400); + return; + } + $rules = []; + $invalid = []; + foreach (array_values($payload['rules']) as $index => $data) { + try { + if (!is_array($data)) { + throw new \InvalidArgumentException('Rule must be an object'); + } + $rules[] = $this->buildRule($data); + } catch (\InvalidArgumentException $e) { + $invalid[] = ['index' => $index + 1, 'message' => $e->getMessage()]; + } + } + $this->rulesManager->loadData(); + $result = $this->rulesManager->import($rules); + $this->rulesManager->saveData(); + $this->getLogger()->info(sprintf('Imported JSON field rules: added=%d, skipped=%d, invalid=%d', $result['added'], $result['skipped'], count($invalid))); + + $this->wpProxy->wp_send_json_success($result + ['invalid' => $invalid]); } } diff --git a/js/visual-configurator.js b/js/visual-configurator.js index ca3de47cb..32d0b51c4 100644 --- a/js/visual-configurator.js +++ b/js/visual-configurator.js @@ -1,10 +1,11 @@ /* global wp, jQuery, smartlingVisualConfigurator */ (function () { - const { render, createElement: el, useState, useEffect, useCallback, Fragment } = wp.element; + const { render, createElement: el, useState, useEffect, useCallback, useRef, Fragment } = wp.element; const { Button, Card, CardBody, + CheckboxControl, CardHeader, Modal, Notice, @@ -74,7 +75,38 @@ return String(value); } - function JsonNode({ value, path, metaKey, onAddRule, rulesByPath, depth = 0 }) { + const KEY_PATTERN = /^[A-Za-z_][\w-]*$/; + const PREVIEW_DEBOUNCE_MS = 400; + + // String properties of an object that can be used to tell otherwise identical structures apart + function scalarProps(object) { + const props = {}; + Object.entries(object).forEach(([k, v]) => { + if (typeof v === 'string' && v !== '' && v.length <= 64 && k !== 'id' && k !== '_id') { + props[k] = v; + } + }); + return props; + } + + function findWidgetType(ancestors) { + for (let i = ancestors.length - 1; i >= 0; i--) { + if (ancestors[i].elType === 'widget' && ancestors[i].widgetType) { + return ancestors[i].widgetType; + } + } + return ''; + } + + function describeCondition(c) { + return `${c.key} = "${c.value}" (${c.ancestor === 0 ? 'same object' : `${c.ancestor} levels up`})`; + } + + function post(action, data) { + return jQuery.post(settings.ajaxUrl, { action, _wpnonce: settings.nonce, ...data }); + } + + function JsonNode({ value, path, metaKey, onAddRule, rulesByPath, depth = 0, ancestors = [], keys = [] }) { const [expanded, setExpanded] = useState(true); if (valueIsLeaf(value)) { const existing = rulesByPath[`${metaKey}|${path}`]; @@ -98,7 +130,7 @@ Button, { variant: 'link', - onClick: () => onAddRule({ path, metaKey, value, isString, isNumeric }), + onClick: () => onAddRule({ path, metaKey, value, isString, isNumeric, keys, ancestors }), }, 'Add rule', ), @@ -107,6 +139,7 @@ const entries = Array.isArray(value) ? value.map((v, i) => [i, v]) : Object.entries(value); + const childAncestors = Array.isArray(value) ? ancestors : [...ancestors, { ...scalarProps(value), elType: value.elType, widgetType: value.widgetType }]; return el( 'div', { style: { paddingLeft: depth === 0 ? 0 : 16 } }, @@ -140,6 +173,8 @@ onAddRule, rulesByPath, depth: depth + 1, + ancestors: childAncestors, + keys: Array.isArray(value) ? keys : [...keys, String(k)], }), ), ), @@ -198,18 +233,81 @@ ); } - function RuleEditor({ draft, onCancel, onSave }) { + function RuleEditor({ draft, contentId, onCancel, onSave }) { const [replacerId, setReplacerId] = useState('copy'); const [refType, setRefType] = useState('attachment'); - if (!draft) return null; + const [mode, setMode] = useState('position'); + const [keyCount, setKeyCount] = useState(1); + const [limitToWidget, setLimitToWidget] = useState(false); + const [selected, setSelected] = useState({}); + const [preview, setPreview] = useState(null); + + const keys = draft ? draft.keys || [] : []; + const ancestors = draft ? draft.ancestors || [] : []; + const widgetType = findWidgetType(ancestors); + const canGeneralize = draft && draft.path !== '' && keys.length > 0 && keys.every((k) => KEY_PATTERN.test(k)); + + // One option per string property of every enclosing object, nearest object first + const conditionOptions = []; + for (let distance = 0; distance < ancestors.length; distance++) { + const object = ancestors[ancestors.length - 1 - distance]; + Object.entries(object).forEach(([key, val]) => { + if (typeof val === 'string' && key !== 'elType' && key !== 'widgetType') { + conditionOptions.push({ id: `${distance}|${key}`, ancestor: distance, key, value: val }); + } + }); + } + const conditions = conditionOptions.filter((o) => selected[o.id]).map(({ ancestor, key, value }) => ({ ancestor, key, value })); + const extended = mode === 'anywhere'; + const propertyPath = !draft + ? '' + : extended + ? `$..${keys.slice(-keyCount).join('.')}` + : draft.path; + const activeWidget = extended && limitToWidget ? widgetType : ''; + const activeConditions = extended ? conditions : []; const composedReplacerId = replacerId === 'related' ? `related|${refType}` : replacerId; + const previewKey = JSON.stringify([propertyPath, activeWidget, activeConditions, composedReplacerId]); + + useEffect(() => { + if (!draft || draft.path === '' || !replacerId) { + setPreview(null); + return undefined; + } + let cancelled = false; + const timer = setTimeout(async () => { + try { + const response = await post(settings.actions.preview, { + id: contentId, + metaKey: draft.metaKey, + propertyPath, + replacerId: composedReplacerId, + widgetType: activeWidget, + conditions: JSON.stringify(activeConditions), + }); + if (!cancelled) { + setPreview(response && response.success + ? response.data + : { error: (response && response.data && response.data.message) || 'Preview failed' }); + } + } catch (e) { + if (!cancelled) setPreview({ error: 'Preview failed' }); + } + }, PREVIEW_DEBOUNCE_MS); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [previewKey, draft && draft.metaKey, contentId]); + + if (!draft) return null; return el( Modal, { title: 'Add rule', onRequestClose: onCancel, shouldCloseOnClickOutside: false, - style: { maxWidth: 520 }, + style: { maxWidth: 620 }, }, el('p', null, 'Target: ', @@ -225,14 +323,70 @@ onChange: setRefType, }) : null, + canGeneralize + ? el(SelectControl, { + label: 'Match', + value: mode, + options: [ + { label: 'This position only (array indices become wildcards)', value: 'position' }, + { label: 'Anywhere in the content with this key path', value: 'anywhere' }, + ], + onChange: setMode, + help: extended ? 'Also matches widgets nested at a different depth.' : undefined, + }) + : null, + extended && keys.length > 1 + ? el(SelectControl, { + label: 'Keys in path', + value: String(keyCount), + options: keys.map((k, idx) => ({ + label: keys.slice(-(idx + 1)).join('.'), + value: String(idx + 1), + })), + onChange: (v) => setKeyCount(parseInt(v, 10)), + help: 'Use more keys when the last key alone is too generic.', + }) + : null, + extended && widgetType + ? el(CheckboxControl, { + label: `Only inside "${widgetType}" widgets`, + checked: limitToWidget, + onChange: setLimitToWidget, + }) + : null, + extended && conditionOptions.length > 0 + ? el('div', null, + el('strong', null, 'Only when'), + conditionOptions.map((o) => + el(CheckboxControl, { + key: o.id, + label: describeCondition(o), + checked: !!selected[o.id], + onChange: (checked) => setSelected({ ...selected, [o.id]: checked }), + }), + ), + ) + : null, + preview && preview.error ? el(Notice, { status: 'warning', isDismissible: false }, preview.error) : null, + preview && !preview.error + ? el('div', { style: { background: '#f6f7f7', padding: 8, maxHeight: 180, overflow: 'auto' } }, + el('strong', null, `${preview.count} match${preview.count === 1 ? '' : 'es'} in this content`), + el('ul', { style: { margin: '4px 0 0 16px', listStyle: 'disc' } }, + preview.values.map((v, idx) => el('li', { key: idx }, el('code', null, v))), + ), + preview.count > preview.values.length ? el('em', null, `…and ${preview.count - preview.values.length} more`) : null, + ) + : null, el('div', null, el(Button, { variant: 'primary', disabled: !replacerId, onClick: () => onSave({ metaKey: draft.metaKey, - propertyPath: draft.path, + propertyPath, replacerId: composedReplacerId, + widgetType: activeWidget, + conditions: JSON.stringify(activeConditions), }), }, 'Save rule'), ' ', @@ -250,6 +404,8 @@ const [rules, setRules] = useState([]); const [draft, setDraft] = useState(null); const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [importResult, setImportResult] = useState(null); + const fileInput = useRef(null); const refreshRules = useCallback(async () => { try { @@ -339,6 +495,47 @@ } }, [deleteConfirmId, refreshRules]); + const handleExport = useCallback(async () => { + try { + const response = await post(settings.actions.export, {}); + if (!response || !response.success) { + setError((response && response.data && response.data.message) || 'Export failed'); + return; + } + const blob = new Blob([JSON.stringify(response.data.export, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'smartling-visual-configurator-rules.json'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (e) { + setError('Export failed: ' + (e.message || 'unknown')); + } + }, []); + + const handleImportFile = useCallback(async (event) => { + const file = event.target.files && event.target.files[0]; + event.target.value = ''; + if (!file) return; + setError(''); + setImportResult(null); + try { + const payload = await file.text(); + const response = await post(settings.actions.import, { payload }); + if (!response || !response.success) { + setError((response && response.data && response.data.message) || 'Import failed'); + return; + } + setImportResult(response.data); + await refreshRules(); + } catch (e) { + setError('Import failed: ' + (e.message || 'unknown')); + } + }, [refreshRules]); + const rulesByPath = {}; rules.forEach((r) => { const key = r.propertyPath ? `${r.metaKey}|${r.propertyPath}` : r.metaKey; @@ -378,6 +575,7 @@ ), el(RuleEditor, { draft, + contentId, onCancel: () => setDraft(null), onSave: handleSaveRule, }), @@ -393,8 +591,28 @@ el(Button, { variant: 'secondary', onClick: () => setDeleteConfirmId(null) }, 'Cancel'), ), ), + importResult && el(Notice, { status: importResult.invalid.length > 0 ? 'warning' : 'success', onRemove: () => setImportResult(null) }, + `Import finished: ${importResult.added} added, ${importResult.skipped} skipped (already exist), ${importResult.invalid.length} invalid. Existing rules were not changed.`, + importResult.invalid.length > 0 && el('ul', { style: { margin: '4px 0 0 16px', listStyle: 'disc' } }, + importResult.invalid.map((i) => el('li', { key: i.index }, `Rule #${i.index}: ${i.message}`)), + ), + ), el(Card, null, - el(CardHeader, null, `Saved rules (${rules.length})`), + el(CardHeader, null, + el('span', null, `Saved rules (${rules.length})`), + el('span', null, + el(Button, { variant: 'secondary', onClick: handleExport, disabled: rules.length === 0 }, 'Export'), + ' ', + el(Button, { variant: 'secondary', onClick: () => fileInput.current && fileInput.current.click() }, 'Import'), + el('input', { + ref: fileInput, + type: 'file', + accept: '.json,application/json', + style: { display: 'none' }, + onChange: handleImportFile, + }), + ), + ), el(CardBody, null, rules.length === 0 ? el('em', null, 'No rules yet.') @@ -403,6 +621,8 @@ el('tr', null, el('th', null, 'Meta key'), el('th', null, 'Path'), + el('th', null, 'Widget'), + el('th', null, 'Only when'), el('th', null, 'Rule'), el('th', null, ''), ), @@ -412,6 +632,10 @@ el('tr', { key: r.id }, el('td', null, el('code', null, r.metaKey)), el('td', null, el('code', null, r.propertyPath || '(whole field)')), + el('td', null, r.widgetType ? el('code', null, r.widgetType) : '—'), + el('td', null, (r.conditions || []).length > 0 + ? (r.conditions || []).map((c, idx) => el('div', { key: idx }, describeCondition(c))) + : '—'), el('td', null, r.replacerId), el('td', null, el(Button, { diff --git a/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php b/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php index 26e30651a..f43de563f 100644 --- a/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php +++ b/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php @@ -14,6 +14,7 @@ use Smartling\Submissions\SubmissionManager; use Smartling\Tests\Mocks\WordpressFunctionsMockHelper; use Smartling\Tuner\JsonFieldRule; +use Smartling\Tests\Smartling\Tuner\JsonLeafMatcherTest; use Smartling\Tuner\JsonFieldRulesManager; class ExternalContentJsonRulesTest extends TestCase @@ -252,6 +253,77 @@ public function testRemoveUntranslatableFieldsStripsCoveredMetaKeys(): void $this->assertSame('preserved', $result['entity']['post_content']); } + private function sovosRules(): array + { + $p = 'accordions__sovos-accordions__items__sovos-accordion__'; + $hero = 'sovos-overview-hero'; + return [ + new JsonFieldRule('_elementor_data', '$..title.text', 'translate', $hero), + new JsonFieldRule('_elementor_data', "\$..{$p}label.text", 'translate', $hero), + new JsonFieldRule('_elementor_data', "\$..{$p}content__sovos-rich-text__text", 'translate', $hero, [ + ['ancestor' => 0, 'key' => 'pattern_type', 'value' => 'sovos-rich-text'], + ]), + new JsonFieldRule('_elementor_data', "\$..{$p}content__sovos-list-items__items__sovos-list-item__text.text", 'translate', $hero, [ + ['ancestor' => 2, 'key' => 'pattern_type', 'value' => 'sovos-list-items'], + ]), + new JsonFieldRule('_elementor_data', '$..image.id', 'related|attachment', $hero), + ]; + } + + public function testExtendedRulesExtractOnlyVisibleWidgetStrings(): void + { + $wpProxy = $this->createMock(WordpressFunctionProxyHelper::class); + $wpProxy->method('getPostMeta')->willReturn(json_encode(JsonLeafMatcherTest::sovosData())); + $engine = $this->buildEngine($this->mockRulesManager($this->sovosRules()), $wpProxy); + + $fields = $engine->getContentFields($this->submission('page', 100), false); + + $this->assertSame( + ['Sovi AI', 'Description', 'Features', '

Real body

', 'Feature one', 'Feature two'], + array_values($fields), + ); + $this->assertSame([269318], $engine->getRelatedContent('page', 100)['attachment']); + } + + public function testExtendedRulesWriteTranslationsAndReplaceIdsInPlace(): void + { + $source = JsonLeafMatcherTest::sovosData(); + $sourceJson = json_encode($source); + $wpProxy = $this->createMock(WordpressFunctionProxyHelper::class); + $wpProxy->method('getPostMeta')->willReturn($sourceJson); + $rules = $this->mockRulesManager($this->sovosRules()); + + $related = $this->createMock(SubmissionEntity::class); + $related->method('getTargetId')->willReturn(555); + $related->method('getId')->willReturn(0); + $submissionManager = $this->createMock(SubmissionManager::class); + $submissionManager->method('findOne')->willReturn($related); + $engine = new ExternalContentJsonRules($rules, new ReplacerFactory($submissionManager), $wpProxy); + + $fields = $engine->getContentFields($this->submission('page', 100), false); + $translated = array_map(static fn(string $v): string => "tr:$v", $fields); + $result = $engine->setContentFields( + ['meta' => ['_elementor_data' => $sourceJson]], + [ExternalContentJsonRules::PLUGIN_ID => $translated, 'meta' => []], + $this->submission('page', 100), + ); + + $decoded = json_decode($result['meta']['_elementor_data'], true); + $widget = $decoded[0]['elements'][0]['elements'][0]; + $items = $widget['settings']['accordions_repeater'][0]['accordions__sovos-accordions__items_repeater']; + $p = 'accordions__sovos-accordions__items__sovos-accordion__'; + $this->assertSame('tr:Sovi AI', $widget['settings']['title']['text']); + $this->assertSame(555, $widget['settings']['image']['id']); + $this->assertSame('tr:Description', $items[0][$p . 'label']['text']); + $this->assertSame('tr:

Real body

', $items[0][$p . 'content_repeater'][0][$p . 'content__sovos-rich-text__text']); + // placeholders are untouched + $this->assertSame('

Sample rich text

', $items[1][$p . 'content_repeater'][0][$p . 'content__sovos-rich-text__text']); + $this->assertSame('List Item 1', $items[0][$p . 'content_repeater'][0][$p . 'content__sovos-list-items__items_repeater'][0][$p . 'content__sovos-list-items__items__sovos-list-item__text']['text']); + $this->assertSame('tr:Feature one', $items[1][$p . 'content_repeater'][0][$p . 'content__sovos-list-items__items_repeater'][0][$p . 'content__sovos-list-items__items__sovos-list-item__text']['text']); + // unrelated widget is untouched + $this->assertSame('Not in the hero', $decoded[0]['elements'][0]['elements'][1]['settings']['title']['text']); + } + private function rule(string $metaKey, string $path, string $replacerId): JsonFieldRule { return new JsonFieldRule($metaKey, $path, $replacerId); diff --git a/tests/Smartling/Tuner/JsonFieldRuleTest.php b/tests/Smartling/Tuner/JsonFieldRuleTest.php index 838c1a9c5..4ceb79e82 100644 --- a/tests/Smartling/Tuner/JsonFieldRuleTest.php +++ b/tests/Smartling/Tuner/JsonFieldRuleTest.php @@ -45,4 +45,44 @@ public function testFromArrayIgnoresLegacyContentTypeField(): void $this->assertSame('$.x', $rule->getPropertyPath()); $this->assertSame('copy', $rule->getReplacerId()); } + + public function testExtendedFieldsRoundTrip(): void + { + $conditions = [['ancestor' => 2, 'key' => 'pattern_type', 'value' => 'sovos-list-items']]; + $rule = new JsonFieldRule('_elementor_data', '$..a-b.text', 'translate', 'sovos-hero', $conditions); + $this->assertTrue($rule->isExtended()); + $this->assertSame(['a-b', 'text'], $rule->getKeySuffix()); + $this->assertEquals($rule, JsonFieldRule::fromArray($rule->toArray())); + $this->assertSame('sovos-hero', $rule->toArray()['widgetType']); + $this->assertSame($conditions, $rule->toArray()['conditions']); + } + + public function testLegacyRuleIsNotExtendedAndOmitsNewFields(): void + { + $rule = new JsonFieldRule('_elementor_data', '$.elements[*].x', 'copy'); + $this->assertFalse($rule->isExtended()); + $this->assertSame(['metaKey', 'propertyPath', 'replacerId'], array_keys($rule->toArray())); + } + + /** + * @dataProvider invalidExtendedProvider + */ + public function testInvalidExtendedRuleThrows(string $path, string $widgetType, array $conditions): void + { + $this->expectException(\InvalidArgumentException::class); + new JsonFieldRule('_elementor_data', $path, 'translate', $widgetType, $conditions); + } + + public static function invalidExtendedProvider(): array + { + $ok = ['ancestor' => 0, 'key' => 'k', 'value' => 'v']; + return [ + 'positional path with widget' => ['$.elements[*].x', 'w', []], + 'bad widget type' => ['$..x', 'bad widget!', []], + 'too many conditions' => ['$..x', '', array_fill(0, 6, $ok)], + 'condition missing value' => ['$..x', '', [['ancestor' => 0, 'key' => 'k']]], + 'negative ancestor' => ['$..x', '', [['ancestor' => -1, 'key' => 'k', 'value' => 'v']]], + 'empty key' => ['$..x', '', [['ancestor' => 0, 'key' => '', 'value' => 'v']]], + ]; + } } diff --git a/tests/Smartling/Tuner/JsonFieldRulesManagerTest.php b/tests/Smartling/Tuner/JsonFieldRulesManagerTest.php index 2de937c38..9de265c7c 100644 --- a/tests/Smartling/Tuner/JsonFieldRulesManagerTest.php +++ b/tests/Smartling/Tuner/JsonFieldRulesManagerTest.php @@ -62,4 +62,51 @@ public function testRemoveItem(): void $this->assertCount(0, $m->listItems()); } + + public function testExportOmitsIdsAndIncludesExtendedFields(): void + { + $m = new JsonFieldRulesManager(); + $m->add(['metaKey' => 'm', 'propertyPath' => '$.x', 'replacerId' => 'copy']); + $m->add(['metaKey' => 'm', 'propertyPath' => '$..y', 'replacerId' => 'translate', 'widgetType' => 'w']); + + $export = $m->export(); + + $this->assertSame(JsonFieldRulesManager::EXPORT_FORMAT_VERSION, $export['version']); + $this->assertSame([ + ['metaKey' => 'm', 'propertyPath' => '$.x', 'replacerId' => 'copy'], + ['metaKey' => 'm', 'propertyPath' => '$..y', 'replacerId' => 'translate', 'widgetType' => 'w'], + ], $export['rules']); + } + + public function testImportPreservesExistingRulesAndSkipsDuplicates(): void + { + $m = new JsonFieldRulesManager(); + $existingId = $m->add(['metaKey' => 'm', 'propertyPath' => '$.keep', 'replacerId' => 'copy']); + $legacyId = $m->add(['contentType' => 'page', 'metaKey' => 'm', 'propertyPath' => '$.legacy', 'replacerId' => 'translate']); + + $result = $m->import([ + new JsonFieldRule('m', '$.keep', 'copy'), + new JsonFieldRule('m', '$.legacy', 'translate'), + new JsonFieldRule('m', '$.new', 'translate'), + new JsonFieldRule('m', '$.new', 'translate'), + new JsonFieldRule('m', '$..scoped', 'translate', 'w'), + ]); + + $this->assertSame(['added' => 2, 'skipped' => 3], $result); + $items = $m->listItems(); + $this->assertCount(4, $items); + $this->assertSame('$.keep', $items[$existingId]->getPropertyPath()); + $this->assertSame('$.legacy', $items[$legacyId]->getPropertyPath()); + } + + public function testExportThenImportIntoEmptyManagerRoundTrips(): void + { + $source = new JsonFieldRulesManager(); + $source->add(['metaKey' => 'm', 'propertyPath' => '$..y', 'replacerId' => 'related|attachment', 'conditions' => [['ancestor' => 0, 'key' => 'k', 'value' => 'v']]]); + $target = new JsonFieldRulesManager(); + + $target->import(array_map(JsonFieldRule::fromArray(...), $source->export()['rules'])); + + $this->assertEquals(array_values($source->listItems()), array_values($target->listItems())); + } } diff --git a/tests/Smartling/Tuner/JsonLeafMatcherTest.php b/tests/Smartling/Tuner/JsonLeafMatcherTest.php new file mode 100644 index 000000000..a35a4aba0 --- /dev/null +++ b/tests/Smartling/Tuner/JsonLeafMatcherTest.php @@ -0,0 +1,123 @@ + ['text' => $text, 'html_tag' => 'div']; + $listItem = static fn(string $id, string $text): array => [ + '_id' => $id, + $p . 'content__sovos-list-items__items__sovos-list-item__text' => ['text' => $text, 'html_tag' => 'div'], + ]; + return [[ + 'id' => 's1', 'elType' => 'section', 'elements' => [[ + 'id' => 'c1', 'elType' => 'column', 'elements' => [[ + 'id' => 'w1', 'elType' => 'widget', 'widgetType' => 'sovos-overview-hero', 'elements' => [], + 'settings' => [ + 'title' => ['text' => 'Sovi AI', 'html_tag' => 'div'], + 'image' => ['id' => 269318, 'alt' => ''], + 'accordions_repeater' => [[ + 'accordions__sovos-accordions__items_repeater' => [ + [ + $p . 'label' => $label('Description'), + $p . 'content_repeater' => [[ + 'pattern_type' => 'sovos-rich-text', + $p . 'content__sovos-rich-text__text' => '

Real body

', + $p . 'content__sovos-list-items__items_repeater' => [ + $listItem('a', 'List Item 1'), + $listItem('b', 'List Item 2'), + ], + ]], + ], + [ + $p . 'label' => $label('Features'), + $p . 'content_repeater' => [[ + 'pattern_type' => 'sovos-list-items', + $p . 'content__sovos-rich-text__text' => '

Sample rich text

', + $p . 'content__sovos-list-items__items_repeater' => [ + $listItem('c', 'Feature one'), + $listItem('d', 'Feature two'), + ], + ]], + ], + ], + ]], + ], + ], [ + 'id' => 'w2', 'elType' => 'widget', 'widgetType' => 'heading', + 'settings' => ['title' => ['text' => 'Not in the hero']], 'elements' => [], + ]], + ]], + ]]; + } + + private function values(JsonFieldRule $rule): array + { + return array_column((new JsonLeafMatcher())->match(self::sovosData(), $rule), 'value'); + } + + public function testKeySuffixMatchesAnywhereIgnoringArrayIndices(): void + { + $this->assertSame( + ['Description', 'Features'], + $this->values(new JsonFieldRule('m', '$..' . self::P . 'label.text', 'translate', 'sovos-overview-hero')), + ); + } + + public function testWidgetScopeExcludesOtherWidgets(): void + { + $this->assertSame(['Sovi AI', 'Not in the hero'], $this->values(new JsonFieldRule('m', '$..title.text', 'translate', '', [ + ['ancestor' => 2, 'key' => 'elType', 'value' => 'widget'], + ]))); + $this->assertSame(['Sovi AI'], $this->values(new JsonFieldRule('m', '$..title.text', 'translate', 'sovos-overview-hero'))); + } + + public function testConditionOnAncestorZeroExcludesPlaceholderRichText(): void + { + $rule = new JsonFieldRule('m', '$..' . self::P . 'content__sovos-rich-text__text', 'translate', 'sovos-overview-hero', [ + ['ancestor' => 0, 'key' => 'pattern_type', 'value' => 'sovos-rich-text'], + ]); + $this->assertSame(['

Real body

'], $this->values($rule)); + } + + public function testConditionOnHigherAncestorExcludesPlaceholderListItems(): void + { + $rule = new JsonFieldRule('m', '$..' . self::P . 'content__sovos-list-items__items__sovos-list-item__text.text', 'translate', 'sovos-overview-hero', [ + ['ancestor' => 2, 'key' => 'pattern_type', 'value' => 'sovos-list-items'], + ]); + $this->assertSame(['Feature one', 'Feature two'], $this->values($rule)); + } + + public function testMatchesNumericLeavesForRelatedRules(): void + { + $this->assertSame([269318], $this->values(new JsonFieldRule('m', '$..image.id', 'related|attachment', 'sovos-overview-hero'))); + } + + public function testSetValueWritesByConcreteSegmentsAndPathIsStable(): void + { + $matcher = new JsonLeafMatcher(); + $rule = new JsonFieldRule('m', '$..' . self::P . 'label.text', 'translate', 'sovos-overview-hero'); + $data = self::sovosData(); + $leaves = $matcher->match($data, $rule); + $this->assertTrue($matcher->setValue($data, $leaves[1]['segments'], 'Özellikler')); + $this->assertSame( + ['Description', 'Özellikler'], + array_column($matcher->match($data, $rule), 'value'), + ); + $this->assertStringStartsWith('$[0].elements[0].elements[0].settings.accordions_repeater[0]', $matcher->pathToString($leaves[0]['segments'])); + $this->assertStringContainsString("['" . self::P . "label']", $matcher->pathToString($leaves[0]['segments'])); + $this->assertFalse($matcher->setValue($data, ['nope', 1], 'x')); + } +} diff --git a/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php b/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php index 8b4e2726f..89b2a1e84 100644 --- a/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php +++ b/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php @@ -221,6 +221,192 @@ public function testAjaxSaveRuleReturns403WhenCapabilityMissing(): void $this->assertTrue($errorCalled); } + /** + * The storage mock does not persist, so keep state across the controller's loadData() call + */ + private function inMemoryManager(): JsonFieldRulesManager + { + return new class extends JsonFieldRulesManager { + public function loadData(): void + { + } + + public function saveData(): void + { + } + }; + } + + private function passthroughProxy(): WordpressFunctionProxyHelper|MockObject + { + $wpProxy = $this->createWpProxy(); + $wpProxy->method('sanitize_text_field')->willReturnCallback(fn(string $v): string => $v); + $wpProxy->method('wp_unslash')->willReturnCallback(fn(string $v): string => $v); + return $wpProxy; + } + + public function testAjaxSaveRuleStoresExtendedRule(): void + { + $_POST = [ + 'metaKey' => '_elementor_data', + 'propertyPath' => '$..title.text', + 'replacerId' => 'translate', + 'widgetType' => 'sovos-overview-hero', + 'conditions' => json_encode([['ancestor' => 0, 'key' => 'pattern_type', 'value' => 'x']]), + ]; + $saved = null; + $wpProxy = $this->passthroughProxy(); + $wpProxy->method('wp_send_json_success')->willReturnCallback(function (array $p) use (&$saved) { + $saved = $p['rule']; + }); + $manager = new JsonFieldRulesManager(); + + $this->makeController($manager, $wpProxy)->ajaxSaveRule(); + + $this->assertSame('sovos-overview-hero', $saved['widgetType']); + $this->assertSame('x', $saved['conditions'][0]['value']); + $this->assertCount(1, $manager->listItems()); + } + + /** + * @dataProvider invalidSaveProvider + */ + public function testAjaxSaveRuleRejectsInvalidExtendedInput(array $extra, string $path = '$..title.text', string $replacer = 'translate'): void + { + $_POST = ['metaKey' => 'm', 'propertyPath' => $path, 'replacerId' => $replacer] + $extra; + $status = null; + $wpProxy = $this->passthroughProxy(); + $wpProxy->method('wp_send_json_error')->willReturnCallback(function ($p, $s = null) use (&$status) { + $status = $s; + }); + $manager = new JsonFieldRulesManager(); + + $this->makeController($manager, $wpProxy)->ajaxSaveRule(); + + $this->assertSame(400, $status); + $this->assertCount(0, $manager->listItems()); + } + + public static function invalidSaveProvider(): array + { + return [ + 'bad widget type' => [['widgetType' => 'bad widget!']], + 'conditions not json' => [['conditions' => 'nope']], + 'positional path with widget' => [['widgetType' => 'w'], '$.a[*].b'], + 'unknown replacer' => [[], '$.a', 'bogus'], + ]; + } + + public function testAjaxExportReturnsRulesWithoutIds(): void + { + $manager = $this->inMemoryManager(); + $manager->add(['metaKey' => 'm', 'propertyPath' => '$..a', 'replacerId' => 'translate', 'widgetType' => 'w']); + $export = null; + $wpProxy = $this->passthroughProxy(); + $wpProxy->method('wp_send_json_success')->willReturnCallback(function (array $p) use (&$export) { + $export = $p['export']; + }); + + $this->makeController($manager, $wpProxy)->ajaxExport(); + + $this->assertSame(JsonFieldRulesManager::EXPORT_FORMAT_VERSION, $export['version']); + $this->assertSame([['metaKey' => 'm', 'propertyPath' => '$..a', 'replacerId' => 'translate', 'widgetType' => 'w']], $export['rules']); + } + + public function testAjaxImportAddsNewRulesKeepsExistingAndReportsInvalid(): void + { + $manager = $this->inMemoryManager(); + $existingId = $manager->add(['metaKey' => 'm', 'propertyPath' => '$.keep', 'replacerId' => 'copy']); + $_POST = ['payload' => json_encode(['version' => 1, 'rules' => [ + ['metaKey' => 'm', 'propertyPath' => '$.keep', 'replacerId' => 'copy'], + ['metaKey' => 'm', 'propertyPath' => '$..new', 'replacerId' => 'translate', 'widgetType' => 'w'], + ['metaKey' => 'm', 'propertyPath' => '$..x', 'replacerId' => 'bogus'], + ['metaKey' => 'm'], + 'garbage', + ]])]; + $result = null; + $wpProxy = $this->passthroughProxy(); + $wpProxy->method('wp_send_json_success')->willReturnCallback(function (array $p) use (&$result) { + $result = $p; + }); + + $this->makeController($manager, $wpProxy)->ajaxImport(); + + $this->assertSame(1, $result['added']); + $this->assertSame(1, $result['skipped']); + $this->assertSame([3, 4, 5], array_column($result['invalid'], 'index')); + $items = $manager->listItems(); + $this->assertCount(2, $items); + $this->assertSame('$.keep', $items[$existingId]->getPropertyPath()); + } + + /** + * @dataProvider invalidImportProvider + */ + public function testAjaxImportRejectsBadFiles(string $payload): void + { + $_POST = ['payload' => $payload]; + $status = null; + $wpProxy = $this->passthroughProxy(); + $wpProxy->method('wp_send_json_error')->willReturnCallback(function ($p, $s = null) use (&$status) { + $status = $s; + }); + $manager = $this->inMemoryManager(); + $manager->add(['metaKey' => 'm', 'propertyPath' => '$.keep', 'replacerId' => 'copy']); + + $this->makeController($manager, $wpProxy)->ajaxImport(); + + $this->assertSame(400, $status); + $this->assertCount(1, $manager->listItems()); + } + + public static function invalidImportProvider(): array + { + return [ + 'empty' => [''], + 'not json' => ['{{'], + 'no rules key' => [json_encode(['version' => 1])], + 'newer version' => [json_encode(['version' => 99, 'rules' => []])], + 'too large' => [str_repeat('a', 1048577)], + ]; + } + + public function testAjaxPreviewReturnsCountAndValues(): void + { + $_POST = [ + 'id' => '7', + 'metaKey' => '_elementor_data', + 'propertyPath' => '$..title.text', + 'replacerId' => 'translate', + 'widgetType' => 'sovos-overview-hero', + ]; + $result = null; + $wpProxy = $this->passthroughProxy(); + $wpProxy->method('getPostMeta')->willReturn(json_encode(\Smartling\Tests\Smartling\Tuner\JsonLeafMatcherTest::sovosData())); + $wpProxy->method('wp_send_json_success')->willReturnCallback(function (array $p) use (&$result) { + $result = $p; + }); + + $this->makeController(new JsonFieldRulesManager(), $wpProxy)->ajaxPreview(); + + $this->assertSame(['count' => 1, 'values' => ['Sovi AI']], $result); + } + + public function testImportAndExportRequireCapability(): void + { + $errors = 0; + $wpProxy = $this->createWpProxy(false); + $wpProxy->method('wp_send_json_error')->willReturnCallback(function ($p, $s = null) use (&$errors) { + $this->assertSame(403, $s); + $errors++; + }); + $controller = $this->makeController(new JsonFieldRulesManager(), $wpProxy); + $controller->ajaxImport(); + $controller->ajaxExport(); + $controller->ajaxPreview(); + $this->assertSame(3, $errors); + } + private function createWpProxy(bool $currentUserCan = true): WordpressFunctionProxyHelper|MockObject { $proxy = $this->createMock(WordpressFunctionProxyHelper::class); From 0c031223369ff934a3d16036e84cf037cb66beb6 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Tue, 29 Sep 2026 14:59:29 +0200 Subject: [PATCH 2/5] use distinct plugin id for Elementor 4 handler so logs are unambiguous (WP-1018) Co-Authored-By: Claude Sonnet 5.5 --- inc/Smartling/ContentTypes/ExternalContentElementor4.php | 5 +++++ .../ContentTypes/ExternalContentElementor4Test.php | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/inc/Smartling/ContentTypes/ExternalContentElementor4.php b/inc/Smartling/ContentTypes/ExternalContentElementor4.php index 5ec4b267e..9f7694b4e 100644 --- a/inc/Smartling/ContentTypes/ExternalContentElementor4.php +++ b/inc/Smartling/ContentTypes/ExternalContentElementor4.php @@ -13,4 +13,9 @@ public function getMinVersion(): string { return '4'; } + + public function getPluginId(): string + { + return 'elementor4'; + } } diff --git a/tests/Smartling/ContentTypes/ExternalContentElementor4Test.php b/tests/Smartling/ContentTypes/ExternalContentElementor4Test.php index 83402ff7d..00c05b140 100644 --- a/tests/Smartling/ContentTypes/ExternalContentElementor4Test.php +++ b/tests/Smartling/ContentTypes/ExternalContentElementor4Test.php @@ -60,6 +60,11 @@ private function mockSubmission(): SubmissionEntity return $submission; } + public function testPluginIdDiffersFromElementor3ForUnambiguousLogs(): void + { + $this->assertSame('elementor4', $this->getHandler($this->makeProxy(''))->getPluginId()); + } + public function testCanHandle(): void { $proxy = $this->createMock(WordpressFunctionProxyHelper::class); @@ -293,7 +298,7 @@ public function testSetContentFieldsWritesTranslationBackIntoTypedStructure(): v // Translation strings are keyed as {containerId: {widgetId: {settingKey: translatedValue}}} $translation = [ 'meta' => [ExternalContentElementor4::META_FIELD_NAME => json_encode($elementData)], - 'elementor' => [ + 'elementor4' => [ 'container1' => [ 'heading1' => ['title' => 'Translated heading'], ], From 97d90718ade56f857c0de5574e80abbebf4d048d Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Wed, 30 Sep 2026 20:03:48 +0200 Subject: [PATCH 3/5] address review comments on visual configurator rules (WP-1018) - store explicit matchMode so "anywhere" rules always use JsonLeafMatcher and match repeater items, with or without widget/conditions - match extended rules against the source JSON and write into the translated copy, so conditions on translatable siblings keep working on download - replace related ids from the source id to avoid remapping twice - validate rule fields in JsonFieldRule and share sanitizing between UI save and import Co-Authored-By: Claude Sonnet 5.5 --- .../ContentTypes/ExternalContentJsonRules.php | 31 +++++-- inc/Smartling/Tuner/JsonFieldRule.php | 45 +++++++++- .../WP/Controller/VisualConfiguratorPage.php | 35 +++----- js/visual-configurator.js | 4 +- .../ExternalContentJsonRulesTest.php | 87 +++++++++++++++++++ tests/Smartling/Tuner/JsonFieldRuleTest.php | 55 ++++++++++++ .../Tuner/JsonFieldRulesManagerTest.php | 2 +- .../Controller/VisualConfiguratorPageTest.php | 51 ++++++++++- 8 files changed, 278 insertions(+), 32 deletions(-) diff --git a/inc/Smartling/ContentTypes/ExternalContentJsonRules.php b/inc/Smartling/ContentTypes/ExternalContentJsonRules.php index 0435fc78c..50607480a 100644 --- a/inc/Smartling/ContentTypes/ExternalContentJsonRules.php +++ b/inc/Smartling/ContentTypes/ExternalContentJsonRules.php @@ -160,13 +160,16 @@ public function setContentFields(array $original, array $translation, Submission $this->getLogger()->debug("Failed to parse meta $metaKey as JSON: " . $e->getMessage()); continue; } + // Extended rules are matched against the source, not the (possibly already translated) copy being edited: + // conditions on translatable siblings must see the same values as they did on upload + $matchSource = $this->decodeJson($original['meta'][$metaKey] ?? null) ?? $jsonObject->getValue(); $modified = false; foreach ($rules as $rule) { [$replacer] = $this->parseReplacer($rule->getReplacerId()); if ($replacer === ReplacerFactory::REPLACER_TRANSLATE) { - $modified = $this->applyTranslateRule($jsonObject, $rule, $metaKey, $translations) || $modified; + $modified = $this->applyTranslateRule($jsonObject, $matchSource, $rule, $metaKey, $translations) || $modified; } elseif ($replacer === ReplacerFactory::REPLACER_RELATED) { - $modified = $this->applyRelatedRule($jsonObject, $rule, $submission) || $modified; + $modified = $this->applyRelatedRule($jsonObject, $matchSource, $rule, $submission) || $modified; } } if ($modified) { @@ -229,12 +232,26 @@ private function safeGet(array $json, string $path): array return $result; } - private function applyTranslateRule(JsonObject $jsonObject, JsonFieldRule $rule, string $metaKey, array $translations): bool + private function decodeJson(mixed $value): ?array + { + if (!is_string($value) || $value === '') { + return null; + } + try { + $decoded = json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + + return is_array($decoded) ? $decoded : null; + } + + private function applyTranslateRule(JsonObject $jsonObject, array $matchSource, JsonFieldRule $rule, string $metaKey, array $translations): bool { if ($rule->isExtended()) { $data = &$jsonObject->getValue(); $changed = false; - foreach ($this->matcher->match($data, $rule) as $leaf) { + foreach ($this->matcher->match($matchSource, $rule) as $leaf) { $key = $this->buildLeafKey($metaKey, $leaf['segments']); if (array_key_exists($key, $translations)) { $changed = $this->matcher->setValue($data, $leaf['segments'], $translations[$key]) || $changed; @@ -264,7 +281,7 @@ private function applyTranslateRule(JsonObject $jsonObject, JsonFieldRule $rule, return $changed; } - private function applyRelatedRule(JsonObject $jsonObject, JsonFieldRule $rule, SubmissionEntity $submission): bool + private function applyRelatedRule(JsonObject $jsonObject, array $matchSource, JsonFieldRule $rule, SubmissionEntity $submission): bool { try { $replacer = $this->replacerFactory->getReplacer($rule->getReplacerId()); @@ -275,7 +292,9 @@ private function applyRelatedRule(JsonObject $jsonObject, JsonFieldRule $rule, S if ($rule->isExtended()) { $data = &$jsonObject->getValue(); $changed = false; - foreach ($this->matcher->match($data, $rule) as $leaf) { + foreach ($this->matcher->match($matchSource, $rule) as $leaf) { + // Replace from the source id: ids another handler already remapped in the copy must not be remapped + // again, a target id can collide with an unrelated source id in multisite $original = $leaf['value']; if (!is_numeric($original) || (int)$original <= 0) { continue; diff --git a/inc/Smartling/Tuner/JsonFieldRule.php b/inc/Smartling/Tuner/JsonFieldRule.php index e53a8196a..140333f31 100644 --- a/inc/Smartling/Tuner/JsonFieldRule.php +++ b/inc/Smartling/Tuner/JsonFieldRule.php @@ -4,12 +4,21 @@ final class JsonFieldRule { + public const MATCH_POSITION = 'position'; + public const MATCH_ANYWHERE = 'anywhere'; public const MAX_CONDITIONS = 5; public const MAX_STRING_LENGTH = 256; + public const MAX_META_KEY_LENGTH = 255; + public const MAX_PATH_LENGTH = 512; + public const MAX_REPLACER_ID_LENGTH = 64; private const EXTENDED_PATH_PATTERN = '~^\$\.\.[A-Za-z_][\w-]*(\.[A-Za-z_][\w-]*)*$~'; private const WIDGET_TYPE_PATTERN = '~^[A-Za-z0-9_-]{1,64}$~'; + private string $matchMode; + /** + * @param string $matchMode MATCH_POSITION rules are evaluated as JSONPath, MATCH_ANYWHERE rules by JsonLeafMatcher. + * Defaults to anywhere when a widget or conditions are set, position otherwise. * @param array $conditions */ public function __construct( @@ -18,18 +27,46 @@ public function __construct( private string $replacerId, private string $widgetType = '', private array $conditions = [], + string $matchMode = '', ) { + foreach (['metaKey' => [$this->metaKey, self::MAX_META_KEY_LENGTH], 'propertyPath' => [$this->propertyPath, self::MAX_PATH_LENGTH], 'replacerId' => [$this->replacerId, self::MAX_REPLACER_ID_LENGTH]] as $field => [$value, $max]) { + if ($value === '') { + throw new \InvalidArgumentException("Field cannot be empty: $field"); + } + if (strlen($value) > $max) { + throw new \InvalidArgumentException("$field exceeds maximum length of $max characters"); + } + if (preg_match('~[\x00-\x1F\x7F]~', $value) === 1) { + throw new \InvalidArgumentException("$field contains invalid characters"); + } + } if ($this->widgetType !== '' && preg_match(self::WIDGET_TYPE_PATTERN, $this->widgetType) !== 1) { throw new \InvalidArgumentException('Invalid widgetType'); } $this->conditions = self::normalizeConditions($this->conditions); + $scoped = $this->widgetType !== '' || $this->conditions !== []; + if ($matchMode === '') { + $matchMode = $scoped ? self::MATCH_ANYWHERE : self::MATCH_POSITION; + } + if (!in_array($matchMode, [self::MATCH_POSITION, self::MATCH_ANYWHERE], true)) { + throw new \InvalidArgumentException("Unknown matchMode: $matchMode"); + } + if ($scoped && $matchMode !== self::MATCH_ANYWHERE) { + throw new \InvalidArgumentException('Rules with a widget or conditions must use matchMode anywhere'); + } + $this->matchMode = $matchMode; if ($this->isExtended() && preg_match(self::EXTENDED_PATH_PATTERN, $this->propertyPath) !== 1) { throw new \InvalidArgumentException( - 'Rules with a widget or conditions must use a path in the form $..key.subkey', + 'Rules that match anywhere must use a path in the form $..key.subkey', ); } } + public function getMatchMode(): string + { + return $this->matchMode; + } + public function getMetaKey(): string { return $this->metaKey; @@ -63,7 +100,7 @@ public function getConditions(): array */ public function isExtended(): bool { - return $this->widgetType !== '' || $this->conditions !== []; + return $this->matchMode === self::MATCH_ANYWHERE; } /** @@ -81,6 +118,9 @@ public function toArray(): array 'propertyPath' => $this->propertyPath, 'replacerId' => $this->replacerId, ]; + if ($this->isExtended()) { + $result['matchMode'] = self::MATCH_ANYWHERE; + } if ($this->widgetType !== '') { $result['widgetType'] = $this->widgetType; } @@ -105,6 +145,7 @@ public static function fromArray(array $data): self (string)$data['replacerId'], (string)($data['widgetType'] ?? ''), is_array($data['conditions'] ?? null) ? $data['conditions'] : [], + (string)($data['matchMode'] ?? ''), ); } diff --git a/inc/Smartling/WP/Controller/VisualConfiguratorPage.php b/inc/Smartling/WP/Controller/VisualConfiguratorPage.php index f2f0a669a..584f43340 100644 --- a/inc/Smartling/WP/Controller/VisualConfiguratorPage.php +++ b/inc/Smartling/WP/Controller/VisualConfiguratorPage.php @@ -212,47 +212,40 @@ private function verifyNonceAndCapabilities(): bool */ private function readRule(): JsonFieldRule { - $get = function (string $key): string { + $data = []; + foreach (['metaKey', 'propertyPath', 'replacerId'] as $key) { if (!isset($_POST[$key]) || !is_string($_POST[$key])) { throw new \InvalidArgumentException("Missing field: $key"); } - return $this->wpProxy->sanitize_text_field($this->wpProxy->wp_unslash($_POST[$key])); - }; - $payload = [ - 'metaKey' => $get('metaKey'), - 'propertyPath' => $get('propertyPath'), - 'replacerId' => $get('replacerId'), - ]; - foreach ($payload as $k => $v) { - if ($v === '') { - throw new \InvalidArgumentException("Field cannot be empty: $k"); - } + $data[$key] = $this->wpProxy->wp_unslash($_POST[$key]); } - if (strlen($payload['propertyPath']) > 512) { - throw new \InvalidArgumentException('propertyPath exceeds maximum length of 512 characters'); + foreach (['widgetType', 'matchMode'] as $key) { + $data[$key] = isset($_POST[$key]) && is_string($_POST[$key]) ? $this->wpProxy->wp_unslash($_POST[$key]) : ''; } - $widgetType = isset($_POST['widgetType']) && is_string($_POST['widgetType']) - ? $this->wpProxy->sanitize_text_field($this->wpProxy->wp_unslash($_POST['widgetType'])) - : ''; - $conditions = []; if (isset($_POST['conditions']) && is_string($_POST['conditions']) && $_POST['conditions'] !== '') { $decoded = json_decode($this->wpProxy->wp_unslash($_POST['conditions']), true); if (!is_array($decoded)) { throw new \InvalidArgumentException('conditions must be a JSON list'); } - $conditions = $decoded; + $data['conditions'] = $decoded; } - return $this->buildRule($payload + ['widgetType' => $widgetType, 'conditions' => $conditions]); + return $this->buildRule($data); } /** - * Validates everything about a rule, including that the replacer exists + * The only way to turn untrusted input (UI or imported file) into a rule: sanitizes the text fields, + * JsonFieldRule validates them and this checks that the replacer exists * * @throws \InvalidArgumentException */ private function buildRule(array $data): JsonFieldRule { + foreach (['metaKey', 'propertyPath', 'replacerId', 'widgetType', 'matchMode'] as $key) { + if (isset($data[$key]) && is_string($data[$key])) { + $data[$key] = $this->wpProxy->sanitize_text_field($data[$key]); + } + } $rule = JsonFieldRule::fromArray($data); try { $this->replacerFactory->getReplacer($rule->getReplacerId()); diff --git a/js/visual-configurator.js b/js/visual-configurator.js index 32d0b51c4..15c296a41 100644 --- a/js/visual-configurator.js +++ b/js/visual-configurator.js @@ -267,7 +267,7 @@ const activeWidget = extended && limitToWidget ? widgetType : ''; const activeConditions = extended ? conditions : []; const composedReplacerId = replacerId === 'related' ? `related|${refType}` : replacerId; - const previewKey = JSON.stringify([propertyPath, activeWidget, activeConditions, composedReplacerId]); + const previewKey = JSON.stringify([propertyPath, activeWidget, activeConditions, composedReplacerId, mode]); useEffect(() => { if (!draft || draft.path === '' || !replacerId) { @@ -284,6 +284,7 @@ replacerId: composedReplacerId, widgetType: activeWidget, conditions: JSON.stringify(activeConditions), + matchMode: mode, }); if (!cancelled) { setPreview(response && response.success @@ -387,6 +388,7 @@ replacerId: composedReplacerId, widgetType: activeWidget, conditions: JSON.stringify(activeConditions), + matchMode: mode, }), }, 'Save rule'), ' ', diff --git a/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php b/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php index f43de563f..9ceef6f1a 100644 --- a/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php +++ b/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php @@ -324,6 +324,93 @@ public function testExtendedRulesWriteTranslationsAndReplaceIdsInPlace(): void $this->assertSame('Not in the hero', $decoded[0]['elements'][0]['elements'][1]['settings']['title']['text']); } + public function testAnywhereRuleWithoutScopeMatchesRepeaterItems(): void + { + $source = [['settings' => ['items' => [['title' => 'A'], ['title' => 'B']]]]]; + $sourceJson = json_encode($source); + $wpProxy = $this->createMock(WordpressFunctionProxyHelper::class); + $wpProxy->method('getPostMeta')->willReturn($sourceJson); + $engine = $this->buildEngine($this->mockRulesManager([ + new JsonFieldRule('_elementor_data', '$..items.title', 'translate', '', [], JsonFieldRule::MATCH_ANYWHERE), + ]), $wpProxy); + + $fields = $engine->getContentFields($this->submission('page', 100), false); + $this->assertSame(['A', 'B'], array_values($fields)); + + $result = $engine->setContentFields( + ['meta' => ['_elementor_data' => $sourceJson]], + [ExternalContentJsonRules::PLUGIN_ID => array_map(static fn(string $v): string => "tr:$v", $fields), 'meta' => []], + $this->submission('page', 100), + ); + $decoded = json_decode($result['meta']['_elementor_data'], true); + $this->assertSame('tr:A', $decoded[0]['settings']['items'][0]['title']); + $this->assertSame('tr:B', $decoded[0]['settings']['items'][1]['title']); + } + + public function testConditionOnTranslatableSiblingStillMatchesAfterAnotherHandlerTranslatedTheCopy(): void + { + $source = [['settings' => ['block' => ['kind' => 'Hello', 'text' => 'Body']]]]; + $translatedByOtherHandler = [['settings' => ['block' => ['kind' => 'Hola', 'text' => 'Body']]]]; + $wpProxy = $this->createMock(WordpressFunctionProxyHelper::class); + $wpProxy->method('getPostMeta')->willReturn(json_encode($source)); + $engine = $this->buildEngine($this->mockRulesManager([ + new JsonFieldRule('_elementor_data', '$..block.text', 'translate', '', [ + ['ancestor' => 0, 'key' => 'kind', 'value' => 'Hello'], + ]), + ]), $wpProxy); + + $fields = $engine->getContentFields($this->submission('page', 100), false); + $this->assertSame(['Body'], array_values($fields)); + + $result = $engine->setContentFields( + ['meta' => ['_elementor_data' => json_encode($source)]], + [ + ExternalContentJsonRules::PLUGIN_ID => array_map(static fn(string $v): string => "tr:$v", $fields), + 'meta' => ['_elementor_data' => json_encode($translatedByOtherHandler)], + ], + $this->submission('page', 100), + ); + + $decoded = json_decode($result['meta']['_elementor_data'], true); + $this->assertSame('tr:Body', $decoded[0]['settings']['block']['text']); + $this->assertSame('Hola', $decoded[0]['settings']['block']['kind'], 'other handler work is preserved'); + } + + public function testRelatedExtendedRuleReplacesFromSourceIdAndIgnoresIdAlreadyRemapped(): void + { + $source = [['settings' => ['image' => ['id' => 11]]]]; + // Another handler already remapped 11 => 110, and 110 also exists as an unrelated source id mapped to 999 + $alreadyRemapped = [['settings' => ['image' => ['id' => 110]]]]; + $submissionManager = $this->createMock(SubmissionManager::class); + $submissionManager->method('findOne')->willReturnCallback(function (array $params) { + $map = [11 => 110, 110 => 999]; + $target = $map[$params[SubmissionEntity::FIELD_SOURCE_ID] ?? 0] ?? null; + if ($target === null) { + return null; + } + $related = $this->createMock(SubmissionEntity::class); + $related->method('getTargetId')->willReturn($target); + $related->method('getId')->willReturn(0); + return $related; + }); + $engine = new ExternalContentJsonRules( + $this->mockRulesManager([new JsonFieldRule('_elementor_data', '$..image.id', 'related|attachment', 'w')]), + new ReplacerFactory($submissionManager), + $this->createMock(WordpressFunctionProxyHelper::class), + ); + $source[0]['elType'] = $alreadyRemapped[0]['elType'] = 'widget'; + $source[0]['widgetType'] = $alreadyRemapped[0]['widgetType'] = 'w'; + + $result = $engine->setContentFields( + ['meta' => ['_elementor_data' => json_encode($source)]], + ['meta' => ['_elementor_data' => json_encode($alreadyRemapped)]], + $this->submission('page', 100), + ); + + $decoded = json_decode($result['meta']['_elementor_data'], true); + $this->assertSame(110, $decoded[0]['settings']['image']['id']); + } + private function rule(string $metaKey, string $path, string $replacerId): JsonFieldRule { return new JsonFieldRule($metaKey, $path, $replacerId); diff --git a/tests/Smartling/Tuner/JsonFieldRuleTest.php b/tests/Smartling/Tuner/JsonFieldRuleTest.php index 4ceb79e82..39d6a549a 100644 --- a/tests/Smartling/Tuner/JsonFieldRuleTest.php +++ b/tests/Smartling/Tuner/JsonFieldRuleTest.php @@ -85,4 +85,59 @@ public static function invalidExtendedProvider(): array 'empty key' => ['$..x', '', [['ancestor' => 0, 'key' => '', 'value' => 'v']]], ]; } + + public function testAnywhereModeWithoutWidgetOrConditionsIsExtended(): void + { + $rule = new JsonFieldRule('_elementor_data', '$..items.title', 'translate', '', [], JsonFieldRule::MATCH_ANYWHERE); + $this->assertTrue($rule->isExtended()); + $this->assertSame('anywhere', $rule->toArray()['matchMode']); + $this->assertEquals($rule, JsonFieldRule::fromArray($rule->toArray())); + } + + public function testAnywhereModeRequiresRecursivePath(): void + { + $this->expectException(\InvalidArgumentException::class); + new JsonFieldRule('_elementor_data', '$.items[*].title', 'translate', '', [], JsonFieldRule::MATCH_ANYWHERE); + } + + public function testPositionModeWithWidgetThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + new JsonFieldRule('_elementor_data', '$..title', 'translate', 'w', [], JsonFieldRule::MATCH_POSITION); + } + + public function testUnknownMatchModeThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + new JsonFieldRule('_elementor_data', '$.x', 'translate', '', [], 'sideways'); + } + + public function testStoredRulesWithoutMatchModeKeepLegacyBehavior(): void + { + $rule = JsonFieldRule::fromArray(['metaKey' => 'm', 'propertyPath' => '$..x', 'replacerId' => 'copy']); + $this->assertFalse($rule->isExtended()); + $this->assertArrayNotHasKey('matchMode', $rule->toArray()); + } + + /** + * @dataProvider invalidFieldProvider + */ + public function testInvalidBaseFieldsThrow(string $metaKey, string $path, string $replacer): void + { + $this->expectException(\InvalidArgumentException::class); + new JsonFieldRule($metaKey, $path, $replacer); + } + + public static function invalidFieldProvider(): array + { + return [ + 'empty meta key' => ['', '$.x', 'copy'], + 'empty path' => ['m', '', 'copy'], + 'empty replacer' => ['m', '$.x', ''], + 'long meta key' => [str_repeat('a', 256), '$.x', 'copy'], + 'long path' => ['m', '$.' . str_repeat('a', 511), 'copy'], + 'long replacer' => ['m', '$.x', str_repeat('a', 65)], + 'control character' => ["m\n", '$.x', 'copy'], + ]; + } } diff --git a/tests/Smartling/Tuner/JsonFieldRulesManagerTest.php b/tests/Smartling/Tuner/JsonFieldRulesManagerTest.php index 9de265c7c..723bc8cbb 100644 --- a/tests/Smartling/Tuner/JsonFieldRulesManagerTest.php +++ b/tests/Smartling/Tuner/JsonFieldRulesManagerTest.php @@ -74,7 +74,7 @@ public function testExportOmitsIdsAndIncludesExtendedFields(): void $this->assertSame(JsonFieldRulesManager::EXPORT_FORMAT_VERSION, $export['version']); $this->assertSame([ ['metaKey' => 'm', 'propertyPath' => '$.x', 'replacerId' => 'copy'], - ['metaKey' => 'm', 'propertyPath' => '$..y', 'replacerId' => 'translate', 'widgetType' => 'w'], + ['metaKey' => 'm', 'propertyPath' => '$..y', 'replacerId' => 'translate', 'matchMode' => 'anywhere', 'widgetType' => 'w'], ], $export['rules']); } diff --git a/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php b/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php index 89b2a1e84..e49103449 100644 --- a/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php +++ b/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php @@ -297,6 +297,55 @@ public static function invalidSaveProvider(): array ]; } + public function testAjaxSaveRuleStoresAnywhereRuleWithoutWidgetOrConditions(): void + { + $_POST = [ + 'metaKey' => '_elementor_data', + 'propertyPath' => '$..items.title', + 'replacerId' => 'translate', + 'matchMode' => 'anywhere', + 'widgetType' => '', + 'conditions' => '[]', + ]; + $saved = null; + $wpProxy = $this->passthroughProxy(); + $wpProxy->method('wp_send_json_success')->willReturnCallback(function (array $p) use (&$saved) { + $saved = $p['rule']; + }); + + $this->makeController(new JsonFieldRulesManager(), $wpProxy)->ajaxSaveRule(); + + $this->assertSame('anywhere', $saved['matchMode']); + } + + public function testAjaxImportAppliesSameValidationAsSave(): void + { + $manager = $this->inMemoryManager(); + $_POST = ['payload' => json_encode(['version' => 1, 'rules' => [ + ['metaKey' => '', 'propertyPath' => '$.a', 'replacerId' => 'copy'], + ['metaKey' => 'm', 'propertyPath' => '$.' . str_repeat('a', 600), 'replacerId' => 'copy'], + ['metaKey' => "m\n", 'propertyPath' => '$.a', 'replacerId' => 'copy'], + ['metaKey' => 'm', 'propertyPath' => '$.ok', 'replacerId' => 'copy'], + ]])]; + $result = null; + $sanitized = []; + $wpProxy = $this->createWpProxy(); + $wpProxy->method('wp_unslash')->willReturnCallback(fn(string $v): string => $v); + $wpProxy->method('sanitize_text_field')->willReturnCallback(function (string $v) use (&$sanitized): string { + $sanitized[] = $v; + return $v; + }); + $wpProxy->method('wp_send_json_success')->willReturnCallback(function (array $p) use (&$result) { + $result = $p; + }); + + $this->makeController($manager, $wpProxy)->ajaxImport(); + + $this->assertSame(1, $result['added']); + $this->assertSame([1, 2, 3], array_column($result['invalid'], 'index')); + $this->assertContains('$.ok', $sanitized, 'imported text fields must be sanitized like UI input'); + } + public function testAjaxExportReturnsRulesWithoutIds(): void { $manager = $this->inMemoryManager(); @@ -310,7 +359,7 @@ public function testAjaxExportReturnsRulesWithoutIds(): void $this->makeController($manager, $wpProxy)->ajaxExport(); $this->assertSame(JsonFieldRulesManager::EXPORT_FORMAT_VERSION, $export['version']); - $this->assertSame([['metaKey' => 'm', 'propertyPath' => '$..a', 'replacerId' => 'translate', 'widgetType' => 'w']], $export['rules']); + $this->assertSame([['metaKey' => 'm', 'propertyPath' => '$..a', 'replacerId' => 'translate', 'matchMode' => 'anywhere', 'widgetType' => 'w']], $export['rules']); } public function testAjaxImportAddsNewRulesKeepsExistingAndReportsInvalid(): void From fbd20d6909d4816c2e50bf7a04268405a193c71b Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Wed, 30 Sep 2026 20:06:24 +0200 Subject: [PATCH 4/5] replace ids for legacy related rules from the source, not the translated copy (WP-1018) Co-Authored-By: Claude Sonnet 5.5 --- .../ContentTypes/ExternalContentJsonRules.php | 29 +++++++++++----- .../ExternalContentJsonRulesTest.php | 33 +++++++++++++++++++ 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/inc/Smartling/ContentTypes/ExternalContentJsonRules.php b/inc/Smartling/ContentTypes/ExternalContentJsonRules.php index 50607480a..32d893ec4 100644 --- a/inc/Smartling/ContentTypes/ExternalContentJsonRules.php +++ b/inc/Smartling/ContentTypes/ExternalContentJsonRules.php @@ -308,17 +308,17 @@ private function applyRelatedRule(JsonObject $jsonObject, array $matchSource, Js return $changed; } - $objects = $jsonObject->getJsonObjects($rule->getPropertyPath()); - if ($objects === false || $objects === null) { - return false; - } - if (!is_array($objects)) { - $objects = [$objects]; + $objects = $this->getNodes($jsonObject, $rule->getPropertyPath()); + // Read ids from the source, the copy may already hold ids remapped by another handler (see extended branch) + $sourceObjects = $this->getNodes(new JsonObject($matchSource), $rule->getPropertyPath()); + $usesSource = count($sourceObjects) === count($objects); + if (!$usesSource) { + $this->getLogger()->notice("Source and translation differ in structure for path={$rule->getPropertyPath()}, using translated values"); } $changed = false; - foreach ($objects as $node) { + foreach ($objects as $index => $node) { $ref = &$node->getValue(); - $original = $ref; + $original = $usesSource ? $sourceObjects[$index]->getValue() : $ref; if (!is_numeric($original) || (int)$original <= 0) { unset($ref); continue; @@ -333,6 +333,19 @@ private function applyRelatedRule(JsonObject $jsonObject, array $matchSource, Js return $changed; } + /** + * @return JsonObject[] + */ + private function getNodes(JsonObject $jsonObject, string $path): array + { + $objects = $jsonObject->getJsonObjects($path); + if ($objects === false || $objects === null) { + return []; + } + + return is_array($objects) ? array_values($objects) : [$objects]; + } + /** * @return array{0:string,1:string} [replacerId, contentTypeHint] */ diff --git a/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php b/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php index 9ceef6f1a..c90a0eda7 100644 --- a/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php +++ b/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php @@ -411,6 +411,39 @@ public function testRelatedExtendedRuleReplacesFromSourceIdAndIgnoresIdAlreadyRe $this->assertSame(110, $decoded[0]['settings']['image']['id']); } + public function testRelatedPositionRuleReplacesFromSourceIdAndIgnoresIdAlreadyRemapped(): void + { + $source = ['elements' => [['image' => ['id' => 11]], ['image' => ['id' => 22]]]]; + // Another handler already remapped 11 => 110 and 22 => 220, 110 is also an unrelated source id mapped to 999 + $alreadyRemapped = ['elements' => [['image' => ['id' => 110]], ['image' => ['id' => 220]]]]; + $submissionManager = $this->createMock(SubmissionManager::class); + $submissionManager->method('findOne')->willReturnCallback(function (array $params) { + $target = [11 => 110, 22 => 220, 110 => 999][$params[SubmissionEntity::FIELD_SOURCE_ID] ?? 0] ?? null; + if ($target === null) { + return null; + } + $related = $this->createMock(SubmissionEntity::class); + $related->method('getTargetId')->willReturn($target); + $related->method('getId')->willReturn(0); + return $related; + }); + $engine = new ExternalContentJsonRules( + $this->mockRulesManager([$this->rule('_elementor_data', '$.elements[*].image.id', 'related|attachment')]), + new ReplacerFactory($submissionManager), + $this->createMock(WordpressFunctionProxyHelper::class), + ); + + $result = $engine->setContentFields( + ['meta' => ['_elementor_data' => json_encode($source)]], + ['meta' => ['_elementor_data' => json_encode($alreadyRemapped)]], + $this->submission('page', 100), + ); + + $decoded = json_decode($result['meta']['_elementor_data'], true); + $this->assertSame(110, $decoded['elements'][0]['image']['id']); + $this->assertSame(220, $decoded['elements'][1]['image']['id']); + } + private function rule(string $metaKey, string $path, string $replacerId): JsonFieldRule { return new JsonFieldRule($metaKey, $path, $replacerId); From 84edb1e25289d3eea1c52684ff25e94633433299 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Thu, 1 Oct 2026 14:33:39 +0200 Subject: [PATCH 5/5] address second round of review comments (WP-1018) - keep elementor as the data key for Elementor 3 and 4 and add getLogName() to Pluggable so logs can tell the handlers apart (elementor3, elementor4) - require edit_post capability for the configurator preview - remount the rule editor for every opened draft so state does not leak - do not offer the leaf's own value as a condition Co-Authored-By: Claude Sonnet 5.5 --- .../ExternalContentElementor3.php | 8 ++++++++ .../ExternalContentElementor4.php | 5 ++++- .../ContentTypes/ExternalContentJsonRules.php | 5 +++++ .../ContentTypes/ExternalContentManager.php | 20 +++++++++---------- inc/Smartling/Extensions/Pluggable.php | 8 ++++++++ .../Extensions/PluggableAbstract.php | 5 +++++ .../WP/Controller/VisualConfiguratorPage.php | 6 ++++++ js/visual-configurator.js | 13 ++++++++++-- .../ExternalContentElementor3Test.php | 7 +++++++ .../ExternalContentElementor4Test.php | 8 +++++--- .../Controller/VisualConfiguratorPageTest.php | 18 +++++++++++++++++ 11 files changed, 87 insertions(+), 16 deletions(-) diff --git a/inc/Smartling/ContentTypes/ExternalContentElementor3.php b/inc/Smartling/ContentTypes/ExternalContentElementor3.php index 0af7b3345..513d689ce 100644 --- a/inc/Smartling/ContentTypes/ExternalContentElementor3.php +++ b/inc/Smartling/ContentTypes/ExternalContentElementor3.php @@ -13,4 +13,12 @@ public function getMinVersion(): string { return '3'; } + + /** + * Name used in logs to tell the handler apart from ExternalContentElementor4, the data key stays the plugin id + */ + public function getLogName(): string + { + return 'elementor3'; + } } diff --git a/inc/Smartling/ContentTypes/ExternalContentElementor4.php b/inc/Smartling/ContentTypes/ExternalContentElementor4.php index 9f7694b4e..571e0c8d3 100644 --- a/inc/Smartling/ContentTypes/ExternalContentElementor4.php +++ b/inc/Smartling/ContentTypes/ExternalContentElementor4.php @@ -14,7 +14,10 @@ public function getMinVersion(): string return '4'; } - public function getPluginId(): string + /** + * Name used in logs to tell the handler apart from ExternalContentElementor3, the data key stays the plugin id + */ + public function getLogName(): string { return 'elementor4'; } diff --git a/inc/Smartling/ContentTypes/ExternalContentJsonRules.php b/inc/Smartling/ContentTypes/ExternalContentJsonRules.php index 32d893ec4..f00e10c0c 100644 --- a/inc/Smartling/ContentTypes/ExternalContentJsonRules.php +++ b/inc/Smartling/ContentTypes/ExternalContentJsonRules.php @@ -44,6 +44,11 @@ public function getPluginId(): string return self::PLUGIN_ID; } + public function getLogName(): string + { + return self::PLUGIN_ID; + } + public function getPluginPaths(): array { return []; diff --git a/inc/Smartling/ContentTypes/ExternalContentManager.php b/inc/Smartling/ContentTypes/ExternalContentManager.php index 25d0e9255..affa03978 100644 --- a/inc/Smartling/ContentTypes/ExternalContentManager.php +++ b/inc/Smartling/ContentTypes/ExternalContentManager.php @@ -33,12 +33,12 @@ public function getExternalContent(array $source, SubmissionEntity $submission, return $this->siteHelper->withBlog($submission->getSourceBlogId(), function () use ($raw, $source, $submission) { foreach ($this->handlers as $handler) { if ($handler->getSupportLevel($submission->getContentType(), $submission->getSourceId()) === Pluggable::SUPPORTED) { - $this->getLogger()->debug("Determined support for {$handler->getPluginId()}, will try to get fields"); + $this->getLogger()->debug("Determined support for {$handler->getLogName()}, will try to get fields"); try { $submission->assertHasSource(); $source[$handler->getPluginId()] = $handler->getContentFields($submission, $raw); } catch (\Throwable $e) { - $this->getLogger()->notice('HandlerName="' . $handler->getPluginId() . '" got exception while trying to get external content: ' . $e->getMessage()); + $this->getLogger()->notice('HandlerName="' . $handler->getLogName() . '" got exception while trying to get external content: ' . $e->getMessage()); } } if ($handler instanceof ContentTypeModifyingInterface) { @@ -47,10 +47,10 @@ public function getExternalContent(array $source, SubmissionEntity $submission, $source = $handler->removeUntranslatableFieldsForUpload($source, $submission); $count = count($this->fieldsFilterHelper->flattenArray($source)); if ($previousCount !== $count) { - $this->getLogger()->info('HandlerName="' . $handler->getPluginId() . '" altered content fields for upload, previousCount=' . $previousCount . ', count=' . $count); + $this->getLogger()->info('HandlerName="' . $handler->getLogName() . '" altered content fields for upload, previousCount=' . $previousCount . ', count=' . $count); } } catch (\Throwable $e) { - $this->getLogger()->warning('HandlerName="' . $handler->getPluginId() . '" got exception while trying to alter content fields: ' . $e->getMessage()); + $this->getLogger()->warning('HandlerName="' . $handler->getLogName() . '" got exception while trying to alter content fields: ' . $e->getMessage()); } } } @@ -74,11 +74,11 @@ public function getExternalRelations(string $contentType, int $id): array $result = []; foreach ($this->handlers as $handler) { if ($handler->getSupportLevel($contentType, $id) === Pluggable::SUPPORTED) { - $this->getLogger()->debug("Determined support for {$handler->getPluginId()}, will try to get related content"); + $this->getLogger()->debug("Determined support for {$handler->getLogName()}, will try to get related content"); try { $result = array_merge_recursive($result, $handler->getRelatedContent($contentType, $id)); } catch (\Throwable $e) { - $this->getLogger()->notice('HandlerName="' . $handler->getPluginId() . + $this->getLogger()->notice('HandlerName="' . $handler->getLogName() . '" got errorClass="' . $e::class . '" while trying to get external related content: ' . $e->getMessage()); } @@ -100,18 +100,18 @@ public function setExternalContent(array $original, array $translation, Submissi { foreach ($this->handlers as $handler) { if ($handler->getSupportLevel($submission->getContentType(), $submission->getSourceId()) === Pluggable::SUPPORTED) { - $this->getLogger()->debug("Determined support for {$handler->getPluginId()}, will try to set fields"); + $this->getLogger()->debug("Determined support for {$handler->getLogName()}, will try to set fields"); try { $externalContent = $handler->setContentFields($original, $translation, $submission); if ($externalContent !== null) { - $this->getLogger()->info('Content array modified by HandlerName="' . $handler->getPluginId() . '"'); + $this->getLogger()->info('Content array modified by HandlerName="' . $handler->getLogName() . '"'); $translation = $externalContent; } } catch (\Throwable $e) { - $this->getLogger()->notice('HandlerName="' . $handler->getPluginId() . '" got exception while trying to set external content: ' . $e->getMessage()); + $this->getLogger()->notice('HandlerName="' . $handler->getLogName() . '" got exception while trying to set external content: ' . $e->getMessage()); } } else { - $this->getLogger()->debug("No support for {$handler->getPluginId()} detected"); + $this->getLogger()->debug("No support for {$handler->getLogName()} detected"); } } diff --git a/inc/Smartling/Extensions/Pluggable.php b/inc/Smartling/Extensions/Pluggable.php index f5a14e47b..bc2a1c57d 100644 --- a/inc/Smartling/Extensions/Pluggable.php +++ b/inc/Smartling/Extensions/Pluggable.php @@ -13,8 +13,16 @@ public function getMaxVersion(): string; public function getMinVersion(): string; + /** + * Key under which the handler's data is stored in submissions, must stay stable across versions + */ public function getPluginId(): string; + /** + * Name to use in logs, may differ from the plugin id when handlers share it + */ + public function getLogName(): string; + /** * @return array with possible paths to the plugin file relative to the plugins directory. * Most plugins have a single possible path, some have variations. diff --git a/inc/Smartling/Extensions/PluggableAbstract.php b/inc/Smartling/Extensions/PluggableAbstract.php index 5ee84536c..214430d35 100644 --- a/inc/Smartling/Extensions/PluggableAbstract.php +++ b/inc/Smartling/Extensions/PluggableAbstract.php @@ -10,6 +10,11 @@ public function __construct(protected PluginHelper $pluginHelper, protected Word { } + public function getLogName(): string + { + return $this->getPluginId(); + } + public function getPluginSupportLevel(): string { $result = Pluggable::NOT_SUPPORTED; diff --git a/inc/Smartling/WP/Controller/VisualConfiguratorPage.php b/inc/Smartling/WP/Controller/VisualConfiguratorPage.php index 584f43340..ae8cd4ba1 100644 --- a/inc/Smartling/WP/Controller/VisualConfiguratorPage.php +++ b/inc/Smartling/WP/Controller/VisualConfiguratorPage.php @@ -266,6 +266,12 @@ public function ajaxPreview(): void $this->wpProxy->wp_send_json_error(['message' => 'Missing or invalid id'], 400); return; } + // The capability check above is global, the preview must not expose meta of posts the user cannot edit + if (!$this->wpProxy->current_user_can('edit_post', $id)) { + $this->getLogger()->warning(sprintf('User %d cannot edit postId=%d, preview denied', get_current_user_id(), $id)); + $this->wpProxy->wp_send_json_error(['message' => 'Insufficient permissions'], 403); + return; + } try { $rule = $this->readRule(); } catch (\InvalidArgumentException $e) { diff --git a/js/visual-configurator.js b/js/visual-configurator.js index 15c296a41..d7a477e8c 100644 --- a/js/visual-configurator.js +++ b/js/visual-configurator.js @@ -252,7 +252,9 @@ for (let distance = 0; distance < ancestors.length; distance++) { const object = ancestors[ancestors.length - 1 - distance]; Object.entries(object).forEach(([key, val]) => { - if (typeof val === 'string' && key !== 'elType' && key !== 'widgetType') { + // The leaf's own value would limit the rule to that single source string + const isLeafItself = distance === 0 && key === keys[keys.length - 1]; + if (typeof val === 'string' && key !== 'elType' && key !== 'widgetType' && !isLeafItself) { conditionOptions.push({ id: `${distance}|${key}`, ancestor: distance, key, value: val }); } }); @@ -405,8 +407,14 @@ const [error, setError] = useState(''); const [rules, setRules] = useState([]); const [draft, setDraft] = useState(null); + const [draftSeq, setDraftSeq] = useState(0); const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [importResult, setImportResult] = useState(null); + // Every opened draft gets a fresh editor, otherwise match mode, keys and ticked conditions leak into the next rule + const openDraft = useCallback((next) => { + setDraftSeq((n) => n + 1); + setDraft(next); + }, []); const fileInput = useRef(null); const refreshRules = useCallback(async () => { @@ -569,13 +577,14 @@ key: name, name, value, - onAddRule: setDraft, + onAddRule: openDraft, rulesByPath, }), ), ), ), el(RuleEditor, { + key: draftSeq, draft, contentId, onCancel: () => setDraft(null), diff --git a/tests/Smartling/ContentTypes/ExternalContentElementor3Test.php b/tests/Smartling/ContentTypes/ExternalContentElementor3Test.php index 40fc31afa..7c2aca857 100644 --- a/tests/Smartling/ContentTypes/ExternalContentElementor3Test.php +++ b/tests/Smartling/ContentTypes/ExternalContentElementor3Test.php @@ -27,6 +27,13 @@ public function testCanHandle() $this->assertEquals(Pluggable::SUPPORTED, $this->getExternalContentElementor($proxy)->getSupportLevel('post', 1)); } + public function testLogNameDiffersFromElementor4ButDataKeyIsUnchanged() + { + $handler = $this->getExternalContentElementor($this->createMock(WordpressFunctionProxyHelper::class)); + $this->assertSame('elementor3', $handler->getLogName()); + $this->assertSame('elementor', $handler->getPluginId()); + } + /** * @dataProvider extractElementorDataProvider */ diff --git a/tests/Smartling/ContentTypes/ExternalContentElementor4Test.php b/tests/Smartling/ContentTypes/ExternalContentElementor4Test.php index 00c05b140..5baa67a49 100644 --- a/tests/Smartling/ContentTypes/ExternalContentElementor4Test.php +++ b/tests/Smartling/ContentTypes/ExternalContentElementor4Test.php @@ -60,9 +60,11 @@ private function mockSubmission(): SubmissionEntity return $submission; } - public function testPluginIdDiffersFromElementor3ForUnambiguousLogs(): void + public function testLogNameDiffersFromElementor3ButDataKeyIsUnchanged(): void { - $this->assertSame('elementor4', $this->getHandler($this->makeProxy(''))->getPluginId()); + $handler = $this->getHandler($this->makeProxy('')); + $this->assertSame('elementor4', $handler->getLogName()); + $this->assertSame('elementor', $handler->getPluginId()); } public function testCanHandle(): void @@ -298,7 +300,7 @@ public function testSetContentFieldsWritesTranslationBackIntoTypedStructure(): v // Translation strings are keyed as {containerId: {widgetId: {settingKey: translatedValue}}} $translation = [ 'meta' => [ExternalContentElementor4::META_FIELD_NAME => json_encode($elementData)], - 'elementor4' => [ + 'elementor' => [ 'container1' => [ 'heading1' => ['title' => 'Translated heading'], ], diff --git a/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php b/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php index e49103449..e44053b7b 100644 --- a/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php +++ b/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php @@ -441,6 +441,24 @@ public function testAjaxPreviewReturnsCountAndValues(): void $this->assertSame(['count' => 1, 'values' => ['Sovi AI']], $result); } + public function testAjaxPreviewDeniedForPostUserCannotEdit(): void + { + $_POST = ['id' => '7', 'metaKey' => '_elementor_data', 'propertyPath' => '$..title.text', 'replacerId' => 'translate']; + $status = null; + $wpProxy = $this->createMock(WordpressFunctionProxyHelper::class); + $wpProxy->method('current_user_can')->willReturnCallback(fn(string $cap): bool => $cap !== 'edit_post'); + $wpProxy->method('sanitize_text_field')->willReturnCallback(fn(string $v): string => $v); + $wpProxy->method('wp_unslash')->willReturnCallback(fn(string $v): string => $v); + $wpProxy->expects($this->never())->method('getPostMeta'); + $wpProxy->method('wp_send_json_error')->willReturnCallback(function ($p, $s = null) use (&$status) { + $status = $s; + }); + + $this->makeController(new JsonFieldRulesManager(), $wpProxy)->ajaxPreview(); + + $this->assertSame(403, $status); + } + public function testImportAndExportRequireCapability(): void { $errors = 0;