diff --git a/CLAUDE.md b/CLAUDE.md index 749e91e54..bfd7dbf41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ This is the **Smartling Connector** WordPress plugin - a translation and localiz - `Submissions/` - Core translation workflow management **Translation Pipeline**: -1. **Upload**: Content serialization → Smartling API upload +1. **Upload**: Content serialization to XML → Smartling API upload 2. **Processing**: Translation occurs in Smartling dashboard 3. **Download**: Completed translations → WordPress content application @@ -90,6 +90,7 @@ This is the **Smartling Connector** WordPress plugin - a translation and localiz ## Development Guidelines ### Code Structure +- PHP language level 8.0 - PSR-0 autoloading with `Smartling\` namespace - Dependency injection throughout the codebase - Extensive use of interfaces for testability diff --git a/inc/Smartling/ContentTypes/ExternalContentJsonRules.php b/inc/Smartling/ContentTypes/ExternalContentJsonRules.php new file mode 100644 index 000000000..05fafedf3 --- /dev/null +++ b/inc/Smartling/ContentTypes/ExternalContentJsonRules.php @@ -0,0 +1,274 @@ +rulesManager->loadData(); + return $this->rulesManager->listItems() === [] + ? Pluggable::NOT_SUPPORTED + : Pluggable::SUPPORTED; + } + + public function getExternalContentTypes(): array + { + return []; + } + + public function getContentFields(SubmissionEntity $submission, bool $raw): array + { + $result = []; + $this->rulesManager->loadData(); + foreach ($this->getRulesByMetaKey() as $metaKey => $rules) { + $json = $this->readMetaJson($submission->getSourceId(), $metaKey); + if ($json === null) { + continue; + } + foreach ($rules as $rule) { + if ($this->parseReplacer($rule->getReplacerId())[0] !== ReplacerFactory::REPLACER_TRANSLATE) { + continue; + } + $matches = $this->safeGet($json, $rule->getPropertyPath()); + foreach ($matches as $index => $value) { + if (is_string($value) && $value !== '') { + $result[$this->buildKey($metaKey, $rule->getPropertyPath(), $index)] = $value; + } + } + } + } + return $result; + } + + public function getRelatedContent(string $contentType, int $contentId): array + { + $result = []; + $this->rulesManager->loadData(); + foreach ($this->getRulesByMetaKey() as $metaKey => $rules) { + $json = $this->readMetaJson($contentId, $metaKey); + if ($json === null) { + continue; + } + foreach ($rules as $rule) { + [$replacer, $hint] = $this->parseReplacer($rule->getReplacerId()); + if ($replacer !== ReplacerFactory::REPLACER_RELATED) { + continue; + } + $referencedType = $hint !== '' ? $hint : ContentTypeHelper::CONTENT_TYPE_UNKNOWN; + foreach ($this->safeGet($json, $rule->getPropertyPath()) as $value) { + if (is_numeric($value) && (int)$value > 0) { + $result[$referencedType][] = (int)$value; + } + } + } + } + foreach ($result as $type => $ids) { + $result[$type] = array_values(array_unique($ids)); + } + return $result; + } + + public function setContentFields(array $original, array $translation, SubmissionEntity $submission): ?array + { + $translations = $translation[$this->getPluginId()] ?? []; + unset($translation[$this->getPluginId()]); + + $this->rulesManager->loadData(); + $changed = false; + foreach ($this->getRulesByMetaKey() as $metaKey => $rules) { + // Prefer a translation already produced by a prior handler over the source. + // JsonRules' edits act as a delta on top of bundled handlers rather than replacing their work. + $sourceJson = $translation['meta'][$metaKey] ?? $original['meta'][$metaKey] ?? null; + if (!is_string($sourceJson) || $sourceJson === '') { + continue; + } + try { + $jsonObject = new JsonObject($sourceJson); + } catch (\Throwable $e) { + $this->getLogger()->debug("Failed to parse meta $metaKey as JSON: " . $e->getMessage()); + continue; + } + $modified = false; + foreach ($rules as $rule) { + [$replacer] = $this->parseReplacer($rule->getReplacerId()); + if ($replacer === ReplacerFactory::REPLACER_TRANSLATE) { + $modified = $this->applyTranslateRule($jsonObject, $rule, $metaKey, $translations) || $modified; + } elseif ($replacer === ReplacerFactory::REPLACER_RELATED) { + $modified = $this->applyRelatedRule($jsonObject, $rule, $submission) || $modified; + } + } + if ($modified) { + $translation['meta'][$metaKey] = $jsonObject->getJson(JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $changed = true; + } + } + + return $changed ? $translation : null; + } + + public function removeUntranslatableFieldsForUpload(array $source, SubmissionEntity $submission): array + { + $this->rulesManager->loadData(); + foreach (array_keys($this->getRulesByMetaKey()) as $metaKey) { + if (isset($source['meta'][$metaKey])) { + unset($source['meta'][$metaKey]); + } + } + return $source; + } + + /** + * @return array + */ + private function getRulesByMetaKey(): array + { + $result = []; + foreach ($this->rulesManager->listItems() as $rule) { + $result[$rule->getMetaKey()][] = $rule; + } + return $result; + } + + private function readMetaJson(int $contentId, string $metaKey): ?array + { + $value = $this->wpProxy->getPostMeta($contentId, $metaKey, true); + 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 safeGet(array $json, string $path): array + { + try { + $result = (new JsonObject($json))->get($path); + } catch (\Throwable $e) { + $this->getLogger()->debug("JsonPath get failed for path=$path: " . $e->getMessage()); + return []; + } + if (!is_array($result)) { + return []; + } + return $result; + } + + private function applyTranslateRule(JsonObject $jsonObject, JsonFieldRule $rule, string $metaKey, array $translations): bool + { + $objects = $jsonObject->getJsonObjects($rule->getPropertyPath()); + if ($objects === false || $objects === null) { + return false; + } + if (!is_array($objects)) { + $objects = [$objects]; + } + $changed = false; + foreach ($objects as $index => $node) { + $key = $this->buildKey($metaKey, $rule->getPropertyPath(), $index); + if (array_key_exists($key, $translations)) { + $ref = &$node->getValue(); + $ref = $translations[$key]; + unset($ref); + $changed = true; + } + } + return $changed; + } + + private function applyRelatedRule(JsonObject $jsonObject, JsonFieldRule $rule, SubmissionEntity $submission): bool + { + try { + $replacer = $this->replacerFactory->getReplacer($rule->getReplacerId()); + } catch (\Throwable $e) { + $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 (!is_array($objects)) { + $objects = [$objects]; + } + $changed = false; + foreach ($objects as $node) { + $ref = &$node->getValue(); + $original = $ref; + if (!is_numeric($original) || (int)$original <= 0) { + unset($ref); + continue; + } + $replaced = $replacer->processAttributeOnDownload($original, $original, $submission); + if ($replaced !== $original) { + $ref = $replaced; + $changed = true; + } + unset($ref); + } + return $changed; + } + + /** + * @return array{0:string,1:string} [replacerId, contentTypeHint] + */ + private function parseReplacer(string $replacerId): array + { + $parts = explode('|', $replacerId, 2); + return [$parts[0], $parts[1] ?? '']; + } + + private function buildKey(string $metaKey, string $path, int $index): string + { + return $metaKey . '|' . $path . '|' . $index; + } +} diff --git a/inc/Smartling/Replacers/ReplacerFactory.php b/inc/Smartling/Replacers/ReplacerFactory.php index 40b42441d..09563f585 100644 --- a/inc/Smartling/Replacers/ReplacerFactory.php +++ b/inc/Smartling/Replacers/ReplacerFactory.php @@ -10,6 +10,7 @@ class ReplacerFactory public const REPLACER_COPY = 'copy'; private const REPLACER_EXCLUDE = 'exclude'; public const REPLACER_RELATED = 'related'; + public const REPLACER_TRANSLATE = 'translate'; private const REPLACER_WP_CORE_IMAGE_INNER_HTML = 'coreImage'; /** @@ -23,6 +24,7 @@ public function __construct(SubmissionManager $submissionManager) self::REPLACER_COPY => new CopyReplacer(), self::REPLACER_EXCLUDE => new ExcludeReplacer(), self::REPLACER_RELATED => new ContentIdReplacer($submissionManager), + self::REPLACER_TRANSLATE => new TranslateReplacer(), self::REPLACER_WP_CORE_IMAGE_INNER_HTML => new ImageInnerHtmlReplacer($submissionManager), ]; } diff --git a/inc/Smartling/Replacers/TranslateReplacer.php b/inc/Smartling/Replacers/TranslateReplacer.php new file mode 100644 index 000000000..92b029d0c --- /dev/null +++ b/inc/Smartling/Replacers/TranslateReplacer.php @@ -0,0 +1,11 @@ +mediaAttachmentRulesManager = $mediaAttachmentRulesManager; - $this->replacerFactory = $replacerFactory; + public function __construct( + private MediaAttachmentRulesManager $mediaAttachmentRulesManager, + private ReplacerFactory $replacerFactory, + private JsonFieldRulesManager $jsonFieldRulesManager, + private PluginInfo $pluginInfo, + private WordpressFunctionProxyHelper $wpProxy, + ) { } public function register(): void @@ -35,6 +39,12 @@ public function register(): void (new ShortcodeForm())->register(); (new FilterForm())->register(); (new MediaRuleForm($this->mediaAttachmentRulesManager, $this->replacerFactory))->register(); + (new VisualConfiguratorPage( + $this->jsonFieldRulesManager, + $this->replacerFactory, + $this->pluginInfo, + $this->wpProxy, + ))->register(); }); } } diff --git a/inc/Smartling/Tuner/JsonFieldRule.php b/inc/Smartling/Tuner/JsonFieldRule.php new file mode 100644 index 000000000..24c4aaf57 --- /dev/null +++ b/inc/Smartling/Tuner/JsonFieldRule.php @@ -0,0 +1,52 @@ +metaKey; + } + + public function getPropertyPath(): string + { + return $this->propertyPath; + } + + public function getReplacerId(): string + { + return $this->replacerId; + } + + public function toArray(): array + { + return [ + 'metaKey' => $this->metaKey, + 'propertyPath' => $this->propertyPath, + 'replacerId' => $this->replacerId, + ]; + } + + public static function fromArray(array $data): self + { + foreach (['metaKey', 'propertyPath', 'replacerId'] as $key) { + if (!array_key_exists($key, $data)) { + throw new \InvalidArgumentException("Missing key in JsonFieldRule array: $key"); + } + } + + return new self( + (string)$data['metaKey'], + (string)$data['propertyPath'], + (string)$data['replacerId'], + ); + } +} diff --git a/inc/Smartling/Tuner/JsonFieldRulesManager.php b/inc/Smartling/Tuner/JsonFieldRulesManager.php new file mode 100644 index 000000000..155a3199e --- /dev/null +++ b/inc/Smartling/Tuner/JsonFieldRulesManager.php @@ -0,0 +1,46 @@ +state)) { + return ''; + } + do { + $id = uniqid('', true); + } while (array_key_exists($id, $this->state)); + $this->state[$id] = $value; + return $id; + } + + /** + * @return JsonFieldRule[] + */ + public function listItems(): array + { + $result = []; + foreach (parent::listItems() as $id => $item) { + try { + $result[$id] = JsonFieldRule::fromArray($item); + } catch (\InvalidArgumentException) { + $this->getLogger()->debug("Unparsable json field rule $id, skipping"); + } + } + return $result; + } +} diff --git a/inc/Smartling/WP/Controller/VisualConfiguratorPage.php b/inc/Smartling/WP/Controller/VisualConfiguratorPage.php new file mode 100644 index 000000000..589dfb11b --- /dev/null +++ b/inc/Smartling/WP/Controller/VisualConfiguratorPage.php @@ -0,0 +1,203 @@ +wpProxy->add_action('admin_menu', [$this, 'menu']); + $this->wpProxy->add_action('network_admin_menu', [$this, 'menu']); + $this->wpProxy->add_action('admin_enqueue_scripts', [$this, 'enqueue']); + $this->wpProxy->add_action('wp_ajax_' . self::ACTION_LIST_RULES, [$this, 'ajaxListRules']); + $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']); + } + + public function menu(): void + { + add_submenu_page( + AdminPage::SLUG, + 'Smartling Visual Configurator', + 'Visual Configurator', + SmartlingUserCapabilities::SMARTLING_CAPABILITY_PROFILE_CAP, + self::SLUG, + [$this, 'pageHandler'], + ); + } + + public function pageHandler(): void + { + $this->renderScript(); + } + + public function enqueue(string $hook): void + { + if (!str_contains($hook, self::SLUG)) { + return; + } + + $handle = $this->pluginInfo->getName() . 'visual-configurator'; + wp_enqueue_script( + $handle, + $this->pluginInfo->getUrl() . 'js/visual-configurator.js', + ['wp-element', 'wp-components', 'wp-api-fetch', 'jquery'], + $this->pluginInfo->getVersion(), + true, + ); + wp_localize_script($handle, 'smartlingVisualConfigurator', [ + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'restRoot' => rest_url('smartling-connector/v2'), + 'nonce' => wp_create_nonce(self::NONCE_ACTION), + 'restNonce' => wp_create_nonce('wp_rest'), + 'replacerOptions' => $this->replacerFactory->getListForUi(), + 'actions' => [ + 'list' => self::ACTION_LIST_RULES, + 'save' => self::ACTION_SAVE_RULE, + 'delete' => self::ACTION_DELETE_RULE, + 'resolveType' => self::ACTION_RESOLVE_TYPE, + ], + ]); + wp_enqueue_style('wp-components'); + } + + public function ajaxListRules(): void + { + $this->verifyNonce(); + $this->rulesManager->loadData(); + $rules = []; + foreach ($this->rulesManager->listItems() as $id => $rule) { + $rules[] = ['id' => $id] + $rule->toArray(); + } + $this->wpProxy->wp_send_json_success(['rules' => $rules]); + } + + public function ajaxSaveRule(): void + { + $this->verifyNonce(); + try { + $payload = $this->readRulePayload(); + } 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'])) + : ''; + + $this->rulesManager->loadData(); + if ($id === '') { + $id = $this->rulesManager->add($data); + if ($id === '') { + $this->wpProxy->wp_send_json_error(['message' => 'Duplicate rule'], 409); + return; + } + } else { + if (!array_key_exists($id, $this->rulesManager->listItems())) { + $this->wpProxy->wp_send_json_error(['message' => 'Rule not found'], 404); + return; + } + $this->rulesManager->updateItem($id, $data); + } + $this->rulesManager->saveData(); + + $this->wpProxy->wp_send_json_success(['rule' => ['id' => $id] + $data]); + } + + public function ajaxResolveType(): void + { + $this->verifyNonce(); + $id = isset($_POST['id']) ? (int)$_POST['id'] : 0; + if ($id <= 0) { + $this->wpProxy->wp_send_json_error(['message' => 'Missing or invalid id'], 400); + return; + } + $type = $this->wpProxy->get_post_type($id); + if ($type === false || $type === '' || $type === null) { + $this->wpProxy->wp_send_json_error(['message' => "No post found with id $id"], 404); + return; + } + $this->wpProxy->wp_send_json_success(['type' => $type]); + } + + public function ajaxDeleteRule(): void + { + $this->verifyNonce(); + $id = isset($_POST['id']) && is_string($_POST['id']) + ? $this->wpProxy->sanitize_text_field($this->wpProxy->wp_unslash($_POST['id'])) + : ''; + if ($id === '') { + $this->wpProxy->wp_send_json_error(['message' => 'Missing id'], 400); + return; + } + + $this->rulesManager->loadData(); + $this->rulesManager->removeItem($id); + $this->rulesManager->saveData(); + + $this->wpProxy->wp_send_json_success(['id' => $id]); + } + + private function verifyNonce(): void + { + $this->wpProxy->check_ajax_referer(self::NONCE_ACTION, '_wpnonce'); + } + + /** + * @return array{metaKey:string,propertyPath:string,replacerId:string} + */ + private function readRulePayload(): array + { + $get = function (string $key): string { + 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"); + } + } + if (strlen($payload['propertyPath']) > 512) { + throw new \InvalidArgumentException('propertyPath exceeds maximum length of 512 characters'); + } + return $payload; + } +} diff --git a/inc/Smartling/WP/View/AdminPage.php b/inc/Smartling/WP/View/AdminPage.php index d5ba2d1ca..cc18d7984 100644 --- a/inc/Smartling/WP/View/AdminPage.php +++ b/inc/Smartling/WP/View/AdminPage.php @@ -4,6 +4,7 @@ use Smartling\WP\Controller\FilterForm; use Smartling\WP\Controller\MediaRuleForm; use Smartling\WP\Controller\ShortcodeForm; +use Smartling\WP\Controller\VisualConfiguratorPage; use Smartling\WP\Table\LocalizationRulesTableWidget; use Smartling\WP\Table\MediaAttachmentTableWidget; use Smartling\WP\Table\ShortcodeTableClass; @@ -69,4 +70,12 @@ + +

