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 5ec4b267e..571e0c8d3 100644 --- a/inc/Smartling/ContentTypes/ExternalContentElementor4.php +++ b/inc/Smartling/ContentTypes/ExternalContentElementor4.php @@ -13,4 +13,12 @@ public function getMinVersion(): string { return '4'; } + + /** + * 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 05fafedf3..f00e10c0c 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 @@ -39,6 +44,11 @@ public function getPluginId(): string return self::PLUGIN_ID; } + public function getLogName(): string + { + return self::PLUGIN_ID; + } + public function getPluginPaths(): array { return []; @@ -75,6 +85,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 +104,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 +129,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; } @@ -134,13 +165,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) { @@ -203,8 +237,35 @@ 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($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; + } + } + unset($data); + + return $changed; + } $objects = $jsonObject->getJsonObjects($rule->getPropertyPath()); if ($objects === false || $objects === null) { return false; @@ -225,7 +286,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()); @@ -233,17 +294,36 @@ private function applyRelatedRule(JsonObject $jsonObject, JsonFieldRule $rule, S $this->getLogger()->notice("Unable to resolve replacer {$rule->getReplacerId()}: " . $e->getMessage()); return false; } - $objects = $jsonObject->getJsonObjects($rule->getPropertyPath()); - if ($objects === false || $objects === null) { - return false; + if ($rule->isExtended()) { + $data = &$jsonObject->getValue(); + $changed = false; + 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; + } + $replaced = $replacer->processAttributeOnDownload($original, $original, $submission); + if ($replaced !== $original) { + $changed = $this->matcher->setValue($data, $leaf['segments'], $replaced) || $changed; + } + } + unset($data); + + return $changed; } - 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; @@ -258,6 +338,19 @@ private function applyRelatedRule(JsonObject $jsonObject, JsonFieldRule $rule, S 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] */ @@ -267,6 +360,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/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/Tuner/JsonFieldRule.php b/inc/Smartling/Tuner/JsonFieldRule.php index 24c4aaf57..140333f31 100644 --- a/inc/Smartling/Tuner/JsonFieldRule.php +++ b/inc/Smartling/Tuner/JsonFieldRule.php @@ -4,11 +4,67 @@ 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( private string $metaKey, private string $propertyPath, 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 that match anywhere must use a path in the form $..key.subkey', + ); + } + } + + public function getMatchMode(): string + { + return $this->matchMode; } public function getMetaKey(): string @@ -26,13 +82,53 @@ 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->matchMode === self::MATCH_ANYWHERE; + } + + /** + * @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->isExtended()) { + $result['matchMode'] = self::MATCH_ANYWHERE; + } + 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 +143,43 @@ 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'] : [], + (string)($data['matchMode'] ?? ''), ); } + + /** + * @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..ae8cd4ba1 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,29 +208,142 @@ 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 { + $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]); + } + foreach (['widgetType', 'matchMode'] as $key) { + $data[$key] = isset($_POST[$key]) && is_string($_POST[$key]) ? $this->wpProxy->wp_unslash($_POST[$key]) : ''; + } + 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'); + } + $data['conditions'] = $decoded; + } + + return $this->buildRule($data); + } + + /** + * 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]); } } - if (strlen($payload['propertyPath']) > 512) { - throw new \InvalidArgumentException('propertyPath exceeds maximum length of 512 characters'); + $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; + } + // 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; } - return $payload; + 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..d7a477e8c 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,84 @@ ); } - 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]) => { + // 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 }); + } + }); + } + 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, mode]); + + 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), + matchMode: mode, + }); + 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 +326,71 @@ 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), + matchMode: mode, }), }, 'Save rule'), ' ', @@ -249,7 +407,15 @@ 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 () => { try { @@ -339,6 +505,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; @@ -370,14 +577,16 @@ key: name, name, value, - onAddRule: setDraft, + onAddRule: openDraft, rulesByPath, }), ), ), ), el(RuleEditor, { + key: draftSeq, draft, + contentId, onCancel: () => setDraft(null), onSave: handleSaveRule, }), @@ -393,8 +602,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 +632,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 +643,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/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 83402ff7d..5baa67a49 100644 --- a/tests/Smartling/ContentTypes/ExternalContentElementor4Test.php +++ b/tests/Smartling/ContentTypes/ExternalContentElementor4Test.php @@ -60,6 +60,13 @@ private function mockSubmission(): SubmissionEntity return $submission; } + public function testLogNameDiffersFromElementor3ButDataKeyIsUnchanged(): void + { + $handler = $this->getHandler($this->makeProxy('')); + $this->assertSame('elementor4', $handler->getLogName()); + $this->assertSame('elementor', $handler->getPluginId()); + } + public function testCanHandle(): void { $proxy = $this->createMock(WordpressFunctionProxyHelper::class); diff --git a/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php b/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php index 26e30651a..c90a0eda7 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,197 @@ 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']); + } + + 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']); + } + + 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); diff --git a/tests/Smartling/Tuner/JsonFieldRuleTest.php b/tests/Smartling/Tuner/JsonFieldRuleTest.php index 838c1a9c5..39d6a549a 100644 --- a/tests/Smartling/Tuner/JsonFieldRuleTest.php +++ b/tests/Smartling/Tuner/JsonFieldRuleTest.php @@ -45,4 +45,99 @@ 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']]], + ]; + } + + 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 2de937c38..723bc8cbb 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', 'matchMode' => 'anywhere', '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..e44053b7b 100644 --- a/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php +++ b/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php @@ -221,6 +221,259 @@ 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 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(); + $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', 'matchMode' => 'anywhere', '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 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; + $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);