Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
281 changes: 281 additions & 0 deletions inc/Smartling/ContentTypes/ExternalContentJsonRules.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
<?php

namespace Smartling\ContentTypes;

use Smartling\Extensions\Pluggable;
use Smartling\Helpers\LoggerSafeTrait;
use Smartling\Helpers\WordpressFunctionProxyHelper;
use Smartling\Replacers\ReplacerFactory;
use Smartling\Submissions\SubmissionEntity;
use Smartling\Tuner\JsonFieldRule;
use Smartling\Tuner\JsonFieldRulesManager;
use Smartling\Vendor\JsonPath\JsonObject;

class ExternalContentJsonRules implements ContentTypeModifyingInterface
{
use LoggerSafeTrait;

public const PLUGIN_ID = 'json-rules';

public function __construct(
private JsonFieldRulesManager $rulesManager,
private ReplacerFactory $replacerFactory,
private WordpressFunctionProxyHelper $wpProxy,
) {
}

public function getMaxVersion(): string
{
return '99';
}

public function getMinVersion(): string
{
return '0';
}

public function getPluginId(): string
{
return self::PLUGIN_ID;
}

public function getPluginPaths(): array
{
return [];
}

public function getPluginSupportLevel(): string
{
return Pluggable::SUPPORTED;
}

public function getSupportLevel(string $contentType, ?int $contentId = null): string
{
$this->rulesManager->loadData();
foreach ($this->rulesManager->listItems() as $rule) {
if ($rule->getContentType() === '*' || $rule->getContentType() === $contentType) {
return Pluggable::SUPPORTED;
}
}
return Pluggable::NOT_SUPPORTED;
}

public function getExternalContentTypes(): array
{
return [];
}

public function getContentFields(SubmissionEntity $submission, bool $raw): array
{
$result = [];
$this->rulesManager->loadData();
foreach ($this->getRulesByMetaKey($submission->getContentType()) 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($contentType) 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($submission->getContentType()) as $metaKey => $rules) {
// Prefer a translation already produced by a prior handler (e.g. Elementor) 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($submission->getContentType())) as $metaKey) {
if (isset($source['meta'][$metaKey])) {
unset($source['meta'][$metaKey]);
}
}
return $source;
}

/**
* @return array<string, JsonFieldRule[]>
*/
private function getRulesByMetaKey(string $contentType): array
{
$result = [];
foreach ($this->rulesManager->listItems() as $rule) {
if ($rule->getContentType() !== '*' && $rule->getContentType() !== $contentType) {
continue;
}
$result[$rule->getMetaKey()][] = $rule;
}
return $result;
}

private function readMetaJson(int $contentId, string $metaKey): ?string
{
$value = $this->wpProxy->getPostMeta($contentId, $metaKey, true);
if (!is_string($value) || $value === '') {
return null;
}
try {
json_decode($value, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
return $value;
}

private function safeGet(string $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) {
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) {
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;
}
}
2 changes: 2 additions & 0 deletions inc/Smartling/Replacers/ReplacerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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),
];
}
Expand Down
11 changes: 11 additions & 0 deletions inc/Smartling/Replacers/TranslateReplacer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Smartling\Replacers;

class TranslateReplacer extends DoNothingContentReplacer
{
public function getLabel(): string
{
return 'Translate';
}
}
24 changes: 17 additions & 7 deletions inc/Smartling/Services/SmartlingFilterUiService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,29 @@

namespace Smartling\Services;

use Smartling\Helpers\PluginInfo;
use Smartling\Helpers\WordpressFunctionProxyHelper;
use Smartling\Replacers\ReplacerFactory;
use Smartling\Tuner\FilterManager;
use Smartling\Tuner\JsonFieldRulesManager;
use Smartling\Tuner\MediaAttachmentRulesManager;
use Smartling\Tuner\ShortcodeManager;
use Smartling\WP\Controller\AdminPage;
use Smartling\WP\Controller\FilterForm;
use Smartling\WP\Controller\MediaRuleForm;
use Smartling\WP\Controller\ShortcodeForm;
use Smartling\WP\Controller\VisualConfiguratorPage;
use Smartling\WP\WPHookInterface;

class SmartlingFilterUiService implements WPHookInterface
{
private MediaAttachmentRulesManager $mediaAttachmentRulesManager;
private ReplacerFactory $replacerFactory;

public function __construct(MediaAttachmentRulesManager $mediaAttachmentRulesManager, ReplacerFactory $replacerFactory)
{
$this->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
Expand All @@ -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();
});
}
}
Expand Down
Loading