Visual Configurator

+

+

+ + + +

diff --git a/inc/Smartling/WP/View/VisualConfiguratorPage.php b/inc/Smartling/WP/View/VisualConfiguratorPage.php new file mode 100644 index 000000000..d7d9a59ff --- /dev/null +++ b/inc/Smartling/WP/View/VisualConfiguratorPage.php @@ -0,0 +1,15 @@ + +
+

+

+ +

+
+ +
diff --git a/inc/config/services.yml b/inc/config/services.yml index d02434887..023faf0ec 100644 --- a/inc/config/services.yml +++ b/inc/config/services.yml @@ -11,6 +11,16 @@ services: arguments: - "%known.attachment.rules%" + json.field.rules.manager: + class: Smartling\Tuner\JsonFieldRulesManager + + content.json.rules: + class: Smartling\ContentTypes\ExternalContentJsonRules + arguments: + - "@json.field.rules.manager" + - "@factory.replacer" + - "@wp.proxy" + persistent.notices.manager: class: Smartling\Helpers\AdminNoticesHelper @@ -261,6 +271,7 @@ services: - ["addHandler", ["@content.elementor4"]] - ["addHandler", ["@content.gravity.forms"]] - ["addHandler", ["@content.yoast"]] + - ["addHandler", ["@content.json.rules"]] manager.job: class: Smartling\Jobs\JobManager @@ -554,6 +565,9 @@ services: arguments: - "@media.attachment.rules.manager" - "@factory.replacer" + - "@json.field.rules.manager" + - "@plugin.info" + - "@wp.proxy" duplicate.submissions.cleaner: class: Smartling\WP\Controller\DuplicateSubmissionsCleaner diff --git a/js/visual-configurator.js b/js/visual-configurator.js new file mode 100644 index 000000000..ca3de47cb --- /dev/null +++ b/js/visual-configurator.js @@ -0,0 +1,441 @@ +/* global wp, jQuery, smartlingVisualConfigurator */ +(function () { + const { render, createElement: el, useState, useEffect, useCallback, Fragment } = wp.element; + const { + Button, + Card, + CardBody, + CardHeader, + Modal, + Notice, + SelectControl, + Spinner, + TextControl, + __experimentalVStack: VStack, + } = wp.components; + + const settings = window.smartlingVisualConfigurator || {}; + const REPLACER_OPTIONS = settings.replacerOptions || {}; + const REFERENCED_CONTENT_TYPES = [ + { label: 'Post-based (attachment)', value: 'attachment' }, + { label: 'Post-based (post)', value: 'postbased' }, + ]; + + function buildReplacerSelect(value, onChange) { + const options = Object.keys(REPLACER_OPTIONS).map((id) => ({ + label: REPLACER_OPTIONS[id], + value: id, + })); + return el(SelectControl, { + label: 'Rule', + value, + options: [{ label: '(none)', value: '' }, ...options], + onChange, + }); + } + + function tryParseJson(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (trimmed === '' || (trimmed[0] !== '{' && trimmed[0] !== '[')) { + return null; + } + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === 'object') return parsed; + } catch (e) { + // not JSON + } + return null; + } + + function joinPath(prefix, segment) { + if (typeof segment === 'number' || /^\d+$/.test(segment)) { + // Emit a wildcard for array indices: clicking one element should produce + // a rule that applies to every sibling at the same position, which is what + // dynamic JSON blobs (Elementor widgets, etc.) need. + return `${prefix}[*]`; + } + if (/^[A-Za-z_][\w]*$/.test(segment)) { + return prefix === '$' ? `$.${segment}` : `${prefix}.${segment}`; + } + return `${prefix}['${segment.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}']`; + } + + function valueIsLeaf(value) { + return value === null || ['string', 'number', 'boolean'].includes(typeof value); + } + + function formatLeaf(value) { + if (typeof value === 'string') { + const trimmed = value.length > 80 ? `${value.slice(0, 80)}…` : value; + return `"${trimmed}"`; + } + return String(value); + } + + function JsonNode({ value, path, metaKey, onAddRule, rulesByPath, depth = 0 }) { + const [expanded, setExpanded] = useState(true); + if (valueIsLeaf(value)) { + const existing = rulesByPath[`${metaKey}|${path}`]; + const isString = typeof value === 'string'; + const isNumeric = typeof value === 'number' || (isString && /^\d+$/.test(value)); + return el( + 'div', + { className: 'svc-leaf', style: { padding: '4px 0 4px 16px', borderLeft: '1px solid #ddd' } }, + el('code', { style: { color: '#0073aa' } }, path), + ' = ', + el('span', { style: { color: '#444' } }, formatLeaf(value)), + ' ', + existing + ? el( + 'span', + { style: { marginLeft: 8, padding: '0 6px', background: '#eef', borderRadius: 4 } }, + 'rule: ', + existing.replacerId, + ) + : el( + Button, + { + variant: 'link', + onClick: () => onAddRule({ path, metaKey, value, isString, isNumeric }), + }, + 'Add rule', + ), + ); + } + const entries = Array.isArray(value) + ? value.map((v, i) => [i, v]) + : Object.entries(value); + return el( + 'div', + { style: { paddingLeft: depth === 0 ? 0 : 16 } }, + el( + Button, + { + variant: 'tertiary', + onClick: () => setExpanded(!expanded), + style: { padding: '0 4px' }, + }, + expanded ? '▾' : '▸', + ' ', + Array.isArray(value) ? `array(${entries.length})` : `object(${entries.length})`, + ), + expanded && + el( + 'div', + { style: { borderLeft: '1px solid #ddd', marginLeft: 4 } }, + entries.map(([k, v]) => + el( + 'div', + { key: String(k) }, + valueIsLeaf(v) + ? null + : el('div', { style: { paddingLeft: 16, color: '#666' } }, String(k), ':'), + el(JsonNode, { + key: String(k), + value: v, + path: joinPath(path, k), + metaKey, + onAddRule, + rulesByPath, + depth: depth + 1, + }), + ), + ), + ), + ); + } + + function MetaField({ name, value, onAddRule, rulesByPath }) { + const parsed = tryParseJson(value); + return el( + Card, + { style: { marginBottom: 12 } }, + el(CardHeader, null, el('strong', null, name), parsed ? ' (JSON)' : ' (plain)'), + el( + CardBody, + null, + parsed + ? el(JsonNode, { + value: parsed, + path: '$', + metaKey: name, + onAddRule, + rulesByPath, + }) + : el( + 'div', + null, + el('code', { style: { color: '#0073aa' } }, name), + ' = ', + el('span', null, formatLeaf(value)), + ' ', + rulesByPath[name] + ? el( + 'span', + { style: { marginLeft: 8, padding: '0 6px', background: '#eef', borderRadius: 4 } }, + 'rule: ', + rulesByPath[name].replacerId, + ) + : el( + Button, + { + variant: 'link', + onClick: () => + onAddRule({ + path: '', + metaKey: name, + value, + isString: typeof value === 'string', + isNumeric: typeof value === 'number' || /^\d+$/.test(String(value)), + }), + }, + 'Add rule', + ), + ), + ), + ); + } + + function RuleEditor({ draft, onCancel, onSave }) { + const [replacerId, setReplacerId] = useState('copy'); + const [refType, setRefType] = useState('attachment'); + if (!draft) return null; + const composedReplacerId = replacerId === 'related' ? `related|${refType}` : replacerId; + return el( + Modal, + { + title: 'Add rule', + onRequestClose: onCancel, + shouldCloseOnClickOutside: false, + style: { maxWidth: 520 }, + }, + el('p', null, + 'Target: ', + el('code', null, draft.metaKey + (draft.path ? ' ' + draft.path : '')), + ), + el(VStack, { spacing: 3 }, + buildReplacerSelect(replacerId, setReplacerId), + replacerId === 'related' + ? el(SelectControl, { + label: 'Referenced content type', + value: refType, + options: REFERENCED_CONTENT_TYPES, + onChange: setRefType, + }) + : null, + el('div', null, + el(Button, { + variant: 'primary', + disabled: !replacerId, + onClick: () => onSave({ + metaKey: draft.metaKey, + propertyPath: draft.path, + replacerId: composedReplacerId, + }), + }, 'Save rule'), + ' ', + el(Button, { variant: 'secondary', onClick: onCancel }, 'Cancel'), + ), + ), + ); + } + + function VisualConfigurator() { + const [contentId, setContentId] = useState(''); + const [content, setContent] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [rules, setRules] = useState([]); + const [draft, setDraft] = useState(null); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + + const refreshRules = useCallback(async () => { + try { + const response = await jQuery.post(settings.ajaxUrl, { + action: settings.actions.list, + _wpnonce: settings.nonce, + }); + if (response && response.success) { + setRules(response.data.rules || []); + } + } catch (e) { + setError('Failed to load rules: ' + (e.message || 'unknown')); + } + }, []); + + useEffect(() => { + refreshRules(); + }, [refreshRules]); + + const loadContent = useCallback(async () => { + if (!contentId) return; + setLoading(true); + setError(''); + setContent(null); + try { + // Resolve the post type from wp_posts so the user doesn't have to pick it. + const typeResp = await jQuery.post(settings.ajaxUrl, { + action: settings.actions.resolveType, + _wpnonce: settings.nonce, + id: contentId, + }); + if (!typeResp || !typeResp.success) { + throw new Error(typeResp?.data?.message || 'Could not resolve post type'); + } + const type = typeResp.data.type; + const url = `${settings.restRoot}/assets/${type}-${contentId}/raw`; + const response = await fetch(url, { + credentials: 'same-origin', + headers: { 'X-WP-Nonce': settings.restNonce }, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + setContent(data); + } catch (e) { + setError('Failed to load content: ' + (e.message || 'unknown')); + } finally { + setLoading(false); + } + }, [contentId]); + + const handleSaveRule = useCallback(async (payload) => { + try { + const response = await jQuery.post(settings.ajaxUrl, { + action: settings.actions.save, + _wpnonce: settings.nonce, + ...payload, + }); + if (!response || !response.success) { + setError(response?.data?.message || 'Save failed'); + return; + } + setDraft(null); + await refreshRules(); + } catch (e) { + setError('Save failed: ' + (e.message || 'unknown')); + } + }, [refreshRules]); + + const handleDeleteRule = useCallback((id) => { + setDeleteConfirmId(id); + }, []); + + const handleConfirmDelete = useCallback(async () => { + const id = deleteConfirmId; + setDeleteConfirmId(null); + try { + await jQuery.post(settings.ajaxUrl, { + action: settings.actions.delete, + _wpnonce: settings.nonce, + id, + }); + await refreshRules(); + } catch (e) { + setError('Delete failed: ' + (e.message || 'unknown')); + } + }, [deleteConfirmId, refreshRules]); + + const rulesByPath = {}; + rules.forEach((r) => { + const key = r.propertyPath ? `${r.metaKey}|${r.propertyPath}` : r.metaKey; + rulesByPath[key] = r; + }); + + return el(Fragment, null, + el(Card, { style: { marginBottom: 16 } }, + el(CardHeader, null, 'Load content sample'), + el(CardBody, null, + el(VStack, { spacing: 3 }, + el(TextControl, { + label: 'Post ID', + help: 'Any post-based content (page, post, attachment, custom post type) — the post type is resolved automatically from wp_posts.', + value: contentId, + onChange: setContentId, + }), + el(Button, { variant: 'primary', onClick: loadContent, disabled: !contentId }, 'Load'), + ), + ), + ), + error ? el(Notice, { status: 'error', isDismissible: false }, error) : null, + loading ? el(Spinner) : null, + content && el(Card, { style: { marginBottom: 16 } }, + el(CardHeader, null, 'Detected meta fields'), + el(CardBody, null, + Object.entries(content.meta || {}).map(([name, value]) => + el(MetaField, { + key: name, + name, + value, + onAddRule: setDraft, + rulesByPath, + }), + ), + ), + ), + el(RuleEditor, { + draft, + onCancel: () => setDraft(null), + onSave: handleSaveRule, + }), + deleteConfirmId !== null && el(Modal, { + title: 'Delete rule', + onRequestClose: () => setDeleteConfirmId(null), + shouldCloseOnClickOutside: true, + }, + el('p', null, 'Are you sure you want to delete this rule?'), + el('div', null, + el(Button, { variant: 'primary', isDestructive: true, onClick: handleConfirmDelete }, 'Delete'), + ' ', + el(Button, { variant: 'secondary', onClick: () => setDeleteConfirmId(null) }, 'Cancel'), + ), + ), + el(Card, null, + el(CardHeader, null, `Saved rules (${rules.length})`), + el(CardBody, null, + rules.length === 0 + ? el('em', null, 'No rules yet.') + : el('table', { className: 'widefat' }, + el('thead', null, + el('tr', null, + el('th', null, 'Meta key'), + el('th', null, 'Path'), + el('th', null, 'Rule'), + el('th', null, ''), + ), + ), + el('tbody', null, + rules.map((r) => + 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.replacerId), + el('td', null, + el(Button, { + variant: 'link', + isDestructive: true, + onClick: () => handleDeleteRule(r.id), + }, 'Delete'), + ), + ), + ), + ), + ), + ), + ), + ); + } + + document.addEventListener('DOMContentLoaded', () => { + const root = document.getElementById('smartling-visual-configurator-root'); + if (!root) return; + if (typeof settings.ajaxUrl === 'undefined') { + root.innerHTML = '

Configurator not configured.

'; + return; + } + render(el(VisualConfigurator), root); + }); +})(); diff --git a/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php b/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php new file mode 100644 index 000000000..26e30651a --- /dev/null +++ b/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php @@ -0,0 +1,290 @@ +mockRulesManager([ + $this->rule('_elementor_data', '$.title', 'translate'), + ]); + $engine = $this->buildEngine($manager); + + $this->assertSame(Pluggable::SUPPORTED, $engine->getSupportLevel('page')); + $this->assertSame(Pluggable::SUPPORTED, $engine->getSupportLevel('post')); + $this->assertSame(Pluggable::SUPPORTED, $engine->getSupportLevel('custom_post_type')); + } + + public function testGetSupportLevelNotSupportedWhenNoRules(): void + { + $manager = $this->mockRulesManager([]); + $engine = $this->buildEngine($manager); + + $this->assertSame(Pluggable::NOT_SUPPORTED, $engine->getSupportLevel('page')); + } + + public function testGetContentFieldsExtractsTranslateStringsOnly(): void + { + $json = json_encode([ + 'elements' => [ + ['settings' => ['title' => 'Hello world', 'id' => 42]], + ['settings' => ['title' => 'Second title', 'id' => 99]], + ], + ]); + $manager = $this->mockRulesManager([ + $this->rule('_elementor_data', '$.elements[*].settings.title', 'translate'), + $this->rule('_elementor_data', '$.elements[*].settings.id', 'related|attachment'), + $this->rule('_elementor_data', '$.foo', 'copy'), + ]); + $wpProxy = $this->createMock(WordpressFunctionProxyHelper::class); + $wpProxy->method('getPostMeta')->willReturn($json); + + $engine = $this->buildEngine($manager, $wpProxy); + $submission = $this->submission('page', 100); + + $result = $engine->getContentFields($submission, false); + + $this->assertCount(2, $result); + $this->assertSame('Hello world', $result['_elementor_data|$.elements[*].settings.title|0']); + $this->assertSame('Second title', $result['_elementor_data|$.elements[*].settings.title|1']); + } + + public function testGetRelatedContentExtractsReferenceIdsGroupedByContentType(): void + { + $json = json_encode([ + 'elements' => [ + ['settings' => ['image' => ['id' => 11]]], + ['settings' => ['image' => ['id' => 22]]], + ['settings' => ['image' => ['id' => 11]]], + ], + ]); + $manager = $this->mockRulesManager([ + $this->rule('_elementor_data', '$.elements[*].settings.image.id', 'related|attachment'), + ]); + $wpProxy = $this->createMock(WordpressFunctionProxyHelper::class); + $wpProxy->method('getPostMeta')->willReturn($json); + + $result = $this->buildEngine($manager, $wpProxy)->getRelatedContent('page', 100); + + $this->assertArrayHasKey('attachment', $result); + $this->assertSame([11, 22], $result['attachment']); + } + + public function testSetContentFieldsAppliesTranslationsAndIdReplacement(): void + { + $sourceJson = json_encode([ + 'elements' => [ + ['settings' => ['title' => 'Hello', 'image' => ['id' => 11]]], + ['settings' => ['title' => 'World', 'image' => ['id' => 22]]], + ], + ]); + $manager = $this->mockRulesManager([ + $this->rule('_elementor_data', '$.elements[*].settings.title', 'translate'), + $this->rule('_elementor_data', '$.elements[*].settings.image.id', 'related|attachment'), + ]); + $submission = $this->submission('page', 100); + + $submissionManager = $this->createMock(SubmissionManager::class); + $submissionManager->method('findOne')->willReturnCallback(function (array $params) { + $remap = [11 => 110, 22 => 220]; + $sourceId = $params[SubmissionEntity::FIELD_SOURCE_ID] ?? null; + if (!isset($remap[$sourceId])) { + return null; + } + $related = $this->createMock(SubmissionEntity::class); + $related->method('getTargetId')->willReturn($remap[$sourceId]); + $related->method('getId')->willReturn(0); + return $related; + }); + + $replacerFactory = new ReplacerFactory($submissionManager); + $wpProxy = $this->createMock(WordpressFunctionProxyHelper::class); + $engine = new ExternalContentJsonRules($manager, $replacerFactory, $wpProxy); + + $translation = [ + ExternalContentJsonRules::PLUGIN_ID => [ + '_elementor_data|$.elements[*].settings.title|0' => 'Hola', + '_elementor_data|$.elements[*].settings.title|1' => 'Mundo', + ], + 'meta' => [], + ]; + $original = ['meta' => ['_elementor_data' => $sourceJson]]; + + $result = $engine->setContentFields($original, $translation, $submission); + + $this->assertIsArray($result); + $this->assertArrayHasKey('meta', $result); + $this->assertArrayHasKey('_elementor_data', $result['meta']); + $this->assertArrayNotHasKey(ExternalContentJsonRules::PLUGIN_ID, $result, 'plugin-id key should be stripped'); + + $decoded = json_decode($result['meta']['_elementor_data'], true); + $this->assertSame('Hola', $decoded['elements'][0]['settings']['title']); + $this->assertSame('Mundo', $decoded['elements'][1]['settings']['title']); + $this->assertSame(110, $decoded['elements'][0]['settings']['image']['id']); + $this->assertSame(220, $decoded['elements'][1]['settings']['image']['id']); + } + + public function testSetContentFieldsPreservesPriorHandlerTranslationsOnSameKey(): void + { + $sourceJson = json_encode([ + 'elements' => [ + ['settings' => ['title' => 'Hello', 'subtitle' => 'World']], + ], + ]); + // Simulates the JSON that an upstream handler wrote into + // $translation['meta']['_elementor_data'] before JsonRules ran: + // BOTH title AND subtitle already translated. + $priorTranslationJson = json_encode([ + 'elements' => [ + ['settings' => ['title' => 'Elementor-Hola', 'subtitle' => 'Elementor-Mundo']], + ], + ]); + $manager = $this->mockRulesManager([ + // JsonRules user configured a rule only for title, not subtitle. + $this->rule('_elementor_data', '$.elements[*].settings.title', 'translate'), + ]); + $submission = $this->submission('page', 100); + $engine = $this->buildEngine($manager); + + $translation = [ + ExternalContentJsonRules::PLUGIN_ID => [ + '_elementor_data|$.elements[*].settings.title|0' => 'JsonRules-Hola', + ], + 'meta' => [ + '_elementor_data' => $priorTranslationJson, + ], + ]; + $original = ['meta' => ['_elementor_data' => $sourceJson]]; + + $result = $engine->setContentFields($original, $translation, $submission); + + $this->assertIsArray($result); + $decoded = json_decode($result['meta']['_elementor_data'], true); + $this->assertSame( + 'JsonRules-Hola', + $decoded['elements'][0]['settings']['title'], + 'JsonRules rule should overwrite the title', + ); + $this->assertSame( + 'Elementor-Mundo', + $decoded['elements'][0]['settings']['subtitle'], + "Prior handler's translation on a path WITHOUT a JsonRules rule must survive", + ); + } + + public function testWildcardArrayIndicesMatchAllOccurrencesAcrossLevels(): void + { + $json = json_encode([ + ['elements' => [ + ['settings' => ['title' => 'A1']], + ['settings' => ['title' => 'A2']], + ]], + ['elements' => [ + ['settings' => ['title' => 'B1']], + ]], + ]); + $manager = $this->mockRulesManager([ + $this->rule('_elementor_data', '$[*].elements[*].settings.title', 'translate'), + ]); + $wpProxy = $this->createMock(WordpressFunctionProxyHelper::class); + $wpProxy->method('getPostMeta')->willReturn($json); + $engine = $this->buildEngine($manager, $wpProxy); + $submission = $this->submission('page', 100); + + $extracted = $engine->getContentFields($submission, false); + $this->assertCount(3, $extracted, 'wildcard at both levels should extract all 3 titles'); + $this->assertContains('A1', $extracted); + $this->assertContains('A2', $extracted); + $this->assertContains('B1', $extracted); + + $translation = [ + ExternalContentJsonRules::PLUGIN_ID => array_combine( + array_keys($extracted), + ['A1-T', 'A2-T', 'B1-T'], + ), + 'meta' => [], + ]; + $original = ['meta' => ['_elementor_data' => $json]]; + + $result = $engine->setContentFields($original, $translation, $submission); + + $this->assertIsArray($result); + $decoded = json_decode($result['meta']['_elementor_data'], true); + $this->assertSame('A1-T', $decoded[0]['elements'][0]['settings']['title']); + $this->assertSame('A2-T', $decoded[0]['elements'][1]['settings']['title']); + $this->assertSame('B1-T', $decoded[1]['elements'][0]['settings']['title']); + } + + public function testRemoveUntranslatableFieldsStripsCoveredMetaKeys(): void + { + $manager = $this->mockRulesManager([ + $this->rule('_elementor_data', '$.x', 'translate'), + ]); + $engine = $this->buildEngine($manager); + + $result = $engine->removeUntranslatableFieldsForUpload([ + 'entity' => ['post_content' => 'preserved'], + 'meta' => ['_elementor_data' => '...', '_other' => 'kept'], + ], $this->submission('page', 100)); + + $this->assertArrayNotHasKey('_elementor_data', $result['meta']); + $this->assertArrayHasKey('_other', $result['meta']); + $this->assertSame('preserved', $result['entity']['post_content']); + } + + private function rule(string $metaKey, string $path, string $replacerId): JsonFieldRule + { + return new JsonFieldRule($metaKey, $path, $replacerId); + } + + /** + * @param JsonFieldRule[] $rules + */ + private function mockRulesManager(array $rules): JsonFieldRulesManager|MockObject + { + $mock = $this->createMock(JsonFieldRulesManager::class); + $mock->method('listItems')->willReturn($rules); + return $mock; + } + + private function buildEngine(JsonFieldRulesManager $manager, ?WordpressFunctionProxyHelper $wpProxy = null): ExternalContentJsonRules + { + $submissionManager = $this->createMock(SubmissionManager::class); + return new ExternalContentJsonRules( + $manager, + new ReplacerFactory($submissionManager), + $wpProxy ?? $this->createMock(WordpressFunctionProxyHelper::class), + ); + } + + private function submission(string $contentType, int $sourceId): SubmissionEntity|MockObject + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getContentType')->willReturn($contentType); + $submission->method('getSourceId')->willReturn($sourceId); + $submission->method('getSourceBlogId')->willReturn(1); + $submission->method('getTargetBlogId')->willReturn(2); + $submission->method('getId')->willReturn(0); + return $submission; + } +} diff --git a/tests/Smartling/Replacers/TranslateReplacerTest.php b/tests/Smartling/Replacers/TranslateReplacerTest.php new file mode 100644 index 000000000..e78f1cadd --- /dev/null +++ b/tests/Smartling/Replacers/TranslateReplacerTest.php @@ -0,0 +1,24 @@ +assertSame('Translate', (new TranslateReplacer())->getLabel()); + } + + public function testUploadPassesValueThrough(): void + { + $this->assertSame('hello', (new TranslateReplacer())->processAttributeOnUpload('hello')); + } + + public function testDownloadUsesTranslatedValue(): void + { + $this->assertSame('hola', (new TranslateReplacer())->processAttributeOnDownload('hello', 'hola', null)); + } +} diff --git a/tests/Smartling/Tuner/JsonFieldRuleTest.php b/tests/Smartling/Tuner/JsonFieldRuleTest.php new file mode 100644 index 000000000..838c1a9c5 --- /dev/null +++ b/tests/Smartling/Tuner/JsonFieldRuleTest.php @@ -0,0 +1,48 @@ +assertSame('_elementor_data', $rule->getMetaKey()); + $this->assertSame('$.elements[*].settings.title', $rule->getPropertyPath()); + $this->assertSame('translate', $rule->getReplacerId()); + } + + public function testToArrayAndFromArray(): void + { + $rule = new JsonFieldRule('_elementor_data', '$.x', 'related|attachment'); + $arr = $rule->toArray(); + $this->assertSame([ + 'metaKey' => '_elementor_data', + 'propertyPath' => '$.x', + 'replacerId' => 'related|attachment', + ], $arr); + $this->assertEquals($rule, JsonFieldRule::fromArray($arr)); + } + + public function testFromArrayMissingKeyThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + JsonFieldRule::fromArray(['metaKey' => '_elementor_data']); + } + + public function testFromArrayIgnoresLegacyContentTypeField(): void + { + $rule = JsonFieldRule::fromArray([ + 'contentType' => 'page', + 'metaKey' => '_elementor_data', + 'propertyPath' => '$.x', + 'replacerId' => 'copy', + ]); + $this->assertSame('_elementor_data', $rule->getMetaKey()); + $this->assertSame('$.x', $rule->getPropertyPath()); + $this->assertSame('copy', $rule->getReplacerId()); + } +} diff --git a/tests/Smartling/Tuner/JsonFieldRulesManagerTest.php b/tests/Smartling/Tuner/JsonFieldRulesManagerTest.php new file mode 100644 index 000000000..2de937c38 --- /dev/null +++ b/tests/Smartling/Tuner/JsonFieldRulesManagerTest.php @@ -0,0 +1,65 @@ +assertSame(JsonFieldRulesManager::STORAGE_KEY, (new JsonFieldRulesManager())->getStorageKey()); + } + + public function testAddAndListItems(): void + { + $m = new JsonFieldRulesManager(); + $id = $m->add([ + 'metaKey' => '_elementor_data', + 'propertyPath' => '$.x', + 'replacerId' => 'copy', + ]); + $this->assertNotSame('', $id); + + $items = $m->listItems(); + $this->assertArrayHasKey($id, $items); + $this->assertInstanceOf(JsonFieldRule::class, $items[$id]); + $this->assertSame('_elementor_data', $items[$id]->getMetaKey()); + $this->assertSame('$.x', $items[$id]->getPropertyPath()); + $this->assertSame('copy', $items[$id]->getReplacerId()); + } + + public function testAddDoesNotDuplicate(): void + { + $m = new JsonFieldRulesManager(); + $data = [ + 'metaKey' => '_elementor_data', + 'propertyPath' => '$.x', + 'replacerId' => 'copy', + ]; + $id1 = $m->add($data); + $id2 = $m->add($data); + $this->assertNotSame('', $id1); + $this->assertSame('', $id2); + $this->assertCount(1, $m->listItems()); + } + + public function testRemoveItem(): void + { + $m = new JsonFieldRulesManager(); + $id = $m->add(['metaKey' => '_elementor_data', 'propertyPath' => '$.a', 'replacerId' => 'copy']); + $this->assertCount(1, $m->listItems()); + + $m->removeItem($id); + + $this->assertCount(0, $m->listItems()); + } +} diff --git a/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php b/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php new file mode 100644 index 000000000..8392295b6 --- /dev/null +++ b/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php @@ -0,0 +1,211 @@ +createMock(JsonFieldRulesManager::class); + $rulesManager->expects($this->once())->method('loadData'); + $rulesManager->method('listItems')->willReturn([ + 'rule-id-1' => new JsonFieldRule('_elementor_data', '$.title', 'translate'), + ]); + + $wpProxy = $this->createWpProxy(); + $wpProxy->expects($this->once()) + ->method('wp_send_json_success') + ->with($this->callback(function ($payload): bool { + return $payload['rules'][0] === [ + 'id' => 'rule-id-1', + 'metaKey' => '_elementor_data', + 'propertyPath' => '$.title', + 'replacerId' => 'translate', + ]; + })); + + $controller = $this->makeController($rulesManager, $wpProxy); + $controller->ajaxListRules(); + } + + public function testAjaxSaveRuleStoresAndReturnsRule(): void + { + $_POST = [ + 'metaKey' => '_elementor_data', + 'propertyPath' => '$.title', + 'replacerId' => 'translate', + ]; + + $rulesManager = new JsonFieldRulesManager(); + $wpProxy = $this->createWpProxy(); + $wpProxy->method('sanitize_text_field')->willReturnCallback(fn(string $v): string => $v); + $wpProxy->method('wp_unslash')->willReturnCallback(fn(string $v): string => $v); + + $savedRule = null; + $wpProxy->method('wp_send_json_success')->willReturnCallback(function (array $payload) use (&$savedRule) { + $savedRule = $payload['rule']; + }); + + $controller = $this->makeController($rulesManager, $wpProxy); + $controller->ajaxSaveRule(); + + $this->assertIsArray($savedRule); + $this->assertSame('_elementor_data', $savedRule['metaKey']); + $this->assertSame('translate', $savedRule['replacerId']); + $this->assertNotEmpty($savedRule['id']); + $this->assertCount(1, $rulesManager->listItems()); + } + + public function testAjaxSaveRuleRejectsMissingFields(): void + { + $_POST = ['metaKey' => '_elementor_data']; + + $wpProxy = $this->createWpProxy(); + $wpProxy->method('sanitize_text_field')->willReturnCallback(fn(string $v): string => $v); + $wpProxy->method('wp_unslash')->willReturnCallback(fn(string $v): string => $v); + + $errorCalled = false; + $wpProxy->method('wp_send_json_error')->willReturnCallback(function ($payload, $status = null) use (&$errorCalled) { + $errorCalled = true; + $this->assertSame(400, $status); + $this->assertStringContainsString('Missing', $payload['message']); + }); + + $this->makeController(new JsonFieldRulesManager(), $wpProxy)->ajaxSaveRule(); + + $this->assertTrue($errorCalled); + } + + public function testAjaxSaveRuleRejectsDuplicate(): void + { + $_POST = [ + 'metaKey' => '_elementor_data', + 'propertyPath' => '$.title', + 'replacerId' => 'translate', + ]; + + $manager = $this->createMock(JsonFieldRulesManager::class); + $manager->method('add')->willReturn(''); + + $wpProxy = $this->createWpProxy(); + $wpProxy->method('sanitize_text_field')->willReturnCallback(fn(string $v): string => $v); + $wpProxy->method('wp_unslash')->willReturnCallback(fn(string $v): string => $v); + + $errorCalled = false; + $wpProxy->method('wp_send_json_error')->willReturnCallback(function ($payload, $status = null) use (&$errorCalled) { + $errorCalled = true; + $this->assertSame(409, $status); + }); + + $this->makeController($manager, $wpProxy)->ajaxSaveRule(); + + $this->assertTrue($errorCalled); + } + + public function testAjaxResolveTypeReturnsPostType(): void + { + $_POST = ['id' => '42']; + + $wpProxy = $this->createWpProxy(); + $wpProxy->method('get_post_type')->with(42)->willReturn('page'); + + $payload = null; + $wpProxy->method('wp_send_json_success')->willReturnCallback(function (array $p) use (&$payload) { + $payload = $p; + }); + + $this->makeController(new JsonFieldRulesManager(), $wpProxy)->ajaxResolveType(); + + $this->assertSame(['type' => 'page'], $payload); + } + + public function testAjaxResolveTypeRejectsMissingId(): void + { + $_POST = []; + $wpProxy = $this->createWpProxy(); + + $errorCalled = false; + $wpProxy->method('wp_send_json_error')->willReturnCallback(function ($p, $status) use (&$errorCalled) { + $errorCalled = true; + $this->assertSame(400, $status); + }); + + $this->makeController(new JsonFieldRulesManager(), $wpProxy)->ajaxResolveType(); + $this->assertTrue($errorCalled); + } + + public function testAjaxResolveTypeReturns404WhenPostMissing(): void + { + $_POST = ['id' => '999999']; + $wpProxy = $this->createWpProxy(); + $wpProxy->method('get_post_type')->willReturn(false); + + $errorCalled = false; + $wpProxy->method('wp_send_json_error')->willReturnCallback(function ($p, $status) use (&$errorCalled) { + $errorCalled = true; + $this->assertSame(404, $status); + }); + + $this->makeController(new JsonFieldRulesManager(), $wpProxy)->ajaxResolveType(); + $this->assertTrue($errorCalled); + } + + public function testAjaxDeleteRuleRemovesItem(): void + { + $manager = new JsonFieldRulesManager(); + $id = $manager->add([ + 'metaKey' => '_elementor_data', + 'propertyPath' => '$.title', + 'replacerId' => 'translate', + ]); + $_POST = ['id' => $id]; + + $wpProxy = $this->createWpProxy(); + $wpProxy->method('sanitize_text_field')->willReturnCallback(fn(string $v): string => $v); + $wpProxy->method('wp_unslash')->willReturnCallback(fn(string $v): string => $v); + $wpProxy->expects($this->once())->method('wp_send_json_success'); + + $this->makeController($manager, $wpProxy)->ajaxDeleteRule(); + + $this->assertCount(0, $manager->listItems()); + } + + private function createWpProxy(): WordpressFunctionProxyHelper|MockObject + { + return $this->createMock(WordpressFunctionProxyHelper::class); + } + + private function makeController(JsonFieldRulesManager $manager, WordpressFunctionProxyHelper $wpProxy): VisualConfiguratorPage + { + $pluginInfo = $this->createMock(PluginInfo::class); + $submissionManager = $this->createMock(SubmissionManager::class); + return new VisualConfiguratorPage( + $manager, + new ReplacerFactory($submissionManager), + $pluginInfo, + $wpProxy, + ); + } +}