diff --git a/CHANGELOG.md b/CHANGELOG.md
index a914013..0a2d1da 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,31 @@
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+## [1.11.0]
+### Features
+- ACF blocks in Gutenberg: field data stored in the block-comment JSON of `acf/*` blocks (`attrs.data`) is now translated automatically, including repeater/group sub-keys (`slides_0_caption`). Short values are batched into a single AI call; the client translation workflow exposes ACF block fields as their own units (`acf_block:{path}:{key}`).
+- ACF options pages: new MCP ability `ai-translate/translate-options` translates ACF fields on options pages (global content such as footer texts or CTAs). WPGlobus/WP Multilang write inline language markup; Polylang-style per-language storage is opt-in via the `slytranslate_acf_options_post_id` filter; TranslatePress reports `not_required` because its string table already covers option output.
+- Meta-key exclusion list: new `slytranslate_meta_keys_exclude` option (whitespace-separated, also exposed through the `configure` ability as `meta_keys_exclude`) excludes keys from translation uniformly across manual, SEO-detected, and field-plugin-resolved keys. Implemented as a priority-5 callback on `slytranslate_translate_meta_key`, so third-party filters can still override it.
+- Per-field opt-out in the ACF field editor: a new "Exclude from AI translation" toggle (`slytranslate_exclude`) in every ACF field definition skips that field during automatic translation.
+- Structured field types: ACF `link` fields are now translated selectively — only the `title` sub-key is sent to the model, `url`/`target` are copied unchanged. Extensible via the new `slytranslate_meta_value_translation_spec` filter.
+- Meta Box (metabox.io) support: `text`, `textarea`, and `wysiwyg` fields are detected via the field registry and translated automatically (filter: `slytranslate_metabox_translatable_field_types`).
+- Pods support: `text`, `paragraph`, and `wysiwyg` fields are detected via the pod definition and translated automatically (filter: `slytranslate_pods_translatable_field_types`).
+- New introspection helper `MetaTranslationService::describe_effective_meta_keys()` reports every effective meta key with its source (`acf`/`seo`/`manual`/`filter`), ACF field label/type, and exclusion state — groundwork for the settings UI and a future `get-translatable-fields` ability.
+- Taxonomy-term translation (opt-in via `translate_terms`): when a translated post references terms without a target-language translation, Polylang setups now create and link AI-translated terms instead of silently dropping categories/tags. New MCP ability `ai-translate/translate-terms` bulk-translates whole taxonomies for existing sites (with `dry_run` preview).
+- Image alt texts: `_wp_attachment_image_alt` is now translated by default (new `default` source in the effective meta-key resolution; the exclusion list and filter API can veto it). Polylang media duplication (`pll_translate_media`) translates alt text, caption, and description of the duplicated attachment, and post translation back-fills the featured image's alt text on the target attachment when it is still empty.
+- Background processing for bulk jobs: new `TranslationQueue` runs one post per scheduled action — Action Scheduler when available, WP-Cron single events otherwise. `translate-content-bulk` accepts `background=true` and returns a `job_id`; `get-progress` and `cancel-translation` accept `job_id` to poll and cancel queue jobs. Jobs survive closed tabs and PHP timeouts; the browser-driven REST path remains the fallback for sites without a working cron.
+- Auto-translate on publish: the previously unused `slytranslate_new_post` option is now consumed — publishing a source-language post queues draft translations for all missing target languages (never auto-published; requires the background queue). Plugin-created translations carry a `_slytranslate_generated` marker as loop guard.
+- Glossary / do-not-translate list: new `slytranslate_glossary` option (exposed via `configure` as `glossary`) with `keep` entries (never translate) and `translate` entries (fixed per-language translations). Injected into every prompt as a compact block; large glossaries are pre-filtered to terms present in the source text.
+- Slug translation (opt-in via `translate_slugs`): slugs of newly created translations are derived from the translated title via `sanitize_title()` + `wp_unique_post_slug()` — no extra model call. Existing translation slugs are never touched unless they are still the auto-generated stub.
+- Diff-based retranslation: overwriting an existing translation now hashes source blocks and meta values (`_slytranslate_unit_hashes` / `_slytranslate_meta_hashes` on the target) and only sends changed blocks/keys through the model; unchanged parts are reused from the existing translation. Falls back to full retranslation when the block structure diverged. The shared fingerprint helper `TranslationFingerprint` also backs the client workflow's stale-source detection.
+- New readonly MCP ability `ai-translate/get-translatable-fields`: reports which meta fields a `translate-content` call would translate or clear, including per-key source attribution (`manual`/`seo`/`acf`/`default`/`filter`), exclusion state, detected SEO plugin, and active field-plugin resolvers.
+- Admin settings page (Settings → SlyTranslate): the first in-WordPress configuration surface. Status row (detected language/SEO/field plugins, AI-client availability, effective model), model picker with refresh, site-wide prompt add-on, glossary editor, meta-field list with per-key exclusion checkboxes (fed by `get-translatable-fields`, including a per-post preview), automation toggles, and a collapsed Advanced section (prompt template with reset, context-window override, direct-API URL with probe status, string-table concurrency with a "Test concurrency" probe shown only for string-table adapters, transport diagnostics). Saves through the same `ConfigurationService::save()` path as the MCP `configure` ability — probe side effects and SSRF validation apply identically. New REST route `ai-translate/probe-string-table-concurrency/run` exposes the concurrency probe to the UI.
+- The settings page and the ACF field-editor toggle ship with German translations (`de_DE`); the settings app receives its strings server-side via `wp_localize_script`, so no JS translation files are needed.
+
+### Changes
+- Shared ACF introspection extracted into `AcfFieldIntrospector`; `AcfMetaResolver` is now a thin consumer of it. The `slytranslate_acf_translatable_field_types` filter is unchanged.
+- `WpglobusAdapter` and `WpMultilangAdapter` expose a public `merge_language_variant()` mutation helper used by the options translation service.
+
## [1.10.0]
### Features
- ACF (Advanced Custom Fields) support: translatable `text`, `textarea`, and `wysiwyg` fields are now detected and translated automatically — no configuration required when ACF is active.
diff --git a/README.md b/README.md
index 22816a0..b885d5a 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ It works with any LLM available through a WordPress AI connector and natively su
- Translates selected text or entire Gutenberg blocks inline, without leaving the editor
- Exposes the same functionality as MCP abilities, so external LLM tools (Claude Code, Codex, and others) can drive translations programmatically
- Carries SEO metadata (title, description) through the same translation workflow as the post content
-- Translates Advanced Custom Fields (ACF) `text`, `textarea`, and `wysiwyg` fields automatically — no configuration required when ACF is active
+- Translates custom fields from ACF (including ACF blocks and options pages), Meta Box, and Pods automatically — no configuration required when the field plugin is active
- Handles long and structured content with chunking and output validation
- Supports model-specific profiles that tune prompt style and retry behavior for known model families
@@ -130,9 +130,11 @@ In the MCP workflow the LLM Wrapper box at the top also acts as the translation
| `ai-translate/translate-text` | Translate arbitrary text |
| `ai-translate/translate-blocks` | Translate serialized Gutenberg blocks |
| `ai-translate/translate-content` | Create or update one translated post/page/CPT entry |
-| `ai-translate/translate-content-bulk` | Bulk-translate multiple entries |
-| `ai-translate/get-progress` | Return live progress for a running translation |
-| `ai-translate/cancel-translation` | Cancel a running translation |
+| `ai-translate/translate-content-bulk` | Bulk-translate multiple entries (`background=true` queues the batch via Action Scheduler/WP-Cron) |
+| `ai-translate/translate-terms` | Bulk-translate taxonomy terms and link them as translations (Polylang only) |
+| `ai-translate/get-translatable-fields` | Introspect which meta fields a translation would translate or clear, with source attribution |
+| `ai-translate/get-progress` | Return live progress for a running translation or a queued background job (`job_id`) |
+| `ai-translate/cancel-translation` | Cancel a running translation or a queued background job (`job_id`) |
| `ai-translate/get-available-models` | List models from configured connectors |
| `ai-translate/save-additional-prompt` | Save per-user additional instructions |
| `ai-translate/configure` | Read or update persistent plugin settings |
@@ -170,7 +172,9 @@ For reliable results in agent workflows:
**Field plugins** (custom fields translated alongside content)
-- Advanced Custom Fields (ACF) — Free and Pro, including Repeater and Flexible Content
+- Advanced Custom Fields (ACF) — Free and Pro, including Repeater and Flexible Content, ACF blocks in Gutenberg, and options pages
+- Meta Box (metabox.io)
+- Pods
**SEO plugins** (metadata translated alongside content)
@@ -238,7 +242,11 @@ add_filter( 'slytranslate_acf_translatable_field_types', function ( $types ) {
} );
```
-To exclude a specific field or add one that is not ACF-registered, use the `slytranslate_translate_meta_key` filter:
+To exclude fields there are three options, from simplest to most flexible:
+
+1. **ACF field editor** — enable the "Exclude from AI translation" toggle in the field's settings.
+2. **Exclusion list** — add the meta keys (whitespace-separated) to the `meta_keys_exclude` setting (via the `configure` MCP ability or the `slytranslate_meta_keys_exclude` option). Works for any meta key, not just ACF.
+3. **Filter API** — use the `slytranslate_translate_meta_key` filter, which has the final say:
```php
add_filter( 'slytranslate_translate_meta_key', function ( $translate, $meta_key ) {
@@ -249,6 +257,21 @@ add_filter( 'slytranslate_translate_meta_key', function ( $translate, $meta_key
}, 10, 2 );
```
+**Are ACF blocks and options pages covered?**
+Yes. Field data stored inside `acf/*` Gutenberg blocks is translated automatically along with the post content, including repeater sub-fields. ACF `link` fields translate only the link title — URL and target are never sent to the model.
+
+ACF fields on options pages (global content such as footer texts or CTAs) are translated through the `ai-translate/translate-options` MCP ability. With WPGlobus or WP Multilang the translated variant is merged into the option value; with TranslatePress nothing needs to happen (its string table already translates option output). Polylang has no language-aware option storage — opt in to per-language storage via the `slytranslate_acf_options_post_id` filter:
+
+```php
+// Polylang pattern: store translated options under options_{lang}
+add_filter( 'slytranslate_acf_options_post_id', function ( $post_id, $target_language ) {
+ return 'options_' . $target_language;
+}, 10, 2 );
+```
+
+**Do Meta Box or Pods fields get translated automatically?**
+Yes. Translatable field types (`text`, `textarea`, `wysiwyg` for Meta Box; `text`, `paragraph`, `wysiwyg` for Pods) are detected through each plugin's field registry. The type lists are extensible via the `slytranslate_metabox_translatable_field_types` and `slytranslate_pods_translatable_field_types` filters.
+
**Does this work without a language plugin?**
Yes, for inline text and block translation. Content translation workflows (full post/page) require a supported language plugin.
@@ -268,7 +291,7 @@ In the list-table dialog, overwrite is off by default. If a translation already
Yes, when using Polylang. Call `ai-translate/set-post-language` with `post_id` and `target_language`. Use `force` to bypass conflict checks and `relink=true` to rewrite translation relations. Not available in WP Multilang mode.
**How do I control the prompt and translation style?**
-Use `ai-translate/configure` for persistent defaults. Pass `additional_prompt` on any `translate-*` call for per-request instructions.
+Use the settings page (Settings → SlyTranslate) or `ai-translate/configure` for persistent defaults — both write through the same backend. Pass `additional_prompt` on any `translate-*` call for per-request instructions.
**Why does `execute-ability` fail even when discovery looks correct?**
Some external WordPress MCP adapter wrappers expose a flatter `execute-ability` signature. If `discover-abilities` shows the correct SlyTranslate schema but `execute-ability` still errors about a missing `parameters` wrapper, investigate the external adapter layer — SlyTranslate controls ability names, descriptions, and schemas, not the wrapper surface.
diff --git a/slytranslate/assets/settings-page.js b/slytranslate/assets/settings-page.js
new file mode 100644
index 0000000..cf9cfa0
--- /dev/null
+++ b/slytranslate/assets/settings-page.js
@@ -0,0 +1,584 @@
+/**
+ * SlyTranslate settings page (Settings → SlyTranslate).
+ *
+ * Small wp.element app without a build step, mirroring the other plugin
+ * assets. All reads and writes go through the ai-translate/configure REST
+ * bridge so the UI shares one backend with the MCP configure ability —
+ * including the direct-API probe side effects on save.
+ */
+(function () {
+ 'use strict';
+
+ var wp = window.wp;
+ if (!wp || !wp.element || !wp.components || !wp.apiFetch) {
+ return;
+ }
+
+ var bootstrap = window.slyTranslateSettings || {};
+ var el = wp.element.createElement;
+ var Fragment = wp.element.Fragment;
+ var useState = wp.element.useState;
+ var useEffect = wp.element.useEffect;
+ var apiFetch = wp.apiFetch;
+ // Strings are translated server-side and passed via wp_localize_script
+ // (same pattern as editor-plugin.js) — no JS translation files needed.
+ var coreI18n = wp.i18n && wp.i18n.__ ? wp.i18n.__ : function (text) { return text; };
+ var localizedStrings = bootstrap.strings || {};
+ var __ = function (text) {
+ return localizedStrings[text] || coreI18n(text, 'slytranslate');
+ };
+ var components = wp.components;
+ var Button = components.Button;
+ var CheckboxControl = components.CheckboxControl;
+ var Notice = components.Notice;
+ var PanelBody = components.PanelBody;
+ var SelectControl = components.SelectControl;
+ var Snackbar = components.Snackbar;
+ var Spinner = components.Spinner;
+ var TextControl = components.TextControl;
+ var TextareaControl = components.TextareaControl;
+ var ToggleControl = components.ToggleControl;
+
+ var BASE = bootstrap.abilitiesRunBasePath || '/ai-translate/v1/';
+
+ function callAbility(name, input) {
+ return apiFetch({
+ path: BASE + 'ai-translate/' + name + '/run',
+ method: 'POST',
+ data: { input: input || {} },
+ });
+ }
+
+ function splitKeys(value) {
+ return String(value || '').split(/\s+/).filter(function (key) { return key !== ''; });
+ }
+
+ /* ---------------------------------------------------------------
+ * Glossary helpers: option entries ↔ editable rows
+ * ------------------------------------------------------------- */
+
+ function glossaryEntriesToRows(entries) {
+ return (entries || []).map(function (entry) {
+ var toText = Object.keys(entry.to || {}).map(function (code) {
+ return code + '=' + entry.to[code];
+ }).join(', ');
+ return { term: entry.term || '', mode: entry.mode || 'keep', toText: toText };
+ });
+ }
+
+ function glossaryRowsToEntries(rows) {
+ return rows.map(function (row) {
+ var to = {};
+ String(row.toText || '').split(/[,;]/).forEach(function (pair) {
+ var idx = pair.indexOf('=');
+ if (idx > 0) {
+ var code = pair.slice(0, idx).trim().toLowerCase();
+ var translation = pair.slice(idx + 1).trim();
+ if (code && translation) {
+ to[code] = translation;
+ }
+ }
+ });
+ return { term: String(row.term || '').trim(), mode: row.mode === 'translate' ? 'translate' : 'keep', to: to };
+ }).filter(function (entry) {
+ return entry.term !== '' && (entry.mode === 'keep' || Object.keys(entry.to).length > 0);
+ });
+ }
+
+ /* ---------------------------------------------------------------
+ * Sections
+ * ------------------------------------------------------------- */
+
+ function StatusRow(props) {
+ var ok = props.ok;
+ return el('span', { style: { marginRight: '24px', display: 'inline-block' } },
+ el('span', { style: { color: ok ? '#00a32a' : '#996800', marginRight: '4px' } }, ok ? '✓' : '—'),
+ props.label
+ );
+ }
+
+ function sourceLabel(field) {
+ if (field.source === 'acf') {
+ var detail = field.field_label || field.key;
+ return 'ACF: ' + detail + (field.field_type ? ' (' + field.field_type + ')' : '');
+ }
+ if (field.source === 'seo') {
+ return __('SEO plugin', 'slytranslate');
+ }
+ if (field.source === 'default') {
+ return __('Built-in default', 'slytranslate');
+ }
+ if (field.source === 'filter') {
+ return __('Filter API', 'slytranslate');
+ }
+ return __('Manual key', 'slytranslate');
+ }
+
+ function SettingsApp() {
+ var settingsState = useState(null);
+ var settings = settingsState[0];
+ var setSettings = settingsState[1];
+
+ var modelsState = useState([]);
+ var models = modelsState[0];
+ var setModels = modelsState[1];
+
+ var fieldsState = useState(null);
+ var fieldsReport = fieldsState[0];
+ var setFieldsReport = fieldsState[1];
+
+ var excludeState = useState([]);
+ var excludedKeys = excludeState[0];
+ var setExcludedKeys = excludeState[1];
+
+ var glossaryState = useState([]);
+ var glossaryRows = glossaryState[0];
+ var setGlossaryRows = glossaryState[1];
+
+ var previewPostState = useState('');
+ var previewPostId = previewPostState[0];
+ var setPreviewPostId = previewPostState[1];
+
+ var busyState = useState(false);
+ var busy = busyState[0];
+ var setBusy = busyState[1];
+
+ var noticeState = useState(null);
+ var notice = noticeState[0];
+ var setNotice = noticeState[1];
+
+ var snackbarState = useState('');
+ var snackbar = snackbarState[0];
+ var setSnackbar = snackbarState[1];
+
+ var probeState = useState(null);
+ var probeResult = probeState[0];
+ var setProbeResult = probeState[1];
+
+ function applyPayload(payload) {
+ setSettings(payload);
+ setExcludedKeys(splitKeys(payload.meta_keys_exclude));
+ setGlossaryRows(glossaryEntriesToRows(payload.glossary));
+ }
+
+ function loadFieldsReport(postId) {
+ return callAbility('get-translatable-fields', postId > 0 ? { post_id: postId } : {})
+ .then(setFieldsReport)
+ .catch(function () { setFieldsReport(null); });
+ }
+
+ useEffect(function () {
+ callAbility('configure', {})
+ .then(applyPayload)
+ .catch(function (error) {
+ setNotice((error && error.message) || __('Failed to load settings.', 'slytranslate'));
+ });
+ callAbility('get-available-models', {})
+ .then(function (response) { setModels(response.models || []); })
+ .catch(function () { setModels([]); });
+ loadFieldsReport(0);
+ }, []);
+
+ function updateSetting(key, value) {
+ var next = {};
+ Object.keys(settings).forEach(function (k) { next[k] = settings[k]; });
+ next[key] = value;
+ setSettings(next);
+ }
+
+ function toggleExclusion(key, active) {
+ setExcludedKeys(function (current) {
+ var without = current.filter(function (k) { return k !== key; });
+ return active ? without : without.concat([key]);
+ });
+ }
+
+ function save() {
+ setBusy(true);
+ setNotice(null);
+ callAbility('configure', {
+ model_slug: settings.model_slug,
+ prompt_template: settings.prompt_template,
+ prompt_addon: settings.prompt_addon,
+ meta_keys_translate: settings.meta_keys_translate,
+ meta_keys_clear: settings.meta_keys_clear,
+ meta_keys_exclude: excludedKeys.join(' '),
+ auto_translate_new: !!settings.auto_translate_new,
+ translate_terms: !!settings.translate_terms,
+ translate_slugs: !!settings.translate_slugs,
+ glossary: glossaryRowsToEntries(glossaryRows),
+ context_window_tokens: parseInt(settings.context_window_tokens, 10) || 0,
+ direct_api_url: settings.direct_api_url,
+ string_table_concurrency: parseInt(settings.string_table_concurrency, 10) || 1,
+ }).then(function (payload) {
+ applyPayload(payload);
+ setSnackbar(__('Settings saved.', 'slytranslate'));
+ window.setTimeout(function () { setSnackbar(''); }, 4000);
+ return loadFieldsReport(parseInt(previewPostId, 10) || 0);
+ }).catch(function (error) {
+ setNotice((error && error.message) || __('Saving failed.', 'slytranslate'));
+ }).then(function () {
+ setBusy(false);
+ });
+ }
+
+ function refreshModels() {
+ setBusy(true);
+ callAbility('get-available-models', { refresh: true })
+ .then(function (response) { setModels(response.models || []); })
+ .catch(function () { })
+ .then(function () { setBusy(false); });
+ }
+
+ function probeConcurrency() {
+ setBusy(true);
+ setProbeResult(null);
+ callAbility('probe-string-table-concurrency', {})
+ .then(setProbeResult)
+ .catch(function (error) {
+ setNotice((error && error.message) || __('Concurrency probe failed.', 'slytranslate'));
+ })
+ .then(function () { setBusy(false); });
+ }
+
+ if (!settings) {
+ return el('div', null,
+ notice ? el(Notice, { status: 'error', isDismissible: false }, notice) : el(Spinner, null)
+ );
+ }
+
+ var modelOptions = [{ label: __('Connector default', 'slytranslate'), value: '' }].concat(
+ models.map(function (model) { return { label: model.label, value: model.value }; })
+ );
+ // Keep an unknown configured slug selectable instead of silently jumping.
+ if (settings.model_slug && !models.some(function (m) { return m.value === settings.model_slug; })) {
+ modelOptions.push({ label: settings.model_slug, value: settings.model_slug });
+ }
+
+ var translateFields = (fieldsReport && fieldsReport.fields || []).filter(function (f) { return f.action === 'translate'; });
+ var clearFields = (fieldsReport && fieldsReport.fields || []).filter(function (f) { return f.action === 'clear'; });
+
+ return el(Fragment, null,
+ notice ? el(Notice, {
+ status: 'error',
+ onRemove: function () { setNotice(null); },
+ }, notice) : null,
+
+ /* ---- Status ---- */
+ el('div', { className: 'card', style: { maxWidth: '720px', padding: '12px 16px', margin: '16px 0' } },
+ el('h2', { style: { marginTop: 0 } }, __('Status', 'slytranslate')),
+ el('div', null,
+ el(StatusRow, {
+ ok: !!bootstrap.languagePlugin,
+ label: bootstrap.languagePlugin
+ ? bootstrap.languagePlugin + ' ' + __('detected', 'slytranslate')
+ : __('No language plugin detected', 'slytranslate'),
+ }),
+ el(StatusRow, {
+ ok: !!settings.detected_seo_plugin,
+ label: settings.detected_seo_plugin
+ ? (settings.detected_seo_plugin_label || settings.detected_seo_plugin) + ' ' + __('detected', 'slytranslate')
+ : __('No SEO plugin detected', 'slytranslate'),
+ })
+ ),
+ el('div', null,
+ el(StatusRow, {
+ ok: !!bootstrap.serverTranslationAvailable,
+ label: bootstrap.serverTranslationAvailable
+ ? __('AI client available', 'slytranslate')
+ : __('AI client missing — install an AI connector for in-WordPress translation', 'slytranslate'),
+ }),
+ el(StatusRow, {
+ ok: true,
+ label: __('Model', 'slytranslate') + ': ' + (settings.model_slug || __('Connector default', 'slytranslate')),
+ })
+ ),
+ (bootstrap.fieldPlugins || []).length > 0 ? el('div', null,
+ el(StatusRow, {
+ ok: true,
+ label: __('Field plugins', 'slytranslate') + ': ' + bootstrap.fieldPlugins.join(', '),
+ })
+ ) : null,
+ settings.direct_api_url ? el('div', null,
+ el(StatusRow, {
+ ok: !!settings.direct_api_kwargs_supported,
+ label: settings.direct_api_kwargs_supported
+ ? __('Direct API: chat_template_kwargs supported', 'slytranslate')
+ : __('Direct API: chat_template_kwargs not detected', 'slytranslate'),
+ })
+ ) : null
+ ),
+
+ /* ---- Model ---- */
+ el('div', { className: 'card', style: { maxWidth: '720px', padding: '12px 16px', margin: '16px 0' } },
+ el('h2', { style: { marginTop: 0 } }, __('Model', 'slytranslate')),
+ el(SelectControl, {
+ label: __('Default model', 'slytranslate'),
+ value: settings.model_slug || '',
+ options: modelOptions,
+ onChange: function (value) { updateSetting('model_slug', value); },
+ __nextHasNoMarginBottom: true,
+ }),
+ el(Button, { variant: 'secondary', onClick: refreshModels, disabled: busy, style: { marginTop: '8px' } },
+ __('Refresh model list', 'slytranslate'))
+ ),
+
+ /* ---- Translation ---- */
+ el('div', { className: 'card', style: { maxWidth: '720px', padding: '12px 16px', margin: '16px 0' } },
+ el('h2', { style: { marginTop: 0 } }, __('Translation', 'slytranslate')),
+ el(TextareaControl, {
+ label: __('Additional instructions (site-wide)', 'slytranslate'),
+ help: __('Appended to every translation request, e.g. tone or brand wording.', 'slytranslate'),
+ value: settings.prompt_addon || '',
+ onChange: function (value) { updateSetting('prompt_addon', value); },
+ rows: 3,
+ __nextHasNoMarginBottom: true,
+ }),
+ el('h3', null, __('Glossary / do not translate', 'slytranslate')),
+ glossaryRows.map(function (row, index) {
+ return el('div', { key: 'glossary-' + index, style: { display: 'flex', gap: '8px', alignItems: 'flex-end', marginBottom: '8px' } },
+ el(TextControl, {
+ label: index === 0 ? __('Term', 'slytranslate') : null,
+ value: row.term,
+ onChange: function (value) {
+ var rows = glossaryRows.slice();
+ rows[index] = { term: value, mode: row.mode, toText: row.toText };
+ setGlossaryRows(rows);
+ },
+ __nextHasNoMarginBottom: true,
+ }),
+ el(SelectControl, {
+ label: index === 0 ? __('Mode', 'slytranslate') : null,
+ value: row.mode,
+ options: [
+ { label: __('Keep unchanged', 'slytranslate'), value: 'keep' },
+ { label: __('Fixed translation', 'slytranslate'), value: 'translate' },
+ ],
+ onChange: function (value) {
+ var rows = glossaryRows.slice();
+ rows[index] = { term: row.term, mode: value, toText: row.toText };
+ setGlossaryRows(rows);
+ },
+ __nextHasNoMarginBottom: true,
+ }),
+ row.mode === 'translate' ? el(TextControl, {
+ label: index === 0 ? __('Translations (en=…, fr=…)', 'slytranslate') : null,
+ value: row.toText,
+ onChange: function (value) {
+ var rows = glossaryRows.slice();
+ rows[index] = { term: row.term, mode: row.mode, toText: value };
+ setGlossaryRows(rows);
+ },
+ __nextHasNoMarginBottom: true,
+ }) : null,
+ el(Button, {
+ variant: 'tertiary',
+ isDestructive: true,
+ onClick: function () {
+ setGlossaryRows(glossaryRows.filter(function (unused, i) { return i !== index; }));
+ },
+ }, __('Remove', 'slytranslate'))
+ );
+ }),
+ el(Button, {
+ variant: 'secondary',
+ onClick: function () {
+ setGlossaryRows(glossaryRows.concat([{ term: '', mode: 'keep', toText: '' }]));
+ },
+ }, __('Add glossary entry', 'slytranslate'))
+ ),
+
+ /* ---- Meta fields ---- */
+ el('div', { className: 'card', style: { maxWidth: '720px', padding: '12px 16px', margin: '16px 0' } },
+ el('h2', { style: { marginTop: 0 } }, __('Meta fields', 'slytranslate')),
+ el('p', { className: 'description' },
+ __('Detected automatically from your SEO and field plugins. Unchecking a field excludes it from translation; the filter API can still override this.', 'slytranslate')),
+ translateFields.length === 0
+ ? el('p', null, __('No translatable meta fields detected for this context.', 'slytranslate'))
+ : translateFields.map(function (field) {
+ var isExcluded = excludedKeys.indexOf(field.key) !== -1;
+ return el(CheckboxControl, {
+ key: 'field-' + field.key,
+ label: field.key,
+ help: sourceLabel(field),
+ checked: !isExcluded,
+ onChange: function (checked) { toggleExclusion(field.key, checked); },
+ __nextHasNoMarginBottom: true,
+ });
+ }),
+ clearFields.length > 0 ? el(Fragment, null,
+ el('h3', null, __('Cleared on translation', 'slytranslate')),
+ el('p', { className: 'description' },
+ clearFields.map(function (f) { return f.key; }).join(', '))
+ ) : null,
+ el('h3', null, __('Additional keys', 'slytranslate')),
+ el(TextControl, {
+ label: __('Translate (space-separated meta keys)', 'slytranslate'),
+ value: settings.meta_keys_translate || '',
+ onChange: function (value) { updateSetting('meta_keys_translate', value); },
+ __nextHasNoMarginBottom: true,
+ }),
+ el(TextControl, {
+ label: __('Clear (space-separated meta keys)', 'slytranslate'),
+ value: settings.meta_keys_clear || '',
+ onChange: function (value) { updateSetting('meta_keys_clear', value); },
+ __nextHasNoMarginBottom: true,
+ }),
+ el('div', { style: { display: 'flex', gap: '8px', alignItems: 'flex-end', marginTop: '12px' } },
+ el(TextControl, {
+ label: __('Preview for post ID', 'slytranslate'),
+ type: 'number',
+ value: previewPostId,
+ onChange: setPreviewPostId,
+ __nextHasNoMarginBottom: true,
+ }),
+ el(Button, {
+ variant: 'secondary',
+ disabled: busy,
+ onClick: function () { loadFieldsReport(parseInt(previewPostId, 10) || 0); },
+ }, __('Load preview', 'slytranslate'))
+ ),
+ fieldsReport && fieldsReport.post_id > 0 ? el('p', { className: 'description' },
+ __('Showing resolution for post', 'slytranslate') + ' #' + fieldsReport.post_id) : null
+ ),
+
+ /* ---- Automation ---- */
+ el('div', { className: 'card', style: { maxWidth: '720px', padding: '12px 16px', margin: '16px 0' } },
+ el('h2', { style: { marginTop: 0 } }, __('Automation', 'slytranslate')),
+ el(ToggleControl, {
+ label: __('Automatically translate new posts on publish (as draft)', 'slytranslate'),
+ checked: !!settings.auto_translate_new,
+ onChange: function (value) { updateSetting('auto_translate_new', value); },
+ __nextHasNoMarginBottom: true,
+ }),
+ el(ToggleControl, {
+ label: __('Translate taxonomy terms alongside content', 'slytranslate'),
+ checked: !!settings.translate_terms,
+ onChange: function (value) { updateSetting('translate_terms', value); },
+ __nextHasNoMarginBottom: true,
+ }),
+ el(ToggleControl, {
+ label: __('Translate post slugs', 'slytranslate'),
+ checked: !!settings.translate_slugs,
+ onChange: function (value) { updateSetting('translate_slugs', value); },
+ __nextHasNoMarginBottom: true,
+ })
+ ),
+
+ /* ---- Advanced (collapsed) ---- */
+ el('div', { className: 'card', style: { maxWidth: '720px', padding: '0', margin: '16px 0' } },
+ el(PanelBody, { title: __('Advanced', 'slytranslate'), initialOpen: false },
+ el(TextareaControl, {
+ label: __('Prompt template', 'slytranslate'),
+ help: __('Placeholders: {FROM_CODE} and {TO_CODE}.', 'slytranslate'),
+ value: settings.prompt_template || '',
+ onChange: function (value) { updateSetting('prompt_template', value); },
+ rows: 4,
+ __nextHasNoMarginBottom: true,
+ }),
+ el(Button, {
+ variant: 'tertiary',
+ onClick: function () { updateSetting('prompt_template', bootstrap.defaultPromptTemplate || ''); },
+ style: { marginBottom: '16px' },
+ }, __('Reset to default', 'slytranslate')),
+ el(TextControl, {
+ label: __('Context window override (tokens, 0 = automatic)', 'slytranslate'),
+ type: 'number',
+ help: __('Effective:', 'slytranslate') + ' ' + (settings.effective_context_window_tokens || 0)
+ + ' ' + __('tokens', 'slytranslate')
+ + ' · ' + __('learned:', 'slytranslate') + ' ' + (settings.learned_context_window_tokens || 0)
+ + ' · ' + __('chunk size:', 'slytranslate') + ' ' + (settings.effective_chunk_chars || 0),
+ value: String(settings.context_window_tokens || 0),
+ onChange: function (value) { updateSetting('context_window_tokens', value); },
+ __nextHasNoMarginBottom: true,
+ }),
+ el(TextControl, {
+ label: __('Direct API URL (OpenAI-compatible, optional)', 'slytranslate'),
+ help: settings.direct_api_url
+ ? (settings.direct_api_kwargs_supported
+ ? __('Probe: chat_template_kwargs supported.', 'slytranslate')
+ : __('Probe: chat_template_kwargs not detected.', 'slytranslate'))
+ : __('Only used by model profiles that require a direct endpoint (e.g. TranslateGemma). Saving runs a capability probe.', 'slytranslate'),
+ value: settings.direct_api_url || '',
+ onChange: function (value) { updateSetting('direct_api_url', value); },
+ __nextHasNoMarginBottom: true,
+ }),
+ bootstrap.isStringTableAdapter ? el(Fragment, null,
+ el(SelectControl, {
+ label: __('String-table concurrency (TranslatePress batches)', 'slytranslate'),
+ help: __('Effective:', 'slytranslate') + ' ' + (settings.string_table_concurrency_effective || 1)
+ + ' · ' + __('recommended:', 'slytranslate') + ' ' + (settings.string_table_concurrency_recommended || 1),
+ value: String(settings.string_table_concurrency || 1),
+ options: ['1', '2', '3', '4'].map(function (n) { return { label: n, value: n }; }),
+ onChange: function (value) { updateSetting('string_table_concurrency', value); },
+ __nextHasNoMarginBottom: true,
+ }),
+ el(Button, { variant: 'secondary', onClick: probeConcurrency, disabled: busy, style: { margin: '8px 0' } },
+ __('Test concurrency', 'slytranslate')),
+ probeResult ? el('table', { className: 'widefat striped', style: { marginBottom: '16px' } },
+ el('thead', null, el('tr', null,
+ el('th', null, __('Level', 'slytranslate')),
+ el('th', null, __('Wall time (ms)', 'slytranslate')),
+ el('th', null, __('Speedup', 'slytranslate')),
+ el('th', null, __('Errors', 'slytranslate'))
+ )),
+ el('tbody', null,
+ (probeResult.levels || []).map(function (level) {
+ return el('tr', { key: 'level-' + level.level },
+ el('td', null, String(level.level)),
+ el('td', null, String(level.wall_ms)),
+ el('td', null, String(level.speedup)),
+ el('td', null, String(level.errors))
+ );
+ }),
+ (probeResult.levels || []).length === 0 ? el('tr', null,
+ el('td', { colSpan: 4 }, probeResult.reason || __('No parallel transport available.', 'slytranslate'))
+ ) : null
+ )
+ ) : null,
+ probeResult ? el('p', { className: 'description' },
+ __('Recommended concurrency:', 'slytranslate') + ' ' + probeResult.recommended) : null
+ ) : null,
+ settings.last_transport_diagnostics ? el(Fragment, null,
+ el('h3', null, __('Transport diagnostics', 'slytranslate')),
+ el('pre', {
+ style: { background: '#f6f7f7', padding: '8px', overflow: 'auto', fontSize: '12px' },
+ }, JSON.stringify(settings.last_transport_diagnostics, null, 2))
+ ) : null
+ )
+ ),
+
+ /* ---- Save ---- */
+ el('div', { style: { maxWidth: '720px', margin: '16px 0' } },
+ el(Button, { variant: 'primary', onClick: save, isBusy: busy, disabled: busy },
+ __('Save settings', 'slytranslate'))
+ ),
+
+ snackbar ? el('div', { style: { position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)', zIndex: 100000 } },
+ el(Snackbar, null, snackbar)
+ ) : null
+ );
+ }
+
+ function mount() {
+ var root = document.getElementById('slytranslate-settings-root');
+ if (!root) {
+ return;
+ }
+ root.innerHTML = '
SlyTranslate
';
+ var appContainer = document.createElement('div');
+ root.appendChild(appContainer);
+
+ if (wp.element.createRoot) {
+ wp.element.createRoot(appContainer).render(el(SettingsApp, null));
+ } else {
+ wp.element.render(el(SettingsApp, null), appContainer);
+ }
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', mount);
+ } else {
+ mount();
+ }
+})();
diff --git a/slytranslate/changelog.txt b/slytranslate/changelog.txt
index ca1cfa4..308fa17 100644
--- a/slytranslate/changelog.txt
+++ b/slytranslate/changelog.txt
@@ -1,3 +1,28 @@
+= 1.11.0 =
+Features:
+* Admin settings page (Settings -> SlyTranslate): status overview (detected language/SEO/field plugins, AI client, model), model picker, site-wide prompt add-on, glossary editor, meta-field list with per-key exclusion checkboxes and per-post preview, automation toggles, and an Advanced section (prompt template, context-window override, direct-API URL with probe status, string-table concurrency test, transport diagnostics). Saves through the same backend as the MCP configure ability.
+* The settings page and the ACF field-editor toggle ship with German translations (de_DE).
+* ACF blocks in Gutenberg: field data stored in the block-comment JSON of acf/* blocks (attrs.data) is now translated automatically, including repeater/group sub-keys. Short values are batched into a single AI call; the client translation workflow exposes ACF block fields as their own units.
+* ACF options pages: new MCP ability ai-translate/translate-options translates ACF fields on options pages (global content such as footer texts or CTAs). WPGlobus/WP Multilang write inline language markup; Polylang-style per-language storage is opt-in via the slytranslate_acf_options_post_id filter; TranslatePress reports not_required because its string table already covers option output.
+* Meta-key exclusion list: new meta_keys_exclude setting excludes keys from translation uniformly across manual, SEO-detected, and field-plugin-resolved keys. Implemented as a priority-5 callback on slytranslate_translate_meta_key, so third-party filters can still override it.
+* Per-field opt-out in the ACF field editor: a new "Exclude from AI translation" toggle in every ACF field definition skips that field during automatic translation.
+* Structured field types: ACF link fields are now translated selectively — only the title sub-key is sent to the model, url/target are copied unchanged. Extensible via the new slytranslate_meta_value_translation_spec filter.
+* Meta Box (metabox.io) support: text, textarea, and wysiwyg fields are detected via the field registry and translated automatically (filter: slytranslate_metabox_translatable_field_types).
+* Pods support: text, paragraph, and wysiwyg fields are detected via the pod definition and translated automatically (filter: slytranslate_pods_translatable_field_types).
+* New introspection helper describe_effective_meta_keys() reports every effective meta key with its source (acf/seo/manual/filter), ACF field label/type, and exclusion state.
+* Taxonomy-term translation (opt-in via translate_terms): missing target-language terms are created and linked automatically instead of being dropped (Polylang). New MCP ability ai-translate/translate-terms bulk-translates whole taxonomies with dry_run preview.
+* Image alt texts are now translated by default (_wp_attachment_image_alt). Polylang media duplication translates alt text, caption, and description; post translation back-fills the featured image alt on the target attachment when empty.
+* Background processing for bulk jobs: translate-content-bulk accepts background=true and queues one post per scheduled action (Action Scheduler or WP-Cron). get-progress and cancel-translation accept job_id. Jobs survive closed tabs and PHP timeouts.
+* Auto-translate on publish: publishing a source-language post queues draft translations for all missing target languages (option auto_translate_new, never auto-published).
+* Glossary / do-not-translate list: new glossary setting with keep entries (never translate) and fixed per-language translations, injected into every prompt.
+* Slug translation (opt-in via translate_slugs): slugs of newly created translations are derived from the translated title; existing slugs stay untouched.
+* Diff-based retranslation: overwriting an existing translation only sends changed blocks and meta values through the model; unchanged parts are reused.
+* New readonly MCP ability ai-translate/get-translatable-fields reports which meta fields would be translated or cleared, with per-key source attribution.
+
+Changes:
+* Shared ACF introspection extracted into AcfFieldIntrospector; AcfMetaResolver is now a thin consumer of it. The slytranslate_acf_translatable_field_types filter is unchanged.
+* WpglobusAdapter and WpMultilangAdapter expose a public merge_language_variant() mutation helper used by the options translation service.
+
= 1.10.0 =
Features:
* ACF (Advanced Custom Fields) support: translatable text, textarea, and wysiwyg fields are now detected and translated automatically — no configuration required when ACF is active.
diff --git a/slytranslate/inc/AbilityRegistrar.php b/slytranslate/inc/AbilityRegistrar.php
index 3e8ae80..c5e3838 100644
--- a/slytranslate/inc/AbilityRegistrar.php
+++ b/slytranslate/inc/AbilityRegistrar.php
@@ -27,6 +27,13 @@ public static function register_abilities(): void {
self::register_translate_blocks_ability();
self::register_translate_content_ability();
self::register_translate_content_bulk_ability();
+ if ( AcfOptionsTranslationService::is_available() ) {
+ self::register_translate_options_ability();
+ }
+ if ( TermTranslationService::is_term_translation_supported() ) {
+ self::register_translate_terms_ability();
+ }
+ self::register_get_translatable_fields_ability();
self::register_get_progress_ability();
self::register_cancel_translation_ability();
self::register_get_available_models_ability();
@@ -309,6 +316,7 @@ private static function register_translate_content_bulk_ability(): void {
'overwrite' => array( 'type' => 'boolean', 'description' => 'When true, update existing target translations instead of returning them as skipped.', 'default' => false ),
'additional_prompt' => array( 'type' => 'string', 'description' => 'Optional extra instructions appended after the global prompt template and the site-wide prompt add-on for every item in the batch.', 'maxLength' => 2000 ),
'model_slug' => array( 'type' => 'string', 'description' => 'Model slug/identifier to use for this translation batch. Overrides the site-wide default.' ),
+ 'background' => array( 'type' => 'boolean', 'description' => 'When true and a background transport (Action Scheduler or WP-Cron) is available, the batch is queued with one action per post and the call returns immediately with a job_id. Poll get-progress with that job_id and cancel via cancel-translation. Falls back to synchronous processing when no transport exists.', 'default' => false ),
),
'required' => array( 'target_language' ),
),
@@ -332,6 +340,8 @@ private static function register_translate_content_bulk_ability(): void {
'succeeded' => array( 'type' => 'integer' ),
'failed' => array( 'type' => 'integer' ),
'skipped' => array( 'type' => 'integer' ),
+ 'queued' => array( 'type' => 'boolean', 'description' => 'True when the batch was queued for background processing. Per-post results are then reported by get-progress with job_id instead of the results array.' ),
+ 'job_id' => array( 'type' => 'string', 'description' => 'Background job identifier. Present only when queued is true.' ),
),
),
'execute_callback' => array( AI_Translate::class, 'execute_translate_posts' ),
@@ -340,6 +350,152 @@ private static function register_translate_content_bulk_ability(): void {
) );
}
+ /* --- translate-options ---------------------------------------- */
+
+ private static function register_translate_options_ability(): void {
+ wp_register_ability( 'ai-translate/translate-options', array(
+ 'label' => __( 'Translate ACF Options', 'slytranslate' ),
+ 'description' => __( 'Translates ACF fields on options pages (global content such as footer texts or CTAs) into one target language. Registered only when ACF options pages exist. With TranslatePress this returns not_required because its string table already covers option output. Use dry_run=true to preview which fields would be translated.', 'slytranslate' ),
+ 'category' => 'ai-translation',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'target_language' => array( 'type' => 'string', 'description' => 'Target language code.' ),
+ 'source_language' => array( 'type' => 'string', 'description' => 'Source language code of the stored option values. Defaults to en.' ),
+ 'page_slug' => array( 'type' => 'string', 'description' => 'Optional menu slug of one options page. Omit to process all options pages.' ),
+ 'dry_run' => array( 'type' => 'boolean', 'description' => 'When true, only report which fields would be translated without calling the model or writing options.', 'default' => false ),
+ ),
+ 'required' => array( 'target_language' ),
+ ),
+ 'output_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'status' => array( 'type' => 'string', 'description' => 'ok, or not_required when the active language plugin already covers option output.' ),
+ 'message' => array( 'type' => 'string' ),
+ 'target_language' => array( 'type' => 'string' ),
+ 'source_language' => array( 'type' => 'string' ),
+ 'dry_run' => array( 'type' => 'boolean' ),
+ 'results' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'field' => array( 'type' => 'string', 'description' => 'ACF field name.' ),
+ 'field_label' => array( 'type' => 'string' ),
+ 'field_type' => array( 'type' => 'string' ),
+ 'page' => array( 'type' => 'string', 'description' => 'Options page menu slug.' ),
+ 'status' => array( 'type' => 'string', 'enum' => array( 'translated', 'pending', 'skipped', 'failed' ) ),
+ 'reason' => array( 'type' => 'string', 'description' => 'Machine-readable reason when skipped or failed. requires_options_post_id_filter means the active language plugin has no language-aware option storage; provide the slytranslate_acf_options_post_id filter to opt in.' ),
+ ),
+ ),
+ ),
+ 'total' => array( 'type' => 'integer' ),
+ 'translated' => array( 'type' => 'integer' ),
+ 'skipped' => array( 'type' => 'integer' ),
+ 'failed' => array( 'type' => 'integer' ),
+ ),
+ ),
+ 'execute_callback' => array( AI_Translate::class, 'execute_translate_options' ),
+ 'permission_callback' => static function ( $input = null ) {
+ return current_user_can( 'manage_options' );
+ },
+ 'meta' => self::public_mcp_meta(),
+ ) );
+ }
+
+ /* --- translate-terms ------------------------------------------ */
+
+ private static function register_translate_terms_ability(): void {
+ wp_register_ability( 'ai-translate/translate-terms', array(
+ 'label' => __( 'Translate Taxonomy Terms', 'slytranslate' ),
+ 'description' => __( 'Bulk-translates taxonomy terms (categories, tags, custom taxonomies) into one target language and links them as translations. Registered only when the active language plugin (Polylang) supports term translation. Use dry_run=true to preview which terms would be translated. Terms that already have a translation are skipped.', 'slytranslate' ),
+ 'category' => 'ai-translation',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'taxonomy' => array( 'type' => 'string', 'description' => 'Taxonomy to process, for example category or post_tag.' ),
+ 'target_language' => array( 'type' => 'string', 'description' => 'Target language code.' ),
+ 'source_language' => array( 'type' => 'string', 'description' => 'Source language code. Defaults to the language plugin default language.' ),
+ 'term_ids' => array( 'type' => 'array', 'description' => 'Explicit term IDs to translate. Omit to process all terms of the taxonomy in the source language.', 'items' => array( 'type' => 'integer' ) ),
+ 'dry_run' => array( 'type' => 'boolean', 'description' => 'When true, only report which terms would be translated without calling the model or writing terms.', 'default' => false ),
+ 'model_slug' => array( 'type' => 'string', 'description' => 'Model slug/identifier to use for this batch. Overrides the site-wide default.' ),
+ ),
+ 'required' => array( 'taxonomy', 'target_language' ),
+ ),
+ 'output_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'results' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'term_id' => array( 'type' => 'integer', 'description' => 'Source term ID.' ),
+ 'translated_term_id' => array( 'type' => 'integer', 'description' => 'Target-language term ID. 0 when status is failed or would_translate.' ),
+ 'status' => array( 'type' => 'string', 'enum' => array( 'success', 'skipped', 'failed', 'would_translate' ) ),
+ 'error' => array( 'type' => 'string', 'description' => 'Human-readable reason when status is failed. Null otherwise.' ),
+ ),
+ ),
+ ),
+ 'total' => array( 'type' => 'integer' ),
+ 'succeeded' => array( 'type' => 'integer' ),
+ 'failed' => array( 'type' => 'integer' ),
+ 'skipped' => array( 'type' => 'integer' ),
+ 'dry_run' => array( 'type' => 'boolean' ),
+ ),
+ ),
+ 'execute_callback' => array( AI_Translate::class, 'execute_translate_terms' ),
+ 'permission_callback' => static function ( $input = null ) {
+ return current_user_can( 'manage_categories' );
+ },
+ 'meta' => self::public_mcp_meta(),
+ ) );
+ }
+
+ /* --- get-translatable-fields ----------------------------------- */
+
+ private static function register_get_translatable_fields_ability(): void {
+ wp_register_ability( 'ai-translate/get-translatable-fields', array(
+ 'label' => __( 'Get Translatable Fields', 'slytranslate' ),
+ 'description' => __( 'Introspects which meta fields a translate-content call would translate or clear, including where each key comes from (manual config, SEO plugin, ACF/field-plugin resolution, default, or filter). Pass post_id for the post-specific view including runtime-resolved ACF fields; omit it for the global view. Call this before translate-content when you need to know what will happen to custom fields.', 'slytranslate' ),
+ 'category' => 'ai-translation',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'post_id' => array( 'type' => 'integer', 'description' => 'Optional post ID for the post-specific field resolution. 0 or omitted returns the global view without post context.' ),
+ ),
+ ),
+ 'output_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'post_id' => array( 'type' => 'integer', 'description' => 'Echoed post context. 0 for the global view.' ),
+ 'fields' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'key' => array( 'type' => 'string', 'description' => 'Meta key.' ),
+ 'action' => array( 'type' => 'string', 'enum' => array( 'translate', 'clear' ), 'description' => 'What translate-content does with this key.' ),
+ 'source' => array( 'type' => 'string', 'enum' => array( 'manual', 'seo', 'acf', 'default', 'filter' ), 'description' => 'Where the key comes from: manual settings, the detected SEO plugin, ACF/field-plugin resolution, the plugin default list, or a third-party filter.' ),
+ 'field_type' => array( 'type' => 'string', 'description' => 'Field type when resolved from a field plugin (for example text, wysiwyg, link). Empty otherwise.' ),
+ 'field_label' => array( 'type' => 'string', 'description' => 'Human-readable field label when resolved from a field plugin. Empty otherwise.' ),
+ 'excluded' => array( 'type' => 'boolean', 'description' => 'True when the key is on the exclusion list and therefore not processed despite being listed.' ),
+ ),
+ 'required' => array( 'key', 'action', 'source' ),
+ ),
+ ),
+ 'seo_plugin' => array( 'type' => 'string', 'description' => 'Slug of the detected SEO plugin. Empty string when none is active.' ),
+ 'seo_plugin_label' => array( 'type' => 'string', 'description' => 'Human-readable name of the detected SEO plugin.' ),
+ 'active_resolvers' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), 'description' => 'Active field-plugin resolvers: acf, metabox, pods.' ),
+ ),
+ 'required' => array( 'fields' ),
+ ),
+ 'execute_callback' => array( AI_Translate::class, 'execute_get_translatable_fields' ),
+ 'permission_callback' => array( static::class, 'permission_callback' ),
+ 'meta' => self::public_mcp_meta( array( 'readonly' => true ) ),
+ ) );
+ }
+
/* --- translate-blocks ---------------------------------------- */
private static function register_translate_blocks_ability(): void {
@@ -384,6 +540,7 @@ private static function register_get_progress_ability(): void {
'type' => 'object',
'properties' => array(
'post_id' => array( 'type' => 'integer', 'description' => 'The post ID whose translation progress to retrieve.' ),
+ 'job_id' => array( 'type' => 'string', 'description' => 'Background queue job ID returned by translate-content-bulk with background=true. When given and known, the job status is returned instead of the per-post phase progress.' ),
),
),
'output_schema' => array(
@@ -393,6 +550,13 @@ private static function register_get_progress_ability(): void {
'percent' => array( 'type' => 'integer', 'description' => 'Overall completion percentage, 0–100.', 'minimum' => 0, 'maximum' => 100 ),
'current_chunk' => array( 'type' => 'integer', 'description' => 'Number of character units processed in the current phase.' ),
'total_chunks' => array( 'type' => 'integer', 'description' => 'Total character units in the current phase. When 0, progress within the phase is indeterminate.' ),
+ 'job_id' => array( 'type' => 'string', 'description' => 'Echoed background job ID. Present only for queue-job lookups.' ),
+ 'status' => array( 'type' => 'string', 'description' => 'Queue job status: queued, running, completed, or cancelled. Present only for queue-job lookups.' ),
+ 'total' => array( 'type' => 'integer', 'description' => 'Total posts in the queue job. Present only for queue-job lookups.' ),
+ 'processed' => array( 'type' => 'integer', 'description' => 'Posts processed so far in the queue job. Present only for queue-job lookups.' ),
+ 'succeeded' => array( 'type' => 'integer', 'description' => 'Successful posts in the queue job. Present only for queue-job lookups.' ),
+ 'failed' => array( 'type' => 'integer', 'description' => 'Failed posts in the queue job. Present only for queue-job lookups.' ),
+ 'skipped' => array( 'type' => 'integer', 'description' => 'Skipped posts in the queue job. Present only for queue-job lookups.' ),
),
),
'execute_callback' => array( AI_Translate::class, 'execute_get_progress' ),
@@ -412,12 +576,14 @@ private static function register_cancel_translation_ability(): void {
'type' => 'object',
'properties' => array(
'post_id' => array( 'type' => 'integer', 'description' => 'Optional post ID whose progress transient should be cleared.' ),
+ 'job_id' => array( 'type' => 'string', 'description' => 'Optional background queue job ID to cancel. Pending scheduled actions of the job are unscheduled.' ),
),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
- 'cancelled' => array( 'type' => 'boolean' ),
+ 'cancelled' => array( 'type' => 'boolean' ),
+ 'job_cancelled' => array( 'type' => 'boolean', 'description' => 'True when a known background queue job was cancelled.' ),
),
'required' => array( 'cancelled' ),
),
@@ -507,7 +673,23 @@ private static function register_configure_ability(): void {
'prompt_addon' => array( 'type' => 'string', 'description' => 'Persistent setting: optional site-wide instructions appended after the prompt template for every translation request.' ),
'meta_keys_translate' => array( 'type' => 'string', 'description' => 'Persistent setting: whitespace-separated list of meta keys to translate. Use a plain string, not an array.' ),
'meta_keys_clear' => array( 'type' => 'string', 'description' => 'Persistent setting: whitespace-separated list of meta keys to clear. Use a plain string, not an array.' ),
- 'auto_translate_new' => array( 'type' => 'boolean', 'description' => 'Persistent setting: auto-translate new translation posts created by the active language plugin.' ),
+ 'meta_keys_exclude' => array( 'type' => 'string', 'description' => 'Persistent setting: whitespace-separated list of meta keys to exclude from translation. Applies to manual, SEO-detected, and auto-resolved field-plugin keys alike.' ),
+ 'auto_translate_new' => array( 'type' => 'boolean', 'description' => 'Persistent setting: queue draft translations for all missing target languages when a source-language post is published. Requires a background transport (Action Scheduler or WP-Cron).' ),
+ 'translate_terms' => array( 'type' => 'boolean', 'description' => 'Persistent setting: when a translated post references taxonomy terms without a target-language translation, create and link AI-translated terms instead of dropping them. Polylang only.' ),
+ 'translate_slugs' => array( 'type' => 'boolean', 'description' => 'Persistent setting: derive the slug of newly created translations from the translated title instead of keeping the source-language slug. Existing translation slugs are never changed.' ),
+ 'glossary' => array(
+ 'type' => 'array',
+ 'description' => 'Persistent setting: glossary entries. mode=keep terms are never translated; mode=translate terms use the fixed translation from the per-language map.',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'term' => array( 'type' => 'string', 'description' => 'The source term.' ),
+ 'mode' => array( 'type' => 'string', 'enum' => array( 'keep', 'translate' ), 'description' => 'keep = never translate; translate = always use the fixed translation.' ),
+ 'to' => array( 'type' => 'object', 'description' => 'Fixed translations keyed by target language code. Required for mode=translate.', 'additionalProperties' => array( 'type' => 'string' ) ),
+ ),
+ 'required' => array( 'term', 'mode' ),
+ ),
+ ),
'context_window_tokens' => array( 'type' => 'integer', 'description' => 'Persistent setting: manual model context-window override in tokens. Use 0 to fall back to auto-detection and learned values.', 'minimum' => 0, 'maximum' => 4000000 ),
'string_table_concurrency' => array( 'type' => 'integer', 'description' => 'Persistent setting: opt-in maximum concurrency for TranslatePress-style string-table batches. Values above 1 only activate when a successful concurrency probe recommends parallel execution for the active model.', 'minimum' => 1, 'maximum' => 4 ),
'model_slug' => array( 'type' => 'string', 'description' => 'Persistent setting: site-wide default model slug/identifier passed to the AI connector. Leave empty to use the connector default.' ),
@@ -522,7 +704,22 @@ private static function register_configure_ability(): void {
'prompt_addon' => array( 'type' => 'string', 'description' => 'Persistent setting: site-wide additional instructions appended after the prompt template for every request.' ),
'meta_keys_translate' => array( 'type' => 'string', 'description' => 'Persistent setting: whitespace-separated list of custom meta keys to translate.' ),
'meta_keys_clear' => array( 'type' => 'string', 'description' => 'Persistent setting: whitespace-separated list of custom meta keys to clear on translation.' ),
- 'auto_translate_new' => array( 'type' => 'boolean', 'description' => 'Persistent setting: whether new translation stubs created by the active language plugin are translated automatically.' ),
+ 'meta_keys_exclude' => array( 'type' => 'string', 'description' => 'Persistent setting: whitespace-separated list of meta keys excluded from translation.' ),
+ 'auto_translate_new' => array( 'type' => 'boolean', 'description' => 'Persistent setting: whether publishing a source-language post queues draft translations for all missing target languages.' ),
+ 'translate_terms' => array( 'type' => 'boolean', 'description' => 'Persistent setting: whether missing taxonomy term translations are created automatically during post translation (Polylang only).' ),
+ 'translate_slugs' => array( 'type' => 'boolean', 'description' => 'Persistent setting: whether slugs of newly created translations are derived from the translated title.' ),
+ 'glossary' => array(
+ 'type' => 'array',
+ 'description' => 'Persistent setting: glossary entries applied to every translation prompt.',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'term' => array( 'type' => 'string' ),
+ 'mode' => array( 'type' => 'string', 'enum' => array( 'keep', 'translate' ) ),
+ 'to' => array( 'type' => 'object', 'additionalProperties' => array( 'type' => 'string' ) ),
+ ),
+ ),
+ ),
'context_window_tokens' => array( 'type' => 'integer', 'description' => 'Persistent setting: manual override for the model context window in tokens. 0 means auto-detection is active.' ),
'string_table_concurrency' => array( 'type' => 'integer', 'description' => 'Persistent setting: configured opt-in maximum concurrency for string-table batch translation.' ),
'string_table_concurrency_effective' => array( 'type' => 'integer', 'description' => 'Inspect-only diagnostic: effective string-table concurrency after applying the probe recommendation and available transport.' ),
diff --git a/slytranslate/inc/AcfBlockTranslator.php b/slytranslate/inc/AcfBlockTranslator.php
new file mode 100644
index 0000000..d570856
--- /dev/null
+++ b/slytranslate/inc/AcfBlockTranslator.php
@@ -0,0 +1,389 @@
+
+ *
+ * ContentTranslator only handles innerHTML/innerBlocks, so the attrs.data
+ * payload of acf/* blocks would pass through untranslated. This class runs as
+ * a pre-pass over the parsed block tree: every data entry whose '_'-prefixed
+ * sibling holds a 'field_…' reference to a translatable field type is
+ * translated in place. Repeater/group sub-fields (slides_0_caption with their
+ * own _slides_0_caption reference) are covered by the same per-key check.
+ *
+ * Serialization stays with serialize_blocks(), which produces correct JSON
+ * including Unicode escaping — the JSON is never built manually here.
+ */
+class AcfBlockTranslator {
+
+ /** Values longer than this skip the JSON batch and get their own call. */
+ private const BATCH_MAX_VALUE_CHARS = 1000;
+
+ /* ---------------------------------------------------------------
+ * Pre-pass over a parsed block tree
+ * ------------------------------------------------------------- */
+
+ /**
+ * True when the block is an ACF block carrying field data.
+ */
+ public static function is_acf_block_with_data( array $block ): bool {
+ $block_name = (string) ( $block['blockName'] ?? '' );
+ return str_starts_with( $block_name, 'acf/' )
+ && isset( $block['attrs']['data'] )
+ && is_array( $block['attrs']['data'] )
+ && ! empty( $block['attrs']['data'] );
+ }
+
+ /**
+ * Translate the attrs.data payload of every acf/* block in a parsed block
+ * tree (recursively, so nested ACF blocks inside wrappers are covered).
+ *
+ * @param array[] $blocks Parsed blocks from parse_blocks().
+ * @return array[]|\WP_Error Updated blocks, or WP_Error on hard failure.
+ */
+ public static function translate_blocks_data(
+ array $blocks,
+ string $to,
+ string $from,
+ string $additional_prompt = ''
+ ): array|\WP_Error {
+ if ( ! function_exists( 'acf_get_field' ) ) {
+ return $blocks;
+ }
+
+ foreach ( $blocks as $index => $block ) {
+ if ( ! is_array( $block ) ) {
+ continue;
+ }
+
+ if ( TranslationProgressTracker::is_cancelled() ) {
+ return new \WP_Error( 'translation_cancelled', 'Translation cancelled.' );
+ }
+
+ if ( self::is_acf_block_with_data( $block ) ) {
+ $translated_data = self::translate_block_data( $block['attrs']['data'], $to, $from, $additional_prompt );
+ if ( is_wp_error( $translated_data ) ) {
+ return $translated_data;
+ }
+ $blocks[ $index ]['attrs']['data'] = $translated_data;
+ }
+
+ if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
+ $translated_inner = self::translate_blocks_data( $block['innerBlocks'], $to, $from, $additional_prompt );
+ if ( is_wp_error( $translated_inner ) ) {
+ return $translated_inner;
+ }
+ $blocks[ $index ]['innerBlocks'] = $translated_inner;
+ }
+ }
+
+ return $blocks;
+ }
+
+ /**
+ * Translate all translatable entries of one acf/* block data array.
+ *
+ * Mirrors the meta pipeline: short values are batched into one JSON call,
+ * longer values or batch failures fall back to individual calls. A failed
+ * individual translation keeps the source value instead of failing the
+ * whole post; cancellation is propagated.
+ *
+ * @param array $data attrs.data of the block.
+ * @return array|\WP_Error Updated data array.
+ */
+ public static function translate_block_data(
+ array $data,
+ string $to,
+ string $from,
+ string $additional_prompt = ''
+ ): array|\WP_Error {
+ $translatable = self::collect_translatable_entries( $data );
+ if ( empty( $translatable ) ) {
+ return $data;
+ }
+
+ $batch_results = self::try_batch_translate( $translatable, $to, $from, $additional_prompt );
+
+ foreach ( $translatable as $key => $value ) {
+ if ( TranslationProgressTracker::is_cancelled() ) {
+ return new \WP_Error( 'translation_cancelled', 'Translation cancelled.' );
+ }
+
+ if ( is_array( $batch_results ) && array_key_exists( $key, $batch_results ) ) {
+ $data[ $key ] = $batch_results[ $key ];
+ continue;
+ }
+
+ $translated = TranslationRuntime::translate_text( $value, $to, $from, $additional_prompt );
+ if ( is_wp_error( $translated ) ) {
+ if ( 'translation_cancelled' === $translated->get_error_code() ) {
+ return $translated;
+ }
+ TimingLogger::log( 'acf_block_field_kept_in_source', array(
+ 'key' => $key,
+ 'reason' => $translated->get_error_code(),
+ ) );
+ continue;
+ }
+
+ $data[ $key ] = (string) $translated;
+ }
+
+ return $data;
+ }
+
+ /**
+ * Collect data entries eligible for translation: string values whose
+ * '_'-prefixed sibling references a translatable (non-structured) field.
+ *
+ * @return array
+ */
+ public static function collect_translatable_entries( array $data ): array {
+ $entries = array();
+
+ foreach ( $data as $key => $value ) {
+ if ( ! is_string( $key ) || str_starts_with( $key, '_' ) ) {
+ continue;
+ }
+ if ( ! is_string( $value ) || '' === trim( $value ) ) {
+ continue;
+ }
+
+ $ref = $data[ '_' . $key ] ?? null;
+ if ( ! AcfFieldIntrospector::is_translatable_ref( is_string( $ref ) ? $ref : null, false ) ) {
+ continue;
+ }
+
+ $entries[ $key ] = $value;
+ }
+
+ return $entries;
+ }
+
+ /**
+ * Translate eligible short values in one JSON batch call.
+ *
+ * @param array $candidates
+ * @return array|null Translated map or null to fall back.
+ */
+ private static function try_batch_translate(
+ array $candidates,
+ string $to,
+ string $from,
+ string $additional_prompt
+ ): ?array {
+ $batchable = array();
+ foreach ( $candidates as $key => $value ) {
+ $length = function_exists( 'mb_strlen' ) ? (int) mb_strlen( $value, 'UTF-8' ) : strlen( $value );
+ if ( $length <= self::BATCH_MAX_VALUE_CHARS ) {
+ $batchable[ $key ] = $value;
+ }
+ }
+
+ if ( count( $batchable ) < 2 ) {
+ return null;
+ }
+
+ $json_input = wp_json_encode( $batchable, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
+ if ( false === $json_input ) {
+ return null;
+ }
+
+ $json_hint = 'The input is a JSON object of field values from one content block. Translate only the string values, not the keys. Preserve inline HTML tags inside values. Return a valid JSON object with the identical keys and translated values. No explanations, no markdown wrappers — only the JSON.';
+ $batch_prompt = '' !== trim( $additional_prompt )
+ ? trim( $additional_prompt ) . "\n\n" . $json_hint
+ : $json_hint;
+
+ $result = TranslationRuntime::translate_text( $json_input, $to, $from, $batch_prompt );
+
+ $ok = ! is_wp_error( $result );
+ TimingLogger::log( 'acf_block_batch', array(
+ 'keys' => array_keys( $batchable ),
+ 'ok' => $ok,
+ ) );
+
+ if ( ! $ok ) {
+ return null;
+ }
+
+ $result_str = trim( (string) $result );
+ $result_str = (string) preg_replace( '/^```(?:json)?\s*/i', '', $result_str );
+ $result_str = (string) preg_replace( '/\s*```\s*$/i', '', $result_str );
+
+ $decoded = json_decode( trim( $result_str ), true );
+ if ( ! is_array( $decoded ) ) {
+ return null;
+ }
+
+ foreach ( array_keys( $batchable ) as $key ) {
+ if ( ! array_key_exists( $key, $decoded ) || ! is_string( $decoded[ $key ] ) ) {
+ return null;
+ }
+
+ $validation = TranslationValidator::validate( $batchable[ $key ], $decoded[ $key ], $to );
+ if ( is_wp_error( $validation ) ) {
+ return null;
+ }
+ }
+
+ return $decoded;
+ }
+
+ /* ---------------------------------------------------------------
+ * Client-translation workflow units
+ * ------------------------------------------------------------- */
+
+ /**
+ * Build client-translation units for all ACF block fields in a post's
+ * serialized content. Unit ids encode the block path and field key:
+ * "acf_block:{path}:{key}" where {path} is dot-joined block indices.
+ *
+ * @return array
+ */
+ public static function build_block_units( string $content ): array {
+ if ( '' === trim( $content )
+ || ! function_exists( 'parse_blocks' )
+ || ! function_exists( 'acf_get_field' )
+ ) {
+ return array();
+ }
+
+ $blocks = parse_blocks( $content );
+ if ( ! is_array( $blocks ) ) {
+ return array();
+ }
+
+ return self::collect_block_units( $blocks, '' );
+ }
+
+ /**
+ * @param array[] $blocks
+ * @param string $path_prefix Dot-joined indices of parent blocks.
+ */
+ private static function collect_block_units( array $blocks, string $path_prefix ): array {
+ $units = array();
+
+ foreach ( $blocks as $index => $block ) {
+ if ( ! is_array( $block ) ) {
+ continue;
+ }
+
+ $path = '' === $path_prefix ? (string) $index : $path_prefix . '.' . $index;
+
+ if ( self::is_acf_block_with_data( $block ) ) {
+ foreach ( self::collect_translatable_entries( $block['attrs']['data'] ) as $key => $value ) {
+ $units[] = array(
+ 'id' => 'acf_block:' . $path . ':' . $key,
+ 'field' => 'acf_block',
+ 'source' => $value,
+ 'format' => 'plain_text',
+ 'lookup_keys' => array(),
+ );
+ }
+ }
+
+ if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
+ $units = array_merge( $units, self::collect_block_units( $block['innerBlocks'], $path ) );
+ }
+ }
+
+ return $units;
+ }
+
+ /**
+ * Merge translated acf_block units back into serialized post content.
+ *
+ * Defensive by design: when the content cannot be parsed, a path no longer
+ * resolves to an acf/* block, or the target key is missing, that unit is
+ * skipped and the content is otherwise returned re-serialized. The block
+ * structure of the translated content must match the source structure
+ * (clients are instructed to preserve block markup).
+ *
+ * @param string $content Serialized block content.
+ * @param array $translations Unit id => translated string.
+ * @return string Updated serialized content.
+ */
+ public static function apply_block_unit_translations( string $content, array $translations ): string {
+ $acf_translations = array();
+ foreach ( $translations as $unit_id => $translated ) {
+ if ( is_string( $unit_id ) && str_starts_with( $unit_id, 'acf_block:' ) && is_string( $translated ) ) {
+ $acf_translations[ $unit_id ] = $translated;
+ }
+ }
+
+ if ( empty( $acf_translations )
+ || '' === trim( $content )
+ || ! function_exists( 'parse_blocks' )
+ || ! function_exists( 'serialize_blocks' )
+ ) {
+ return $content;
+ }
+
+ $blocks = parse_blocks( $content );
+ if ( ! is_array( $blocks ) || empty( $blocks ) ) {
+ return $content;
+ }
+
+ $applied = 0;
+ foreach ( $acf_translations as $unit_id => $translated ) {
+ $parts = explode( ':', $unit_id, 3 );
+ if ( 3 !== count( $parts ) || '' === $parts[1] || '' === $parts[2] ) {
+ continue;
+ }
+
+ if ( self::set_block_data_value( $blocks, explode( '.', $parts[1] ), $parts[2], $translated ) ) {
+ ++$applied;
+ }
+ }
+
+ if ( 0 === $applied ) {
+ return $content;
+ }
+
+ $serialized = serialize_blocks( $blocks );
+ return is_string( $serialized ) && '' !== $serialized ? $serialized : $content;
+ }
+
+ /**
+ * Set one data value at the block addressed by the index path.
+ *
+ * @param array[] $blocks Parsed blocks (modified in place).
+ * @param string[] $path Block index path segments.
+ * @return bool True when the value was written.
+ */
+ private static function set_block_data_value( array &$blocks, array $path, string $key, string $value ): bool {
+ $segment = array_shift( $path );
+ if ( null === $segment || ! ctype_digit( $segment ) ) {
+ return false;
+ }
+
+ $index = (int) $segment;
+ if ( ! isset( $blocks[ $index ] ) || ! is_array( $blocks[ $index ] ) ) {
+ return false;
+ }
+
+ if ( ! empty( $path ) ) {
+ if ( empty( $blocks[ $index ]['innerBlocks'] ) || ! is_array( $blocks[ $index ]['innerBlocks'] ) ) {
+ return false;
+ }
+ return self::set_block_data_value( $blocks[ $index ]['innerBlocks'], $path, $key, $value );
+ }
+
+ if ( ! self::is_acf_block_with_data( $blocks[ $index ] )
+ || ! array_key_exists( $key, $blocks[ $index ]['attrs']['data'] )
+ || ! is_string( $blocks[ $index ]['attrs']['data'][ $key ] )
+ ) {
+ return false;
+ }
+
+ $blocks[ $index ]['attrs']['data'][ $key ] = $value;
+ return true;
+ }
+}
diff --git a/slytranslate/inc/AcfFieldIntrospector.php b/slytranslate/inc/AcfFieldIntrospector.php
new file mode 100644
index 0000000..3278436
--- /dev/null
+++ b/slytranslate/inc/AcfFieldIntrospector.php
@@ -0,0 +1,111 @@
+ array( 'subkeys' => array( 'title' ) ),
+ );
+
+ /**
+ * Field types whose string values are translated as a whole.
+ *
+ * @return string[]
+ */
+ public static function get_translatable_types(): array {
+ $types = apply_filters( 'slytranslate_acf_translatable_field_types', self::TRANSLATABLE_TYPES );
+ return is_array( $types ) ? $types : self::TRANSLATABLE_TYPES;
+ }
+
+ /**
+ * Resolve a 'field_…' reference to its ACF field definition.
+ *
+ * @return array|null Field array with at least a 'type' entry, or null.
+ */
+ public static function get_field_for_ref( ?string $ref ): ?array {
+ if ( ! is_string( $ref ) || ! str_starts_with( $ref, 'field_' ) || ! function_exists( 'acf_get_field' ) ) {
+ return null;
+ }
+
+ $field = acf_get_field( $ref );
+ if ( ! is_array( $field ) || ! isset( $field['type'] ) ) {
+ return null;
+ }
+
+ return $field;
+ }
+
+ /**
+ * True when the field carries the per-field opt-out flag saved by the
+ * "Exclude from AI translation" toggle in the ACF field editor.
+ */
+ public static function is_field_excluded( array $field ): bool {
+ return ! empty( $field['slytranslate_exclude'] );
+ }
+
+ /**
+ * True when the field should be translated.
+ *
+ * @param array $field ACF field definition.
+ * @param bool $include_structured Also accept structured types that have a
+ * value spec (e.g. link). Callers that can
+ * only handle plain string values must pass
+ * false so structured values never reach a
+ * whole-value translation path.
+ */
+ public static function is_translatable_field( array $field, bool $include_structured = true ): bool {
+ if ( self::is_field_excluded( $field ) ) {
+ return false;
+ }
+
+ $type = (string) ( $field['type'] ?? '' );
+ if ( in_array( $type, self::get_translatable_types(), true ) ) {
+ return true;
+ }
+
+ return $include_structured && ! empty( self::get_value_spec( $type ) );
+ }
+
+ /**
+ * True when a 'field_…' reference points to a translatable field.
+ */
+ public static function is_translatable_ref( ?string $ref, bool $include_structured = true ): bool {
+ $field = self::get_field_for_ref( $ref );
+ return null !== $field && self::is_translatable_field( $field, $include_structured );
+ }
+
+ /**
+ * Sub-key translation spec for a structured field type.
+ *
+ * @return array Empty array when the whole value may be translated, or
+ * array( 'subkeys' => string[] ) when only listed sub-keys
+ * of an array value are translatable.
+ */
+ public static function get_value_spec( string $type ): array {
+ return self::VALUE_SPECS[ $type ] ?? array();
+ }
+}
diff --git a/slytranslate/inc/AcfMetaResolver.php b/slytranslate/inc/AcfMetaResolver.php
index 94085e6..a971397 100644
--- a/slytranslate/inc/AcfMetaResolver.php
+++ b/slytranslate/inc/AcfMetaResolver.php
@@ -9,19 +9,41 @@
* when ACF is active. Hooks onto slytranslate_meta_keys_translate so
* third-party callbacks can still run after this resolver.
*
+ * Field-type knowledge lives in AcfFieldIntrospector; this class only walks
+ * post meta, matches '_meta_key' => 'field_…' references and records what it
+ * resolved so describe_effective_meta_keys() can report per-key sources.
+ *
* Only text/textarea/wysiwyg fields are added by default. The list is
* extensible via the slytranslate_acf_translatable_field_types filter.
+ * Structured types with a value spec (e.g. link) are added too; their values
+ * are translated selectively via MetaTranslationService value specs.
*/
class AcfMetaResolver {
- private const TRANSLATABLE_TYPES = array(
- 'text',
- 'textarea',
- 'wysiwyg',
- );
+ /**
+ * Per-request cache of resolved ACF keys, keyed by post ID:
+ * post_id => meta_key => array( field_type, field_label ).
+ *
+ * @var array>
+ */
+ private static $resolved_field_info = array();
public static function register(): void {
add_filter( 'slytranslate_meta_keys_translate', array( self::class, 'add_acf_translatable_keys' ), 10, 5 );
+ add_action( 'acf/render_field_settings', array( self::class, 'render_field_exclude_setting' ) );
+ }
+
+ public static function reset_cache(): void {
+ self::$resolved_field_info = array();
+ }
+
+ /**
+ * Field info recorded while resolving keys for a post.
+ *
+ * @return array
+ */
+ public static function get_resolved_field_info( int $post_id ): array {
+ return self::$resolved_field_info[ $post_id ] ?? array();
}
/**
@@ -40,28 +62,56 @@ public static function add_acf_translatable_keys( array $translate, int $post_id
return $translate;
}
- $translatable_types = apply_filters( 'slytranslate_acf_translatable_field_types', self::TRANSLATABLE_TYPES );
-
foreach ( $post_meta as $meta_key => $values ) {
if ( ! is_string( $meta_key ) || str_starts_with( $meta_key, '_' ) ) {
continue;
}
- $ref = $post_meta[ '_' . $meta_key ][0] ?? null;
- if ( ! is_string( $ref ) || ! str_starts_with( $ref, 'field_' ) ) {
+ $ref = $post_meta[ '_' . $meta_key ][0] ?? null;
+ $field = AcfFieldIntrospector::get_field_for_ref( is_string( $ref ) ? $ref : null );
+ if ( null === $field || ! AcfFieldIntrospector::is_translatable_field( $field ) ) {
continue;
}
- $field = acf_get_field( $ref );
- if ( ! is_array( $field ) || ! isset( $field['type'] ) ) {
- continue;
+ $field_type = (string) $field['type'];
+ $spec = AcfFieldIntrospector::get_value_spec( $field_type );
+ if ( ! empty( $spec ) ) {
+ MetaTranslationService::set_meta_value_spec( $meta_key, $spec, $field_type );
}
- if ( in_array( $field['type'], $translatable_types, true ) ) {
- $translate[] = $meta_key;
- }
+ self::$resolved_field_info[ $post_id ][ $meta_key ] = array(
+ 'field_type' => $field_type,
+ 'field_label' => (string) ( $field['label'] ?? '' ),
+ );
+
+ $translate[] = $meta_key;
}
return $translate;
}
+
+ /**
+ * Render the "Exclude from AI translation" toggle in the ACF field editor
+ * (WPML-style per-field opt-out). The flag is stored in the field array as
+ * slytranslate_exclude and honoured by AcfFieldIntrospector.
+ *
+ * @param array $field The field definition being edited.
+ */
+ public static function render_field_exclude_setting( $field ): void {
+ if ( ! function_exists( 'acf_render_field_setting' ) || ! is_array( $field ) ) {
+ return;
+ }
+
+ acf_render_field_setting(
+ $field,
+ array(
+ 'label' => __( 'Exclude from AI translation', 'slytranslate' ),
+ 'instructions' => __( 'When enabled, SlyTranslate skips this field during automatic translation.', 'slytranslate' ),
+ 'name' => 'slytranslate_exclude',
+ 'type' => 'true_false',
+ 'ui' => 1,
+ ),
+ true
+ );
+ }
}
diff --git a/slytranslate/inc/AcfOptionsTranslationService.php b/slytranslate/inc/AcfOptionsTranslationService.php
new file mode 100644
index 0000000..e7fb801
--- /dev/null
+++ b/slytranslate/inc/AcfOptionsTranslationService.php
@@ -0,0 +1,267 @@
+ 'not_required',
+ 'message' => __( 'TranslatePress translates option output via its string table; option values need no separate translation.', 'slytranslate' ),
+ 'target_language' => $to,
+ 'source_language' => $from,
+ 'dry_run' => $dry_run,
+ 'results' => array(),
+ 'total' => 0,
+ 'translated' => 0,
+ 'skipped' => 0,
+ 'failed' => 0,
+ );
+ }
+
+ $option_fields = self::collect_translatable_option_fields( $page_slug );
+
+ $results = array();
+ $translated = 0;
+ $skipped = 0;
+ $failed = 0;
+
+ foreach ( $option_fields as $entry ) {
+ if ( TranslationProgressTracker::is_cancelled() ) {
+ return new \WP_Error( 'translation_cancelled', 'Translation cancelled.' );
+ }
+
+ $field = $entry['field'];
+ $field_name = (string) $field['name'];
+ $raw_value = self::read_option_value( $field_name );
+
+ if ( ! is_string( $raw_value ) || '' === trim( $raw_value ) ) {
+ ++$skipped;
+ $results[] = self::result_entry( $entry, 'skipped', 'empty_value' );
+ continue;
+ }
+
+ $supports_inline = $adapter instanceof WpglobusAdapter || $adapter instanceof WpMultilangAdapter;
+ $source_text = $supports_inline
+ ? $adapter->get_language_variant( $raw_value, $from )
+ : $raw_value;
+
+ if ( '' === trim( $source_text ) ) {
+ ++$skipped;
+ $results[] = self::result_entry( $entry, 'skipped', 'no_source_variant' );
+ continue;
+ }
+
+ // Without language-aware option storage the only safe write target
+ // is a per-language post_id provided via filter (Polylang pattern:
+ // 'options_{lang}' through acf/settings/current_language).
+ $target_post_id = 'options';
+ if ( ! $supports_inline ) {
+ $target_post_id = (string) apply_filters( 'slytranslate_acf_options_post_id', 'options', $to, $field, $adapter );
+ if ( 'options' === $target_post_id ) {
+ ++$skipped;
+ $results[] = self::result_entry( $entry, 'skipped', 'requires_options_post_id_filter' );
+ continue;
+ }
+ }
+
+ if ( $dry_run ) {
+ ++$translated;
+ $results[] = self::result_entry( $entry, 'pending' );
+ continue;
+ }
+
+ $translated_text = TranslationRuntime::translate_text( $source_text, $to, $from, '' );
+ if ( is_wp_error( $translated_text ) ) {
+ if ( 'translation_cancelled' === $translated_text->get_error_code() ) {
+ return $translated_text;
+ }
+ ++$failed;
+ $results[] = self::result_entry( $entry, 'failed', $translated_text->get_error_code() );
+ continue;
+ }
+
+ if ( $supports_inline ) {
+ $new_value = $adapter->merge_language_variant( $raw_value, $from, $to, (string) $translated_text );
+ self::write_option_value( $field, $new_value, 'options' );
+ } else {
+ self::write_option_value( $field, (string) $translated_text, $target_post_id );
+ }
+
+ ++$translated;
+ $results[] = self::result_entry( $entry, 'translated' );
+ }
+
+ return array(
+ 'status' => 'ok',
+ 'message' => '',
+ 'target_language' => $to,
+ 'source_language' => $from,
+ 'dry_run' => $dry_run,
+ 'results' => $results,
+ 'total' => count( $results ),
+ 'translated' => $translated,
+ 'skipped' => $skipped,
+ 'failed' => $failed,
+ );
+ }
+
+ /**
+ * Collect translatable fields across all (or one) ACF options pages.
+ *
+ * Only top-level fields with plainly translatable types are returned;
+ * structured types and nested repeater/group sub-fields are out of scope
+ * for the options path.
+ *
+ * @return array
+ */
+ public static function collect_translatable_option_fields( string $page_slug = '' ): array {
+ $pages = acf_get_options_pages();
+ if ( ! is_array( $pages ) || empty( $pages ) ) {
+ return array();
+ }
+
+ $collected = array();
+ $seen_keys = array();
+
+ foreach ( $pages as $page ) {
+ if ( ! is_array( $page ) ) {
+ continue;
+ }
+
+ $slug = (string) ( $page['menu_slug'] ?? '' );
+ if ( '' === $slug || ( '' !== $page_slug && $slug !== $page_slug ) ) {
+ continue;
+ }
+
+ $groups = acf_get_field_groups( array( 'options_page' => $slug ) );
+ if ( ! is_array( $groups ) ) {
+ continue;
+ }
+
+ foreach ( $groups as $group ) {
+ $fields = acf_get_fields( $group );
+ if ( ! is_array( $fields ) ) {
+ continue;
+ }
+
+ foreach ( $fields as $field ) {
+ if ( ! is_array( $field ) || ! isset( $field['name'], $field['type'] ) ) {
+ continue;
+ }
+
+ $field_key = (string) ( $field['key'] ?? $field['name'] );
+ if ( isset( $seen_keys[ $field_key ] ) ) {
+ continue;
+ }
+
+ if ( ! AcfFieldIntrospector::is_translatable_field( $field, false ) ) {
+ continue;
+ }
+
+ $seen_keys[ $field_key ] = true;
+ $collected[] = array(
+ 'page' => $slug,
+ 'field' => $field,
+ );
+ }
+ }
+ }
+
+ return $collected;
+ }
+
+ /**
+ * Raw stored option value for an ACF options field.
+ */
+ private static function read_option_value( string $field_name ) {
+ if ( function_exists( 'get_field' ) ) {
+ return get_field( $field_name, 'options', false );
+ }
+ return get_option( 'options_' . $field_name, '' );
+ }
+
+ /**
+ * Persist a translated option value through ACF when possible (keeps the
+ * field reference keys intact), falling back to the raw option.
+ */
+ private static function write_option_value( array $field, string $value, string $post_id ): void {
+ if ( function_exists( 'update_field' ) ) {
+ update_field( (string) ( $field['key'] ?? $field['name'] ), $value, $post_id );
+ return;
+ }
+
+ $prefix = 'options' === $post_id ? 'options' : $post_id;
+ update_option( $prefix . '_' . (string) $field['name'], $value, false );
+ }
+
+ /**
+ * @param array{page: string, field: array} $entry
+ */
+ private static function result_entry( array $entry, string $status, string $reason = '' ): array {
+ return array(
+ 'field' => (string) $entry['field']['name'],
+ 'field_label' => (string) ( $entry['field']['label'] ?? '' ),
+ 'field_type' => (string) ( $entry['field']['type'] ?? '' ),
+ 'page' => $entry['page'],
+ 'status' => $status,
+ 'reason' => $reason,
+ );
+ }
+}
diff --git a/slytranslate/inc/AutoPublishTranslationService.php b/slytranslate/inc/AutoPublishTranslationService.php
new file mode 100644
index 0000000..83b8ac2
--- /dev/null
+++ b/slytranslate/inc/AutoPublishTranslationService.php
@@ -0,0 +1,94 @@
+ID < 1 ) {
+ return;
+ }
+ if ( get_option( 'slytranslate_new_post', '0' ) !== '1' ) {
+ return;
+ }
+
+ // Loop guard: translations created by this plugin must not trigger
+ // another round of translations.
+ if ( get_post_meta( $post->ID, self::GENERATED_META_KEY, true ) ) {
+ return;
+ }
+
+ $adapter = AI_Translate::get_adapter();
+ if ( ! $adapter || AI_Translate::is_single_entry_translation_mode() ) {
+ return;
+ }
+
+ if ( is_wp_error( TranslationQueryService::validate_translatable_post_type( (string) $post->post_type ) ) ) {
+ return;
+ }
+
+ // Only translate posts in the source language: secondary-language posts
+ // are usually translations themselves.
+ $source_language = (string) ( $adapter->get_post_language( $post->ID ) ?? '' );
+ if ( '' === $source_language ) {
+ return;
+ }
+ if ( function_exists( 'pll_default_language' ) && (string) \pll_default_language() !== $source_language ) {
+ return;
+ }
+
+ if ( ! TranslationQueue::is_available() ) {
+ TimingLogger::log( 'auto_translate_skipped', array(
+ 'post' => $post->ID,
+ 'reason' => 'queue_unavailable',
+ ) );
+ return;
+ }
+
+ $existing_translations = $adapter->get_post_translations( $post->ID );
+ $existing_translations = is_array( $existing_translations ) ? $existing_translations : array();
+
+ foreach ( array_keys( $adapter->get_languages() ) as $language_code ) {
+ $language_code = sanitize_key( (string) $language_code );
+ if ( '' === $language_code || $language_code === $source_language ) {
+ continue;
+ }
+ if ( ! empty( $existing_translations[ $language_code ] ) ) {
+ continue;
+ }
+
+ TranslationQueue::enqueue_bulk(
+ array( $post->ID ),
+ $language_code,
+ array(
+ 'post_status' => 'draft',
+ 'overwrite' => false,
+ )
+ );
+ }
+ }
+}
diff --git a/slytranslate/inc/ClientTranslationWorkflowService.php b/slytranslate/inc/ClientTranslationWorkflowService.php
index 434bad1..4e730ef 100644
--- a/slytranslate/inc/ClientTranslationWorkflowService.php
+++ b/slytranslate/inc/ClientTranslationWorkflowService.php
@@ -147,6 +147,21 @@ public static function apply_single( array $input ): mixed {
}
} else {
$translated_content = isset( $by_id['content'] ) ? (string) $by_id['content'] : null;
+
+ // Merge translated ACF block units into the content. The translated
+ // content (when provided) keeps the source block structure, so the
+ // acf_block unit paths from the prepare step still resolve.
+ $has_acf_block_units = false;
+ foreach ( $by_id as $unit_id => $unused ) {
+ if ( str_starts_with( (string) $unit_id, 'acf_block:' ) ) {
+ $has_acf_block_units = true;
+ break;
+ }
+ }
+ if ( $has_acf_block_units ) {
+ $content_for_injection = null !== $translated_content ? $translated_content : (string) $post->post_content;
+ $translated_content = AcfBlockTranslator::apply_block_unit_translations( $content_for_injection, $by_id );
+ }
}
// Collect translated meta.
@@ -480,6 +495,13 @@ private static function build_units_for_post(
'format' => 'html',
'lookup_keys' => array(),
);
+
+ // ACF block field data lives in the block-comment JSON, which clients
+ // must preserve verbatim inside the content unit — so those values
+ // are exposed as their own units and merged back on apply.
+ foreach ( AcfBlockTranslator::build_block_units( (string) $post->post_content ) as $acf_unit ) {
+ $units[] = $acf_unit;
+ }
}
// Excerpt unit.
@@ -505,9 +527,11 @@ private static function build_units_for_post(
/**
* Compute a source hash over title, content, and excerpt to detect
* source changes between prepare and apply calls.
+ *
+ * Shared with the server-side diff retranslation via TranslationFingerprint.
*/
private static function compute_source_hash( \WP_Post $post ): string {
- return md5( $post->post_title . "\x00" . $post->post_content . "\x00" . $post->post_excerpt );
+ return TranslationFingerprint::compute_post_fingerprint( $post );
}
/**
diff --git a/slytranslate/inc/ConfigurationService.php b/slytranslate/inc/ConfigurationService.php
index dc887a5..9120c8a 100644
--- a/slytranslate/inc/ConfigurationService.php
+++ b/slytranslate/inc/ConfigurationService.php
@@ -35,9 +35,26 @@ public static function save( array $input ): ?\WP_Error {
if ( isset( $input['meta_keys_clear'] ) ) {
update_option( 'slytranslate_meta_clear', sanitize_textarea_field( $input['meta_keys_clear'] ), false );
}
+ if ( isset( $input['meta_keys_exclude'] ) ) {
+ update_option( 'slytranslate_meta_keys_exclude', sanitize_textarea_field( $input['meta_keys_exclude'] ), false );
+ }
if ( isset( $input['auto_translate_new'] ) ) {
update_option( 'slytranslate_new_post', $input['auto_translate_new'] ? '1' : '0', false );
}
+ if ( isset( $input['translate_terms'] ) ) {
+ update_option( 'slytranslate_translate_terms', $input['translate_terms'] ? '1' : '0', false );
+ }
+ if ( isset( $input['translate_slugs'] ) ) {
+ update_option( 'slytranslate_translate_slugs', $input['translate_slugs'] ? '1' : '0', false );
+ }
+ if ( array_key_exists( 'glossary', $input ) ) {
+ $glossary = GlossaryService::sanitize_entries( $input['glossary'] );
+ if ( empty( $glossary ) ) {
+ delete_option( 'slytranslate_glossary' );
+ } else {
+ update_option( 'slytranslate_glossary', $glossary, false );
+ }
+ }
if ( isset( $input['context_window_tokens'] ) ) {
$context_window_tokens = min( 4000000, absint( $input['context_window_tokens'] ) );
if ( $context_window_tokens > 0 ) {
diff --git a/slytranslate/inc/ContentTranslator.php b/slytranslate/inc/ContentTranslator.php
index 5f80a4f..6a20380 100644
--- a/slytranslate/inc/ContentTranslator.php
+++ b/slytranslate/inc/ContentTranslator.php
@@ -111,6 +111,15 @@ private static function do_translate_block_sections(
string $from,
string $additional_prompt
): mixed {
+ // Pre-pass: translate ACF block field data (attrs.data of acf/* blocks)
+ // before the regular content pipeline runs. The pipeline preserves block
+ // comments (and therefore the attrs JSON) via placeholders, so the
+ // translated data survives all downstream paths unchanged.
+ $blocks = AcfBlockTranslator::translate_blocks_data( $blocks, $to, $from, $additional_prompt );
+ if ( is_wp_error( $blocks ) ) {
+ return $blocks;
+ }
+
$translated_sections = array();
$pending_blocks = array();
$chunk_char_limit = TranslationRuntime::get_chunk_char_limit();
diff --git a/slytranslate/inc/GlossaryService.php b/slytranslate/inc/GlossaryService.php
new file mode 100644
index 0000000..0631e1a
--- /dev/null
+++ b/slytranslate/inc/GlossaryService.php
@@ -0,0 +1,137 @@
+ 'SlyBase', 'mode' => 'keep' )
+ * array( 'term' => 'Anleitung', 'mode' => 'translate', 'to' => array( 'en' => 'Guide' ) )
+ *
+ * The glossary is injected into the prompt as a compact instruction block.
+ */
+class GlossaryService {
+
+ private const MAX_ENTRIES = 500;
+
+ /**
+ * Glossary entries above this count are pre-filtered against the source
+ * text so very large glossaries do not eat the context window.
+ */
+ private const PREFILTER_THRESHOLD = 20;
+
+ /**
+ * @return array}>
+ */
+ public static function get_entries(): array {
+ return self::sanitize_entries( get_option( 'slytranslate_glossary', array() ) );
+ }
+
+ /**
+ * Sanitizer for the slytranslate_glossary option (Settings API + configure).
+ *
+ * @param mixed $value Raw option value.
+ * @return array}>
+ */
+ public static function sanitize_entries( $value ): array {
+ if ( ! is_array( $value ) ) {
+ return array();
+ }
+
+ $entries = array();
+ foreach ( $value as $entry ) {
+ if ( ! is_array( $entry ) ) {
+ continue;
+ }
+
+ $term = isset( $entry['term'] ) && is_string( $entry['term'] ) ? sanitize_text_field( $entry['term'] ) : '';
+ if ( '' === $term ) {
+ continue;
+ }
+
+ $mode = isset( $entry['mode'] ) && is_string( $entry['mode'] ) ? sanitize_key( $entry['mode'] ) : 'keep';
+ if ( ! in_array( $mode, array( 'keep', 'translate' ), true ) ) {
+ $mode = 'keep';
+ }
+
+ $to = array();
+ if ( 'translate' === $mode && isset( $entry['to'] ) && is_array( $entry['to'] ) ) {
+ foreach ( $entry['to'] as $language_code => $translation ) {
+ $code = sanitize_key( (string) $language_code );
+ $translation = is_string( $translation ) ? sanitize_text_field( $translation ) : '';
+ if ( '' !== $code && '' !== $translation ) {
+ $to[ $code ] = $translation;
+ }
+ }
+ }
+
+ if ( 'translate' === $mode && empty( $to ) ) {
+ // A fixed-translation entry without translations is useless.
+ continue;
+ }
+
+ $entries[] = array( 'term' => $term, 'mode' => $mode, 'to' => $to );
+ if ( count( $entries ) >= self::MAX_ENTRIES ) {
+ break;
+ }
+ }
+
+ return $entries;
+ }
+
+ /**
+ * Build the prompt block for the target language.
+ *
+ * Returns an empty string when the glossary is empty or no entry applies.
+ * When the glossary is large and source text is available, entries are
+ * pre-filtered to terms actually present in the text.
+ *
+ * @param string $to Target language code.
+ * @param string $source_text Full source text of the current translation call ('' = no filtering).
+ */
+ public static function build_prompt_block( string $to, string $source_text = '' ): string {
+ $entries = self::get_entries();
+ if ( empty( $entries ) ) {
+ return '';
+ }
+
+ if ( '' !== $source_text && count( $entries ) > self::PREFILTER_THRESHOLD ) {
+ $entries = array_values( array_filter(
+ $entries,
+ static function ( array $entry ) use ( $source_text ): bool {
+ return false !== stripos( $source_text, $entry['term'] );
+ }
+ ) );
+ }
+
+ $keep = array();
+ $fixed = array();
+ foreach ( $entries as $entry ) {
+ if ( 'keep' === $entry['mode'] ) {
+ $keep[] = '"' . $entry['term'] . '"';
+ continue;
+ }
+ if ( isset( $entry['to'][ $to ] ) ) {
+ $fixed[] = 'Always translate "' . $entry['term'] . '" as "' . $entry['to'][ $to ] . '".';
+ }
+ }
+
+ $lines = array();
+ if ( ! empty( $keep ) ) {
+ $lines[] = 'Never translate the following terms, keep them exactly as written: ' . implode( ', ', $keep ) . '.';
+ }
+ if ( ! empty( $fixed ) ) {
+ $lines[] = implode( ' ', $fixed );
+ }
+
+ if ( empty( $lines ) ) {
+ return '';
+ }
+
+ return "Glossary rules (do NOT translate these instructions, apply them to the user-provided content):\n" . implode( "\n", $lines );
+ }
+}
diff --git a/slytranslate/inc/MediaTranslationService.php b/slytranslate/inc/MediaTranslationService.php
new file mode 100644
index 0000000..270063c
--- /dev/null
+++ b/slytranslate/inc/MediaTranslationService.php
@@ -0,0 +1,121 @@
+ attributes inside post content are already handled by
+ * the regular content pipeline — this service covers the attachment-meta
+ * side, which stays in the source language otherwise.
+ */
+class MediaTranslationService {
+
+ public const ALT_TEXT_META_KEY = '_wp_attachment_image_alt';
+
+ /**
+ * Hook callback for Polylang's `pll_translate_media` action, fired after
+ * Polylang created a media translation.
+ *
+ * @param int $source_attachment_id Source attachment ID.
+ * @param int $new_attachment_id Newly created attachment translation ID.
+ * @param string $target_language Language of the new attachment.
+ */
+ public static function handle_pll_translate_media( $source_attachment_id, $new_attachment_id, $target_language ): void {
+ $source_attachment_id = absint( $source_attachment_id );
+ $new_attachment_id = absint( $new_attachment_id );
+ $to = sanitize_key( (string) $target_language );
+
+ if ( $source_attachment_id < 1 || $new_attachment_id < 1 || '' === $to || $source_attachment_id === $new_attachment_id ) {
+ return;
+ }
+
+ $from = function_exists( 'pll_get_post_language' ) ? (string) \pll_get_post_language( $source_attachment_id ) : '';
+ if ( '' === $from || $from === $to ) {
+ return;
+ }
+
+ // Alt text.
+ $alt = get_post_meta( $source_attachment_id, self::ALT_TEXT_META_KEY, true );
+ if ( is_string( $alt ) && '' !== trim( $alt ) ) {
+ $translated_alt = TranslationRuntime::translate_text( $alt, $to, $from, self::get_attachment_text_hint() );
+ if ( ! is_wp_error( $translated_alt ) && '' !== trim( (string) $translated_alt ) ) {
+ update_post_meta( $new_attachment_id, self::ALT_TEXT_META_KEY, trim( (string) $translated_alt ) );
+ }
+ }
+
+ // Caption (post_excerpt) and description (post_content).
+ $attachment = get_post( $new_attachment_id );
+ if ( ! $attachment ) {
+ return;
+ }
+
+ $update = array();
+ foreach ( array( 'post_excerpt', 'post_content' ) as $field ) {
+ $value = (string) $attachment->$field;
+ if ( '' === trim( $value ) ) {
+ continue;
+ }
+ $translated = TranslationRuntime::translate_text( $value, $to, $from, self::get_attachment_text_hint() );
+ if ( ! is_wp_error( $translated ) && '' !== trim( (string) $translated ) ) {
+ $update[ $field ] = (string) $translated;
+ }
+ }
+
+ if ( ! empty( $update ) ) {
+ $update['ID'] = $new_attachment_id;
+ wp_update_post( wp_slash( $update ) );
+ }
+ }
+
+ /**
+ * After a post translation was saved: translate the alt text of the
+ * translated post's featured image when Polylang media translation is
+ * active and the target attachment's alt is still empty.
+ *
+ * Best effort — failures never break the post translation that already
+ * succeeded.
+ */
+ public static function maybe_translate_featured_image_alt( int $source_post_id, int $translated_post_id, string $to, string $from ): void {
+ if ( $source_post_id < 1 || $translated_post_id < 1 || $source_post_id === $translated_post_id ) {
+ return;
+ }
+ if ( ! function_exists( 'get_post_thumbnail_id' ) ) {
+ return;
+ }
+ if ( function_exists( 'pll_get_option' ) && ! \pll_get_option( 'media_support' ) ) {
+ return;
+ }
+
+ $source_thumbnail = absint( get_post_thumbnail_id( $source_post_id ) );
+ $target_thumbnail = absint( get_post_thumbnail_id( $translated_post_id ) );
+ if ( $source_thumbnail < 1 || $target_thumbnail < 1 || $source_thumbnail === $target_thumbnail ) {
+ return;
+ }
+
+ $target_alt = get_post_meta( $target_thumbnail, self::ALT_TEXT_META_KEY, true );
+ if ( is_string( $target_alt ) && '' !== trim( $target_alt ) ) {
+ return;
+ }
+
+ $source_alt = get_post_meta( $source_thumbnail, self::ALT_TEXT_META_KEY, true );
+ if ( ! is_string( $source_alt ) || '' === trim( $source_alt ) ) {
+ return;
+ }
+
+ $translated_alt = TranslationRuntime::translate_text( $source_alt, $to, $from, self::get_attachment_text_hint() );
+ if ( is_wp_error( $translated_alt ) || '' === trim( (string) $translated_alt ) ) {
+ return;
+ }
+
+ update_post_meta( $target_thumbnail, self::ALT_TEXT_META_KEY, trim( (string) $translated_alt ) );
+ }
+
+ private static function get_attachment_text_hint(): string {
+ return 'The input is a short image description (alt text or caption). Translate it concisely and return only the translated text without HTML or quotes.';
+ }
+}
diff --git a/slytranslate/inc/MetaBoxMetaResolver.php b/slytranslate/inc/MetaBoxMetaResolver.php
new file mode 100644
index 0000000..2236d36
--- /dev/null
+++ b/slytranslate/inc/MetaBoxMetaResolver.php
@@ -0,0 +1,63 @@
+ [value]).
+ * @return array Updated translate list.
+ */
+ public static function add_metabox_translatable_keys( array $translate, int $post_id, string $from, string $to, array $post_meta ): array {
+ if ( empty( $post_meta ) || ! function_exists( 'rwmb_get_field_settings' ) ) {
+ return $translate;
+ }
+
+ $translatable_types = apply_filters( 'slytranslate_metabox_translatable_field_types', self::TRANSLATABLE_TYPES );
+ $translatable_types = is_array( $translatable_types ) ? $translatable_types : self::TRANSLATABLE_TYPES;
+
+ foreach ( $post_meta as $meta_key => $values ) {
+ if ( ! is_string( $meta_key ) || str_starts_with( $meta_key, '_' ) || in_array( $meta_key, $translate, true ) ) {
+ continue;
+ }
+
+ $settings = rwmb_get_field_settings( $meta_key, array( 'object_type' => 'post' ), $post_id );
+ if ( ! is_array( $settings ) ) {
+ continue;
+ }
+
+ if ( in_array( (string) ( $settings['type'] ?? '' ), $translatable_types, true ) ) {
+ $translate[] = $meta_key;
+ }
+ }
+
+ return $translate;
+ }
+}
diff --git a/slytranslate/inc/MetaTranslationService.php b/slytranslate/inc/MetaTranslationService.php
index 63b2971..2c3db89 100644
--- a/slytranslate/inc/MetaTranslationService.php
+++ b/slytranslate/inc/MetaTranslationService.php
@@ -29,6 +29,17 @@ class MetaTranslationService {
private const INTERNAL_META_PREFIXES_TO_SKIP = array(
'_oembed_',
+ '_slytranslate_',
+ );
+
+ /**
+ * Meta keys translated by default on every install. Alt texts are SEO- and
+ * accessibility-relevant and there is no legitimate reason to keep them in
+ * the source language; the exclusion list and the per-key filter can still
+ * veto the key.
+ */
+ public const DEFAULT_TRANSLATE_META_KEYS = array(
+ '_wp_attachment_image_alt',
);
/* ---------------------------------------------------------------
@@ -40,6 +51,15 @@ class MetaTranslationService {
private static $seo_plugin_config = null;
private static $resolved_meta_key_config = array();
+ /**
+ * Per-request value-spec registry for structured meta values:
+ * meta_key => array( 'spec' => array( 'subkeys' => string[] ), 'field_type' => string ).
+ *
+ * Populated by resolvers (e.g. AcfMetaResolver for ACF link fields) so
+ * translate_meta_value_for_key() only translates the listed sub-keys.
+ */
+ private static $meta_value_specs = array();
+
/* ---------------------------------------------------------------
* Public helpers used by AI_Translate and tests
* ------------------------------------------------------------- */
@@ -71,6 +91,67 @@ public static function reset_cache(): void {
self::$meta_clear = null;
self::$seo_plugin_config = null;
self::$resolved_meta_key_config = array();
+ self::$meta_value_specs = array();
+ AcfMetaResolver::reset_cache();
+ }
+
+ /* ---------------------------------------------------------------
+ * Value specs for structured meta values
+ * ------------------------------------------------------------- */
+
+ /**
+ * Register a sub-key translation spec for a meta key.
+ *
+ * @param string $meta_key Meta key the spec applies to.
+ * @param array $spec array( 'subkeys' => string[] ).
+ * @param string $field_type Originating field type (e.g. 'link').
+ */
+ public static function set_meta_value_spec( string $meta_key, array $spec, string $field_type = '' ): void {
+ if ( '' === $meta_key ) {
+ return;
+ }
+ self::$meta_value_specs[ $meta_key ] = array(
+ 'spec' => $spec,
+ 'field_type' => $field_type,
+ );
+ }
+
+ /**
+ * Registered value spec for a meta key (empty array when none).
+ *
+ * @return array{spec: array, field_type: string}
+ */
+ public static function get_meta_value_spec( string $meta_key ): array {
+ return self::$meta_value_specs[ $meta_key ] ?? array( 'spec' => array(), 'field_type' => '' );
+ }
+
+ /* ---------------------------------------------------------------
+ * Exclusion list (slytranslate_meta_keys_exclude option)
+ * ------------------------------------------------------------- */
+
+ /**
+ * User-configured meta keys excluded from translation.
+ *
+ * @return string[]
+ */
+ public static function meta_keys_exclude(): array {
+ return self::meta_keys( 'slytranslate_meta_keys_exclude' );
+ }
+
+ /**
+ * Core callback on slytranslate_translate_meta_key (priority 5): vetoes
+ * keys on the exclusion list. Third-party callbacks at default priority 10
+ * can still overrule this, keeping the filter API the final authority.
+ *
+ * @param bool|mixed $include Whether the key is included so far.
+ * @param string $meta_key Meta key being decided.
+ * @return bool|mixed
+ */
+ public static function filter_excluded_meta_key( $include, $meta_key ) {
+ if ( in_array( (string) $meta_key, self::meta_keys_exclude(), true ) ) {
+ return false;
+ }
+ return $include;
}
/* ---------------------------------------------------------------
@@ -86,6 +167,9 @@ public static function reset_cache(): void {
* not exist in real post meta. Successful batch
* translations are returned as part of the result so
* the caller can extract and clean them up.
+ * @param array $skip_keys Meta keys to omit from the result entirely, e.g.
+ * because the existing translation already holds an
+ * up-to-date value (diff retranslation).
* @return array|\WP_Error
*/
public static function prepare_translation_meta(
@@ -94,9 +178,13 @@ public static function prepare_translation_meta(
string $from,
string $additional_prompt,
array $all_meta = array(),
- array $extra_candidates = array()
+ array $extra_candidates = array(),
+ array $skip_keys = array()
): array|\WP_Error {
$meta = ! empty( $all_meta ) ? $all_meta : get_post_meta( $post_id );
+ if ( ! empty( $skip_keys ) && is_array( $meta ) ) {
+ $meta = array_diff_key( $meta, array_flip( $skip_keys ) );
+ }
$processed_meta = array();
$meta_key_config = self::get_effective_meta_key_config( $post_id, is_array( $meta ) ? $meta : array(), $from, $to );
@@ -318,6 +406,10 @@ private static function collect_batch_candidates(
if ( 'slim_seo' === $key ) {
continue;
}
+ // Keys with a value spec need sub-key handling — skip from batching.
+ if ( ! empty( self::get_meta_value_spec( (string) $key )['spec'] ) ) {
+ continue;
+ }
$value = maybe_unserialize( $values[0] ?? '' );
if ( ! is_string( $value ) || '' === trim( $value ) ) {
@@ -397,6 +489,38 @@ public static function translate_meta_value_for_key(
return $translated_value;
}
+ // Structured values with a sub-key spec (e.g. ACF link arrays): only the
+ // listed sub-keys are translated, everything else (url, target, …) is
+ // copied unchanged. Third parties can adjust or provide specs here.
+ $registered = self::get_meta_value_spec( $meta_key );
+ $spec = apply_filters(
+ 'slytranslate_meta_value_translation_spec',
+ $registered['spec'],
+ $meta_key,
+ $registered['field_type']
+ );
+
+ if ( is_array( $spec ) && ! empty( $spec['subkeys'] ) ) {
+ // A spec means the value has a fixed structure; non-array values
+ // (e.g. a bare URL string from a link field) are never translated.
+ if ( ! is_array( $value ) ) {
+ return $value;
+ }
+
+ $translated_value = $value;
+ foreach ( (array) $spec['subkeys'] as $subkey ) {
+ if ( ! array_key_exists( $subkey, $translated_value ) ) {
+ continue;
+ }
+ $translated_subkey = self::translate_meta_value( $translated_value[ $subkey ], $to, $from, $additional_prompt );
+ if ( is_wp_error( $translated_subkey ) ) {
+ return $translated_subkey;
+ }
+ $translated_value[ $subkey ] = $translated_subkey;
+ }
+ return $translated_value;
+ }
+
return self::translate_meta_value( $value, $to, $from, $additional_prompt );
}
@@ -432,7 +556,7 @@ public static function get_effective_meta_key_config( int $post_id = 0, ?array $
}
$meta_key_config = array(
- 'translate' => self::merge_meta_keys( self::meta_keys( 'slytranslate_meta_translate' ), $seo_plugin_config['translate'] ),
+ 'translate' => self::merge_meta_keys( self::meta_keys( 'slytranslate_meta_translate' ), $seo_plugin_config['translate'], self::DEFAULT_TRANSLATE_META_KEYS ),
'clear' => self::merge_meta_keys( self::meta_keys( 'slytranslate_meta_clear' ), $seo_plugin_config['clear'] ),
'seo' => $seo_plugin_config,
);
@@ -469,6 +593,110 @@ public static function get_effective_meta_key_config( int $post_id = 0, ?array $
return $meta_key_config;
}
+ /**
+ * Describe the effective translate keys with their source for UIs and
+ * introspection abilities.
+ *
+ * Sources: 'manual' (slytranslate_meta_translate option), 'seo' (detected
+ * SEO plugin), 'acf' (resolved by AcfMetaResolver), 'filter' (added by a
+ * third-party slytranslate_meta_keys_translate callback). Keys on the
+ * exclusion list are included with excluded=true even though they are
+ * removed from the effective translate list.
+ *
+ * @return array
+ */
+ public static function describe_effective_meta_keys( int $post_id = 0 ): array {
+ $post_meta = null;
+ if ( $post_id > 0 ) {
+ $post_meta = get_post_meta( $post_id );
+ $post_meta = is_array( $post_meta ) ? $post_meta : array();
+ }
+
+ $config = self::get_effective_meta_key_config( $post_id, $post_meta );
+ $manual_keys = self::meta_keys( 'slytranslate_meta_translate' );
+ $seo_keys = is_array( $config['seo']['translate'] ?? null ) ? $config['seo']['translate'] : array();
+ $excluded = self::meta_keys_exclude();
+ $acf_info = AcfMetaResolver::get_resolved_field_info( $post_id );
+
+ // Excluded keys are missing from the effective list but should still be
+ // reported (with excluded=true) when a known source would provide them.
+ $known_source_keys = array_merge( $manual_keys, $seo_keys, array_keys( $acf_info ), self::DEFAULT_TRANSLATE_META_KEYS );
+ $all_keys = self::merge_meta_keys( $config['translate'], array_intersect( $excluded, $known_source_keys ) );
+
+ $described = array();
+ foreach ( $all_keys as $meta_key ) {
+ if ( isset( $acf_info[ $meta_key ] ) ) {
+ $source = 'acf';
+ } elseif ( in_array( $meta_key, $manual_keys, true ) ) {
+ $source = 'manual';
+ } elseif ( in_array( $meta_key, $seo_keys, true ) ) {
+ $source = 'seo';
+ } elseif ( in_array( $meta_key, self::DEFAULT_TRANSLATE_META_KEYS, true ) ) {
+ $source = 'default';
+ } else {
+ $source = 'filter';
+ }
+
+ $described[] = array(
+ 'key' => $meta_key,
+ 'source' => $source,
+ 'field_label' => (string) ( $acf_info[ $meta_key ]['field_label'] ?? '' ),
+ 'field_type' => (string) ( $acf_info[ $meta_key ]['field_type'] ?? '' ),
+ 'excluded' => in_array( $meta_key, $excluded, true ),
+ );
+ }
+
+ return $described;
+ }
+
+ /**
+ * Full introspection report for the get-translatable-fields ability and
+ * the settings UI: effective translate keys (with source attribution),
+ * clear keys, detected SEO plugin, and active field-plugin resolvers.
+ *
+ * @param int $post_id Optional post context (0 = global view).
+ */
+ public static function get_translatable_fields_report( int $post_id = 0 ): array {
+ $fields = array();
+ foreach ( self::describe_effective_meta_keys( $post_id ) as $entry ) {
+ $entry['action'] = 'translate';
+ $fields[] = $entry;
+ }
+
+ $seo_config = self::get_active_seo_plugin_config();
+ $seo_clear = is_array( $seo_config['clear'] ?? null ) ? $seo_config['clear'] : array();
+ $excluded = self::meta_keys_exclude();
+ foreach ( self::meta_clear( $post_id ) as $meta_key ) {
+ $fields[] = array(
+ 'key' => $meta_key,
+ 'source' => in_array( $meta_key, $seo_clear, true ) ? 'seo' : 'manual',
+ 'field_label' => '',
+ 'field_type' => '',
+ 'excluded' => in_array( $meta_key, $excluded, true ),
+ 'action' => 'clear',
+ );
+ }
+
+ $active_resolvers = array();
+ if ( function_exists( 'acf_get_field' ) ) {
+ $active_resolvers[] = 'acf';
+ }
+ if ( function_exists( 'rwmb_get_field_settings' ) ) {
+ $active_resolvers[] = 'metabox';
+ }
+ if ( function_exists( 'pods_api' ) ) {
+ $active_resolvers[] = 'pods';
+ }
+
+ return array(
+ 'post_id' => $post_id,
+ 'fields' => $fields,
+ 'seo_plugin' => (string) ( $seo_config['key'] ?? '' ),
+ 'seo_plugin_label' => (string) ( $seo_config['label'] ?? '' ),
+ 'active_resolvers' => $active_resolvers,
+ );
+ }
+
private static function get_runtime_source_meta_keys( array $post_meta ): array {
$meta_keys = array();
foreach ( $post_meta as $meta_key => $values ) {
diff --git a/slytranslate/inc/Plugin.php b/slytranslate/inc/Plugin.php
index bebdd65..5d0cbda 100644
--- a/slytranslate/inc/Plugin.php
+++ b/slytranslate/inc/Plugin.php
@@ -11,7 +11,7 @@
* and Plugin::EDITOR_SCRIPT instead of duplicating the strings.
*/
final class Plugin {
- public const VERSION = '1.10.0';
+ public const VERSION = '1.11.0';
public const REST_NAMESPACE = 'ai-translate/v1';
public const EDITOR_SCRIPT = 'ai-translate-editor';
@@ -19,5 +19,11 @@ public static function register_optional_integrations(): void {
if ( function_exists( 'acf_get_field' ) ) {
AcfMetaResolver::register();
}
+ if ( function_exists( 'rwmb_get_field_settings' ) ) {
+ MetaBoxMetaResolver::register();
+ }
+ if ( function_exists( 'pods_api' ) ) {
+ PodsMetaResolver::register();
+ }
}
}
diff --git a/slytranslate/inc/PodsMetaResolver.php b/slytranslate/inc/PodsMetaResolver.php
new file mode 100644
index 0000000..fb33d47
--- /dev/null
+++ b/slytranslate/inc/PodsMetaResolver.php
@@ -0,0 +1,112 @@
+load_pod() and matches
+ * meta keys against the registered field names.
+ *
+ * Only text/paragraph/wysiwyg fields are added by default; extensible via the
+ * slytranslate_pods_translatable_field_types filter.
+ */
+class PodsMetaResolver {
+
+ private const TRANSLATABLE_TYPES = array(
+ 'text',
+ 'paragraph',
+ 'wysiwyg',
+ );
+
+ public static function register(): void {
+ add_filter( 'slytranslate_meta_keys_translate', array( self::class, 'add_pods_translatable_keys' ), 10, 5 );
+ }
+
+ /**
+ * @param array $translate Current list of keys to translate.
+ * @param int $post_id Post ID (0 = no post context).
+ * @param string $from Source locale (may be empty).
+ * @param string $to Target locale (may be empty).
+ * @param array $post_meta Raw post meta (key => [value]).
+ * @return array Updated translate list.
+ */
+ public static function add_pods_translatable_keys( array $translate, int $post_id, string $from, string $to, array $post_meta ): array {
+ if ( empty( $post_meta ) || $post_id < 1 || ! function_exists( 'pods_api' ) ) {
+ return $translate;
+ }
+
+ $pod_fields = self::get_pod_field_types( $post_id );
+ if ( empty( $pod_fields ) ) {
+ return $translate;
+ }
+
+ $translatable_types = apply_filters( 'slytranslate_pods_translatable_field_types', self::TRANSLATABLE_TYPES );
+ $translatable_types = is_array( $translatable_types ) ? $translatable_types : self::TRANSLATABLE_TYPES;
+
+ foreach ( $post_meta as $meta_key => $values ) {
+ if ( ! is_string( $meta_key ) || str_starts_with( $meta_key, '_' ) || in_array( $meta_key, $translate, true ) ) {
+ continue;
+ }
+
+ if ( isset( $pod_fields[ $meta_key ] ) && in_array( $pod_fields[ $meta_key ], $translatable_types, true ) ) {
+ $translate[] = $meta_key;
+ }
+ }
+
+ return $translate;
+ }
+
+ /**
+ * Field name => field type map for the pod matching the post's type.
+ *
+ * Supports both the legacy array shape of load_pod() and newer Pod/Field
+ * objects (which implement ArrayAccess).
+ *
+ * @return array
+ */
+ private static function get_pod_field_types( int $post_id ): array {
+ $post_type = function_exists( 'get_post_type' ) ? (string) get_post_type( $post_id ) : '';
+ if ( '' === $post_type ) {
+ return array();
+ }
+
+ $api = pods_api();
+ if ( ! is_object( $api ) || ! method_exists( $api, 'load_pod' ) ) {
+ return array();
+ }
+
+ $pod = $api->load_pod( array( 'name' => $post_type ) );
+ if ( empty( $pod ) ) {
+ return array();
+ }
+
+ $fields = null;
+ if ( is_array( $pod ) && isset( $pod['fields'] ) ) {
+ $fields = $pod['fields'];
+ } elseif ( is_object( $pod ) && method_exists( $pod, 'get_fields' ) ) {
+ $fields = $pod->get_fields();
+ }
+
+ if ( ! is_array( $fields ) ) {
+ return array();
+ }
+
+ $field_types = array();
+ foreach ( $fields as $name => $field ) {
+ $type = '';
+ if ( is_array( $field ) || $field instanceof \ArrayAccess ) {
+ $type = (string) ( $field['type'] ?? '' );
+ }
+ if ( '' !== $type && is_string( $name ) ) {
+ $field_types[ $name ] = $type;
+ }
+ }
+
+ return $field_types;
+ }
+}
diff --git a/slytranslate/inc/PolylangAdapter.php b/slytranslate/inc/PolylangAdapter.php
index 10caa24..4d061ca 100644
--- a/slytranslate/inc/PolylangAdapter.php
+++ b/slytranslate/inc/PolylangAdapter.php
@@ -38,6 +38,18 @@ public function get_post_translations( int $post_id ): array {
return pll_get_post_translations( $post_id );
}
+ /**
+ * Whether Polylang exposes the term-translation API needed by
+ * TermTranslationService. String-table adapters never support this —
+ * their string-table path already covers term names.
+ */
+ public function supports_term_translation(): bool {
+ return $this->is_available()
+ && function_exists( 'pll_get_term' )
+ && function_exists( 'pll_set_term_language' )
+ && function_exists( 'pll_save_term_translations' );
+ }
+
public function supports_mutation_capability( string $capability ): bool {
if ( ! $this->is_available() ) {
return false;
@@ -113,7 +125,7 @@ public function create_translation( int $source_post_id, string $target_lang, ar
return new \WP_Error( 'source_post_not_found', 'Source post not found.' );
}
- $from_lang = pll_get_post_language( $source_post_id );
+ $from_lang = $this->get_post_language( $source_post_id );
$existing = pll_get_post( $source_post_id, $target_lang );
$overwrite = ! empty( $data['overwrite'] );
@@ -124,7 +136,8 @@ public function create_translation( int $source_post_id, string $target_lang, ar
);
}
- $translation_id = $existing;
+ $translation_id = $existing;
+ $is_new_translation = ! $translation_id;
// Use a filter to preserve the original post author.
$author_override = function ( $data ) use ( $post ) {
@@ -180,8 +193,36 @@ public function create_translation( int $source_post_id, string $target_lang, ar
}
$update_data['post_status'] = $data['post_status'] ?? 'draft';
+ // Opt-in slug translation: derive the slug from the already-translated
+ // title (no extra LLM call). Only on newly created translations — or
+ // when the existing slug is still the auto-generated stub — because
+ // changing slugs of live translations breaks inbound links.
+ if ( get_option( 'slytranslate_translate_slugs', '0' ) === '1'
+ && isset( $update_data['post_title'] )
+ && '' !== trim( (string) $update_data['post_title'] )
+ ) {
+ $apply_slug = $is_new_translation;
+ if ( ! $apply_slug && function_exists( 'get_post_field' ) ) {
+ $current_slug = (string) get_post_field( 'post_name', $translation_id );
+ $apply_slug = '' !== $current_slug && $current_slug === sanitize_title( $post->post_title . " ({$target_lang})" );
+ }
+
+ if ( $apply_slug ) {
+ $translated_slug = sanitize_title( (string) $update_data['post_title'] );
+ if ( '' !== $translated_slug ) {
+ $update_data['post_name'] = function_exists( 'wp_unique_post_slug' )
+ ? wp_unique_post_slug( $translated_slug, (int) $translation_id, (string) $update_data['post_status'], $post->post_type, (int) ( $post->post_parent ?? 0 ) )
+ : $translated_slug;
+ }
+ }
+ }
+
wp_update_post( wp_slash( $update_data ) );
+ // Loop-guard marker for auto-translate-on-publish: posts created by the
+ // plugin must never trigger another translation round.
+ update_post_meta( $translation_id, AutoPublishTranslationService::GENERATED_META_KEY, '1' );
+
// Copy and process meta.
if ( ! empty( $data['meta'] ) && is_array( $data['meta'] ) ) {
foreach ( $data['meta'] as $key => $value ) {
@@ -211,6 +252,16 @@ public function create_translation( int $source_post_id, string $target_lang, ar
if ( function_exists( 'pll_is_translated_taxonomy' ) && ! pll_is_translated_taxonomy( $taxonomy ) ) {
$translated_term_ids[] = $term_id;
+ continue;
+ }
+
+ // Opt-in: translate the missing term instead of silently
+ // dropping it, so the translated post keeps its categories/tags.
+ if ( TermTranslationService::is_enabled() && $this->supports_term_translation() && is_string( $from_lang ) && '' !== $from_lang ) {
+ $created_term_id = TermTranslationService::translate_term( (int) $term_id, $target_lang, $from_lang );
+ if ( ! is_wp_error( $created_term_id ) && $created_term_id > 0 ) {
+ $translated_term_ids[] = $created_term_id;
+ }
}
}
diff --git a/slytranslate/inc/PostTranslationService.php b/slytranslate/inc/PostTranslationService.php
index 972db96..6df3fa9 100644
--- a/slytranslate/inc/PostTranslationService.php
+++ b/slytranslate/inc/PostTranslationService.php
@@ -180,6 +180,19 @@ public static function translate_post(
$meta_for_batch = is_array( $all_meta ) ? $all_meta : array();
$meta_key_config = MetaTranslationService::get_effective_meta_key_config( $post_id, $meta_for_batch, $from, $to );
+
+ // Diff retranslation: when overwriting an existing sibling-post
+ // translation, meta keys whose source value is unchanged keep their
+ // already-translated target value and skip the pipeline entirely.
+ $skip_meta_keys = array();
+ if ( $overwrite && $existing_translation && ! AI_Translate::is_single_entry_translation_mode() ) {
+ $skip_meta_keys = TranslationFingerprint::get_unchanged_meta_keys( (int) $existing_translation, $meta_for_batch, $meta_key_config['translate'] );
+ if ( ! empty( $skip_meta_keys ) ) {
+ $meta_for_batch = array_diff_key( $meta_for_batch, array_flip( $skip_meta_keys ) );
+ TimingLogger::log( 'diff_meta_skipped', array( 'keys' => $skip_meta_keys ) );
+ }
+ }
+
$batch_candidates = MetaTranslationService::count_batch_eligible_candidates( $meta_for_batch, $meta_key_config, $batch_eligible_candidates );
$will_batch_title = $title_batch_eligible && $batch_candidates >= 2;
@@ -292,7 +305,16 @@ public static function translate_post(
TranslationProgressTracker::complete_phase( 'title' );
}
} else {
- $content = ContentTranslator::translate_parsed_blocks( $parsed_blocks, $post->post_content, $to, $from, $additional_prompt );
+ // Diff retranslation: reuse translated blocks from the existing
+ // translation for source blocks whose content hash is unchanged.
+ $content_reuse_map = null;
+ if ( $overwrite && $existing_translation && is_array( $parsed_blocks ) && ! empty( $parsed_blocks ) && ! AI_Translate::is_single_entry_translation_mode() ) {
+ $content_reuse_map = TranslationFingerprint::plan_block_reuse( $parsed_blocks, (int) $existing_translation );
+ }
+
+ $content = is_array( $content_reuse_map )
+ ? self::translate_blocks_with_reuse( $parsed_blocks, $content_reuse_map, $to, $from, $additional_prompt )
+ : ContentTranslator::translate_parsed_blocks( $parsed_blocks, $post->post_content, $to, $from, $additional_prompt );
if ( is_wp_error( $content ) ) {
TimingLogger::log( 'phase_end', array( 'phase' => 'content', 'duration_ms' => TimingLogger::stop( $phase_started ), 'ok' => false, 'reason' => $content->get_error_code() ) );
self::log_job_end( $post_id, $job_started_at, false );
@@ -338,7 +360,7 @@ public static function translate_post(
TranslationProgressTracker::mark_phase( 'meta' );
TimingLogger::log( 'phase_start', array( 'phase' => 'meta' ) );
$phase_started = TimingLogger::start();
- $processed_meta = MetaTranslationService::prepare_translation_meta( $post_id, $to, $from, $additional_prompt, $all_meta, $extra_candidates );
+ $processed_meta = MetaTranslationService::prepare_translation_meta( $post_id, $to, $from, $additional_prompt, $all_meta, $extra_candidates, $skip_meta_keys );
if ( is_wp_error( $processed_meta ) ) {
TimingLogger::log( 'phase_end', array( 'phase' => 'meta', 'duration_ms' => TimingLogger::stop( $phase_started ), 'ok' => false, 'reason' => $processed_meta->get_error_code() ) );
self::log_job_end( $post_id, $job_started_at, false );
@@ -411,6 +433,19 @@ public static function translate_post(
if ( $saving_ok ) {
self::clear_embed_cache_meta( (int) $result );
+
+ if ( ! AI_Translate::is_single_entry_translation_mode() && (int) $result !== $post_id ) {
+ // Record source fingerprints on the translation so the next
+ // overwrite can skip unchanged blocks and meta values.
+ TranslationFingerprint::store_fingerprints(
+ (int) $result,
+ is_array( $parsed_blocks ) ? $parsed_blocks : array(),
+ $all_meta,
+ $meta_key_config['translate']
+ );
+ MediaTranslationService::maybe_translate_featured_image_alt( $post_id, (int) $result, $to, $from );
+ }
+
TranslationProgressTracker::complete_phase( 'saving' );
TranslationProgressTracker::set_progress( 'done' );
}
@@ -425,6 +460,70 @@ public static function translate_post(
}
}
+ /**
+ * Translate only the top-level blocks whose content hash is not covered by
+ * the reuse map; unchanged blocks are copied from the existing translation.
+ *
+ * Consecutive changed blocks are translated as one group so the existing
+ * grouping/batching inside ContentTranslator keeps working.
+ *
+ * @param array $blocks parse_blocks() result of the source content.
+ * @param array $translated_by_hash Source block hash → serialized translated block.
+ * @return string|\WP_Error
+ */
+ private static function translate_blocks_with_reuse( array $blocks, array $translated_by_hash, string $to, string $from, string $additional_prompt ): string|\WP_Error {
+ $pieces = array();
+ $pending = array();
+
+ $flush = static function () use ( &$pieces, &$pending, $to, $from, $additional_prompt ) {
+ if ( empty( $pending ) ) {
+ return true;
+ }
+ $serialized = serialize_blocks( $pending );
+ $translated = ContentTranslator::translate_parsed_blocks( $pending, $serialized, $to, $from, $additional_prompt );
+ if ( is_wp_error( $translated ) ) {
+ return $translated;
+ }
+ $pieces[] = (string) $translated;
+ $pending = array();
+ return true;
+ };
+
+ $reused = 0;
+ foreach ( $blocks as $block ) {
+ if ( ! is_array( $block ) ) {
+ continue;
+ }
+
+ $serialized_block = serialize_blocks( array( $block ) );
+ $hash = TranslationFingerprint::hash( $serialized_block );
+ if ( isset( $translated_by_hash[ $hash ] ) ) {
+ $flush_result = $flush();
+ if ( is_wp_error( $flush_result ) ) {
+ return $flush_result;
+ }
+ $pieces[] = $translated_by_hash[ $hash ];
+ $reused++;
+ TranslationProgressTracker::advance_units( 'content', self::char_length( $serialized_block ) );
+ continue;
+ }
+
+ $pending[] = $block;
+ }
+
+ $flush_result = $flush();
+ if ( is_wp_error( $flush_result ) ) {
+ return $flush_result;
+ }
+
+ TimingLogger::log( 'diff_blocks_reused', array(
+ 'reused' => $reused,
+ 'total' => count( $blocks ),
+ ) );
+
+ return implode( '', $pieces );
+ }
+
private static function translate_title_with_empty_output_retry( string $title, string $target_language, string $source_language, string $additional_prompt ): mixed {
$title_hint = 'This is a post title. Translate it concisely and keep a similar length to the original. Do not expand, elaborate, or add content.';
$title_prompt = '' !== trim( $additional_prompt ) ? $additional_prompt . "\n\n" . $title_hint : $title_hint;
diff --git a/slytranslate/inc/Settings.php b/slytranslate/inc/Settings.php
index 56972f0..55ff19d 100644
--- a/slytranslate/inc/Settings.php
+++ b/slytranslate/inc/Settings.php
@@ -22,7 +22,11 @@ public static function register(): void {
register_setting( $g, 'slytranslate_prompt_addon', array( 'sanitize_callback' => 'sanitize_textarea_field', 'default' => '' ) );
register_setting( $g, 'slytranslate_meta_translate', array( 'sanitize_callback' => 'sanitize_textarea_field', 'default' => '' ) );
register_setting( $g, 'slytranslate_meta_clear', array( 'sanitize_callback' => 'sanitize_textarea_field', 'default' => '' ) );
+ register_setting( $g, 'slytranslate_meta_keys_exclude', array( 'sanitize_callback' => 'sanitize_textarea_field', 'default' => '' ) );
register_setting( $g, 'slytranslate_new_post', array( 'sanitize_callback' => 'sanitize_key', 'default' => '0' ) );
+ register_setting( $g, 'slytranslate_translate_terms', array( 'sanitize_callback' => 'sanitize_key', 'default' => '0' ) );
+ register_setting( $g, 'slytranslate_translate_slugs', array( 'sanitize_callback' => 'sanitize_key', 'default' => '0' ) );
+ register_setting( $g, 'slytranslate_glossary', array( 'sanitize_callback' => array( GlossaryService::class, 'sanitize_entries' ), 'default' => array() ) );
register_setting( $g, 'slytranslate_context_window_tokens', array( 'sanitize_callback' => 'absint', 'default' => 0 ) );
register_setting( $g, 'slytranslate_model_slug', array( 'sanitize_callback' => 'sanitize_text_field', 'default' => '' ) );
register_setting( $g, 'slytranslate_direct_api_url', array( 'sanitize_callback' => 'esc_url_raw', 'default' => '' ) );
diff --git a/slytranslate/inc/SettingsPage.php b/slytranslate/inc/SettingsPage.php
new file mode 100644
index 0000000..e7a7cad
--- /dev/null
+++ b/slytranslate/inc/SettingsPage.php
@@ -0,0 +1,232 @@
+';
+ echo '' . esc_html__( 'SlyTranslate', 'slytranslate' ) . '
';
+ echo '' . esc_html__( 'Loading settings …', 'slytranslate' ) . '
';
+ echo '';
+ }
+
+ /**
+ * Enqueue the settings app only on its own admin page.
+ *
+ * @param string $hook_suffix Current admin page hook suffix.
+ */
+ public static function enqueue_assets( $hook_suffix ): void {
+ if ( 'settings_page_' . self::PAGE_SLUG !== (string) $hook_suffix ) {
+ return;
+ }
+
+ wp_enqueue_style( 'wp-components' );
+
+ wp_enqueue_script(
+ self::SCRIPT_HANDLE,
+ plugins_url( 'assets/settings-page.js', dirname( __DIR__ ) . '/slytranslate.php' ),
+ array( 'wp-api-fetch', 'wp-components', 'wp-element', 'wp-i18n' ),
+ self::get_script_version(),
+ true
+ );
+
+ wp_localize_script( self::SCRIPT_HANDLE, 'slyTranslateSettings', self::get_bootstrap_data() );
+
+ if ( function_exists( 'wp_set_script_translations' ) ) {
+ wp_set_script_translations(
+ self::SCRIPT_HANDLE,
+ 'slytranslate',
+ plugin_dir_path( dirname( __DIR__ ) . '/slytranslate.php' ) . 'languages'
+ );
+ }
+ }
+
+ /**
+ * Static environment facts for the app. Everything editable lives in the
+ * configure payload; this only carries what a page load can know cheaply:
+ * detected plugins, availability flags, and rendering hints.
+ */
+ public static function get_bootstrap_data(): array {
+ $adapter = AI_Translate::get_adapter();
+
+ return array(
+ 'abilitiesRunBasePath' => '/' . Plugin::REST_NAMESPACE . '/',
+ 'pluginVersion' => Plugin::VERSION,
+ 'languagePlugin' => self::get_language_plugin_label( $adapter ),
+ 'serverTranslationAvailable' => AI_Translate::is_server_translation_ui_available(),
+ 'isStringTableAdapter' => $adapter instanceof StringTableContentAdapter,
+ 'fieldPlugins' => self::get_active_field_plugins(),
+ 'defaultPromptTemplate' => AI_Translate::get_default_prompt(),
+ 'strings' => self::get_ui_strings(),
+ );
+ }
+
+
+ /**
+ * Server-side translated UI strings for the settings app, keyed by their
+ * English source text. Passed via wp_localize_script so the JS needs no
+ * separate translation files (same pattern as the editor bootstrap).
+ *
+ * @return array
+ */
+ private static function get_ui_strings(): array {
+ return array(
+ 'AI client available' => __( 'AI client available', 'slytranslate' ),
+ 'AI client missing — install an AI connector for in-WordPress translation' => __( 'AI client missing — install an AI connector for in-WordPress translation', 'slytranslate' ),
+ 'Add glossary entry' => __( 'Add glossary entry', 'slytranslate' ),
+ 'Additional instructions (site-wide)' => __( 'Additional instructions (site-wide)', 'slytranslate' ),
+ 'Additional keys' => __( 'Additional keys', 'slytranslate' ),
+ 'Advanced' => __( 'Advanced', 'slytranslate' ),
+ 'Appended to every translation request, e.g. tone or brand wording.' => __( 'Appended to every translation request, e.g. tone or brand wording.', 'slytranslate' ),
+ 'Automatically translate new posts on publish (as draft)' => __( 'Automatically translate new posts on publish (as draft)', 'slytranslate' ),
+ 'Automation' => __( 'Automation', 'slytranslate' ),
+ 'Built-in default' => __( 'Built-in default', 'slytranslate' ),
+ 'Clear (space-separated meta keys)' => __( 'Clear (space-separated meta keys)', 'slytranslate' ),
+ 'Cleared on translation' => __( 'Cleared on translation', 'slytranslate' ),
+ 'Concurrency probe failed.' => __( 'Concurrency probe failed.', 'slytranslate' ),
+ 'Connector default' => __( 'Connector default', 'slytranslate' ),
+ 'Context window override (tokens, 0 = automatic)' => __( 'Context window override (tokens, 0 = automatic)', 'slytranslate' ),
+ 'Default model' => __( 'Default model', 'slytranslate' ),
+ 'Detected automatically from your SEO and field plugins. Unchecking a field excludes it from translation; the filter API can still override this.' => __( 'Detected automatically from your SEO and field plugins. Unchecking a field excludes it from translation; the filter API can still override this.', 'slytranslate' ),
+ 'Direct API URL (OpenAI-compatible, optional)' => __( 'Direct API URL (OpenAI-compatible, optional)', 'slytranslate' ),
+ 'Direct API: chat_template_kwargs not detected' => __( 'Direct API: chat_template_kwargs not detected', 'slytranslate' ),
+ 'Direct API: chat_template_kwargs supported' => __( 'Direct API: chat_template_kwargs supported', 'slytranslate' ),
+ 'Effective:' => __( 'Effective:', 'slytranslate' ),
+ 'Errors' => __( 'Errors', 'slytranslate' ),
+ 'Failed to load settings.' => __( 'Failed to load settings.', 'slytranslate' ),
+ 'Field plugins' => __( 'Field plugins', 'slytranslate' ),
+ 'Filter API' => __( 'Filter API', 'slytranslate' ),
+ 'Fixed translation' => __( 'Fixed translation', 'slytranslate' ),
+ 'Glossary / do not translate' => __( 'Glossary / do not translate', 'slytranslate' ),
+ 'Keep unchanged' => __( 'Keep unchanged', 'slytranslate' ),
+ 'Level' => __( 'Level', 'slytranslate' ),
+ 'Load preview' => __( 'Load preview', 'slytranslate' ),
+ 'Manual key' => __( 'Manual key', 'slytranslate' ),
+ 'Meta fields' => __( 'Meta fields', 'slytranslate' ),
+ 'Mode' => __( 'Mode', 'slytranslate' ),
+ 'Model' => __( 'Model', 'slytranslate' ),
+ 'No SEO plugin detected' => __( 'No SEO plugin detected', 'slytranslate' ),
+ 'No language plugin detected' => __( 'No language plugin detected', 'slytranslate' ),
+ 'No parallel transport available.' => __( 'No parallel transport available.', 'slytranslate' ),
+ 'No translatable meta fields detected for this context.' => __( 'No translatable meta fields detected for this context.', 'slytranslate' ),
+ 'Only used by model profiles that require a direct endpoint (e.g. TranslateGemma). Saving runs a capability probe.' => __( 'Only used by model profiles that require a direct endpoint (e.g. TranslateGemma). Saving runs a capability probe.', 'slytranslate' ),
+ 'Placeholders: {FROM_CODE} and {TO_CODE}.' => __( 'Placeholders: {FROM_CODE} and {TO_CODE}.', 'slytranslate' ),
+ 'Preview for post ID' => __( 'Preview for post ID', 'slytranslate' ),
+ 'Probe: chat_template_kwargs not detected.' => __( 'Probe: chat_template_kwargs not detected.', 'slytranslate' ),
+ 'Probe: chat_template_kwargs supported.' => __( 'Probe: chat_template_kwargs supported.', 'slytranslate' ),
+ 'Prompt template' => __( 'Prompt template', 'slytranslate' ),
+ 'Recommended concurrency:' => __( 'Recommended concurrency:', 'slytranslate' ),
+ 'Refresh model list' => __( 'Refresh model list', 'slytranslate' ),
+ 'Remove' => __( 'Remove', 'slytranslate' ),
+ 'Reset to default' => __( 'Reset to default', 'slytranslate' ),
+ 'SEO plugin' => __( 'SEO plugin', 'slytranslate' ),
+ 'Save settings' => __( 'Save settings', 'slytranslate' ),
+ 'Saving failed.' => __( 'Saving failed.', 'slytranslate' ),
+ 'Settings saved.' => __( 'Settings saved.', 'slytranslate' ),
+ 'Showing resolution for post' => __( 'Showing resolution for post', 'slytranslate' ),
+ 'Speedup' => __( 'Speedup', 'slytranslate' ),
+ 'Status' => __( 'Status', 'slytranslate' ),
+ 'String-table concurrency (TranslatePress batches)' => __( 'String-table concurrency (TranslatePress batches)', 'slytranslate' ),
+ 'Term' => __( 'Term', 'slytranslate' ),
+ 'Test concurrency' => __( 'Test concurrency', 'slytranslate' ),
+ 'Translate (space-separated meta keys)' => __( 'Translate (space-separated meta keys)', 'slytranslate' ),
+ 'Translate post slugs' => __( 'Translate post slugs', 'slytranslate' ),
+ 'Translate taxonomy terms alongside content' => __( 'Translate taxonomy terms alongside content', 'slytranslate' ),
+ 'Translation' => __( 'Translation', 'slytranslate' ),
+ 'Translations (en=…, fr=…)' => __( 'Translations (en=…, fr=…)', 'slytranslate' ),
+ 'Transport diagnostics' => __( 'Transport diagnostics', 'slytranslate' ),
+ 'Wall time (ms)' => __( 'Wall time (ms)', 'slytranslate' ),
+ 'chunk size:' => __( 'chunk size:', 'slytranslate' ),
+ 'detected' => __( 'detected', 'slytranslate' ),
+ 'learned:' => __( 'learned:', 'slytranslate' ),
+ 'recommended:' => __( 'recommended:', 'slytranslate' ),
+ 'tokens' => __( 'tokens', 'slytranslate' ),
+ );
+ }
+
+ /**
+ * Human-readable name of the active language plugin ('' = none detected).
+ */
+ private static function get_language_plugin_label( ?TranslationPluginAdapter $adapter ): string {
+ if ( $adapter instanceof PolylangAdapter ) {
+ return 'Polylang';
+ }
+ if ( $adapter instanceof WpMultilangAdapter ) {
+ return 'WP Multilang';
+ }
+ if ( $adapter instanceof WpglobusAdapter ) {
+ return 'WPGlobus';
+ }
+ if ( $adapter instanceof TranslatePressAdapter ) {
+ return 'TranslatePress';
+ }
+ if ( null !== $adapter ) {
+ // Third-party adapter registered via slytranslate_adapter_candidates.
+ $class_name = get_class( $adapter );
+ $short_name = substr( (string) strrchr( '\\' . $class_name, '\\' ), 1 );
+ return '' !== $short_name ? $short_name : $class_name;
+ }
+ return '';
+ }
+
+ /**
+ * Active field plugins, mirroring the gates in
+ * Plugin::register_optional_integrations().
+ *
+ * @return string[] Labels of detected field plugins.
+ */
+ private static function get_active_field_plugins(): array {
+ $plugins = array();
+ if ( function_exists( 'acf_get_field' ) ) {
+ $plugins[] = 'ACF';
+ }
+ if ( function_exists( 'rwmb_get_field_settings' ) ) {
+ $plugins[] = 'Meta Box';
+ }
+ if ( function_exists( 'pods_api' ) ) {
+ $plugins[] = 'Pods';
+ }
+ return $plugins;
+ }
+
+ private static function get_script_version(): string {
+ $script_path = plugin_dir_path( dirname( __DIR__ ) . '/slytranslate.php' ) . 'assets/settings-page.js';
+ $script_mtime = file_exists( $script_path ) ? filemtime( $script_path ) : false;
+
+ if ( false === $script_mtime ) {
+ return Plugin::VERSION;
+ }
+
+ return Plugin::VERSION . '.' . (string) $script_mtime;
+ }
+}
diff --git a/slytranslate/inc/TermTranslationService.php b/slytranslate/inc/TermTranslationService.php
new file mode 100644
index 0000000..cd754f6
--- /dev/null
+++ b/slytranslate/inc/TermTranslationService.php
@@ -0,0 +1,235 @@
+ 0 ) {
+ return $existing;
+ }
+
+ $term = get_term( $term_id );
+ if ( ! $term || is_wp_error( $term ) ) {
+ return new \WP_Error( 'term_not_found', __( 'Source term not found.', 'slytranslate' ) );
+ }
+
+ if ( function_exists( 'pll_is_translated_taxonomy' ) && ! \pll_is_translated_taxonomy( $term->taxonomy ) ) {
+ return new \WP_Error( 'taxonomy_not_translated', __( 'The taxonomy is not managed by the language plugin.', 'slytranslate' ) );
+ }
+
+ $name_hint = 'This is a taxonomy term name (category, tag, or similar). Translate it concisely and return only the translated name.';
+ $translated_name = TranslationRuntime::translate_text( (string) $term->name, $to, $from, $name_hint );
+ if ( is_wp_error( $translated_name ) ) {
+ return $translated_name;
+ }
+ $translated_name = trim( (string) $translated_name );
+ if ( '' === $translated_name ) {
+ $translated_name = (string) $term->name;
+ }
+
+ $translated_description = '';
+ if ( '' !== trim( (string) $term->description ) ) {
+ $translated_description = TranslationRuntime::translate_text( (string) $term->description, $to, $from, '' );
+ if ( is_wp_error( $translated_description ) ) {
+ // Description is secondary — keep the term creation alive.
+ $translated_description = (string) $term->description;
+ }
+ }
+
+ $inserted = wp_insert_term(
+ $translated_name,
+ $term->taxonomy,
+ array(
+ 'description' => (string) $translated_description,
+ 'slug' => sanitize_title( $translated_name ),
+ )
+ );
+
+ if ( is_wp_error( $inserted ) ) {
+ // A term with the translated name already exists in this taxonomy —
+ // reuse it instead of failing the whole post translation.
+ $existing_id = absint( $inserted->get_error_data( 'term_exists' ) );
+ if ( $existing_id < 1 ) {
+ return $inserted;
+ }
+ $new_term_id = $existing_id;
+ } else {
+ $new_term_id = absint( $inserted['term_id'] ?? 0 );
+ }
+
+ if ( $new_term_id < 1 ) {
+ return new \WP_Error( 'term_insert_failed', __( 'The translated term could not be created.', 'slytranslate' ) );
+ }
+
+ \pll_set_term_language( $new_term_id, $to );
+
+ $translations = function_exists( 'pll_get_term_translations' ) ? \pll_get_term_translations( $term_id ) : array();
+ $translations = is_array( $translations ) ? $translations : array();
+ $translations[ $from ] = $term_id;
+ $translations[ $to ] = $new_term_id;
+ \pll_save_term_translations( $translations );
+
+ TimingLogger::log( 'term_translated', array(
+ 'term' => $term_id,
+ 'new_term' => $new_term_id,
+ 'taxonomy' => (string) $term->taxonomy,
+ 'from' => $from,
+ 'to' => $to,
+ ) );
+
+ return $new_term_id;
+ }
+
+ /**
+ * Execute callback for the ai-translate/translate-terms ability.
+ *
+ * Bulk-translates terms of one taxonomy for existing sites. With dry_run
+ * only reports which terms would be translated.
+ *
+ * @param array $input taxonomy, target_language, optional term_ids, source_language, dry_run.
+ * @return array|\WP_Error
+ */
+ public static function execute_translate_terms( $input ): array|\WP_Error {
+ $input = is_array( $input ) ? $input : array();
+
+ if ( ! self::is_term_translation_supported() ) {
+ return new \WP_Error( 'term_translation_unsupported', __( 'The active translation plugin does not support term translation.', 'slytranslate' ) );
+ }
+
+ $taxonomy = isset( $input['taxonomy'] ) && is_string( $input['taxonomy'] ) ? sanitize_key( $input['taxonomy'] ) : '';
+ if ( '' === $taxonomy ) {
+ return new \WP_Error( 'missing_taxonomy', __( 'A taxonomy is required.', 'slytranslate' ) );
+ }
+
+ $to = isset( $input['target_language'] ) && is_string( $input['target_language'] ) ? sanitize_key( $input['target_language'] ) : '';
+ if ( '' === $to ) {
+ return new \WP_Error( 'missing_target_language', __( 'Target language is required.', 'slytranslate' ) );
+ }
+
+ if ( function_exists( 'pll_is_translated_taxonomy' ) && ! \pll_is_translated_taxonomy( $taxonomy ) ) {
+ return new \WP_Error( 'taxonomy_not_translated', __( 'The taxonomy is not managed by the language plugin.', 'slytranslate' ) );
+ }
+
+ $from = isset( $input['source_language'] ) && is_string( $input['source_language'] ) ? sanitize_key( $input['source_language'] ) : '';
+ if ( '' === $from && function_exists( 'pll_default_language' ) ) {
+ $from = (string) \pll_default_language();
+ }
+ if ( '' === $from || $from === $to ) {
+ return new \WP_Error( 'invalid_source_language', __( 'A source language different from the target language is required.', 'slytranslate' ) );
+ }
+
+ $dry_run = ! empty( $input['dry_run'] );
+
+ $term_ids = array();
+ if ( isset( $input['term_ids'] ) && is_array( $input['term_ids'] ) ) {
+ foreach ( $input['term_ids'] as $term_id ) {
+ $term_id = absint( $term_id );
+ if ( $term_id > 0 ) {
+ $term_ids[] = $term_id;
+ }
+ }
+ } else {
+ $terms = get_terms( array(
+ 'taxonomy' => $taxonomy,
+ 'hide_empty' => false,
+ 'fields' => 'ids',
+ 'lang' => $from,
+ ) );
+ if ( is_wp_error( $terms ) ) {
+ return $terms;
+ }
+ $term_ids = array_map( 'absint', is_array( $terms ) ? $terms : array() );
+ }
+
+ return TranslationRuntime::with_model_slug_override(
+ $input,
+ static function () use ( $term_ids, $to, $from, $dry_run ) {
+ $results = array();
+ $succeeded = 0;
+ $failed = 0;
+ $skipped = 0;
+
+ foreach ( $term_ids as $term_id ) {
+ $existing = absint( \pll_get_term( $term_id, $to ) );
+ if ( $existing > 0 ) {
+ $skipped++;
+ $results[] = array( 'term_id' => $term_id, 'translated_term_id' => $existing, 'status' => 'skipped', 'error' => null );
+ continue;
+ }
+
+ if ( $dry_run ) {
+ $results[] = array( 'term_id' => $term_id, 'translated_term_id' => 0, 'status' => 'would_translate', 'error' => null );
+ continue;
+ }
+
+ $translated = self::translate_term( $term_id, $to, $from );
+ if ( is_wp_error( $translated ) ) {
+ $failed++;
+ $results[] = array( 'term_id' => $term_id, 'translated_term_id' => 0, 'status' => 'failed', 'error' => $translated->get_error_message() );
+ continue;
+ }
+
+ $succeeded++;
+ $results[] = array( 'term_id' => $term_id, 'translated_term_id' => $translated, 'status' => 'success', 'error' => null );
+ }
+
+ return array(
+ 'results' => $results,
+ 'total' => count( $term_ids ),
+ 'succeeded' => $succeeded,
+ 'failed' => $failed,
+ 'skipped' => $skipped,
+ 'dry_run' => $dry_run,
+ );
+ }
+ );
+ }
+}
diff --git a/slytranslate/inc/TranslationFingerprint.php b/slytranslate/inc/TranslationFingerprint.php
new file mode 100644
index 0000000..f264a11
--- /dev/null
+++ b/slytranslate/inc/TranslationFingerprint.php
@@ -0,0 +1,206 @@
+post_title . "\x00" . $post->post_content . "\x00" . $post->post_excerpt );
+ }
+
+ /**
+ * Hash every top-level block of a parsed block list.
+ *
+ * @param array $blocks Result of parse_blocks().
+ * @return string[] Index → sha256 of the serialized block.
+ */
+ public static function compute_block_hashes( array $blocks ): array {
+ if ( ! function_exists( 'serialize_blocks' ) ) {
+ return array();
+ }
+
+ $hashes = array();
+ foreach ( array_values( $blocks ) as $index => $block ) {
+ if ( ! is_array( $block ) ) {
+ return array();
+ }
+ $hashes[ $index ] = self::hash( serialize_blocks( array( $block ) ) );
+ }
+
+ return $hashes;
+ }
+
+ /**
+ * Hash the source values of the given meta keys.
+ *
+ * @param array $all_meta Raw get_post_meta() result (values wrapped in arrays).
+ * @param string[] $keys Meta keys to fingerprint.
+ * @return array meta key → sha256.
+ */
+ public static function compute_meta_hashes( array $all_meta, array $keys ): array {
+ $hashes = array();
+ foreach ( $keys as $key ) {
+ if ( ! is_string( $key ) || ! isset( $all_meta[ $key ] ) ) {
+ continue;
+ }
+ $raw = $all_meta[ $key ];
+ if ( is_array( $raw ) ) {
+ $raw = reset( $raw );
+ }
+ if ( ! is_scalar( $raw ) ) {
+ continue;
+ }
+ $hashes[ $key ] = self::hash( (string) $raw );
+ }
+
+ return $hashes;
+ }
+
+ /**
+ * Build a reuse map for diff-based retranslation of an existing translation.
+ *
+ * Matches by block *content hash* (not index) so reordered blocks are still
+ * reused. Falls back to null (full retranslation) when no hashes were
+ * stored, the stored hash count does not match the translated post's block
+ * count (blocks were inserted/removed since), or nothing matches.
+ *
+ * @param array $source_blocks parse_blocks() result of the current source content.
+ * @param int $translated_post_id Existing translation to harvest translated blocks from.
+ * @return array|null source block hash → serialized translated block.
+ */
+ public static function plan_block_reuse( array $source_blocks, int $translated_post_id ): ?array {
+ if ( empty( $source_blocks ) || $translated_post_id < 1 ) {
+ return null;
+ }
+ if ( ! function_exists( 'parse_blocks' ) || ! function_exists( 'serialize_blocks' ) ) {
+ return null;
+ }
+
+ $stored = get_post_meta( $translated_post_id, self::UNIT_HASHES_META_KEY, true );
+ if ( ! is_array( $stored ) || empty( $stored ) ) {
+ return null;
+ }
+
+ $translated_post = get_post( $translated_post_id );
+ if ( ! $translated_post || '' === trim( (string) $translated_post->post_content ) ) {
+ return null;
+ }
+
+ $translated_blocks = parse_blocks( $translated_post->post_content );
+ if ( ! is_array( $translated_blocks ) || count( $translated_blocks ) !== count( $stored ) ) {
+ // Block structure of the translation diverged from what we recorded
+ // (manual edits, inserts, deletes) — a positional mapping would be
+ // wrong, so force a full retranslation.
+ return null;
+ }
+
+ $stored = array_values( $stored );
+ $translated_blocks = array_values( $translated_blocks );
+
+ $map = array();
+ foreach ( $stored as $index => $source_hash ) {
+ if ( ! is_string( $source_hash ) || '' === $source_hash || ! isset( $translated_blocks[ $index ] ) ) {
+ continue;
+ }
+ if ( ! isset( $map[ $source_hash ] ) ) {
+ $map[ $source_hash ] = serialize_blocks( array( $translated_blocks[ $index ] ) );
+ }
+ }
+
+ if ( empty( $map ) ) {
+ return null;
+ }
+
+ $reusable = 0;
+ foreach ( $source_blocks as $block ) {
+ if ( is_array( $block ) && isset( $map[ self::hash( serialize_blocks( array( $block ) ) ) ] ) ) {
+ $reusable++;
+ }
+ }
+
+ return $reusable > 0 ? $map : null;
+ }
+
+ /**
+ * Persist block and meta fingerprints of the source on the translated post
+ * so the next overwrite can diff against them.
+ *
+ * @param int $translated_post_id Target post that received the translation.
+ * @param array $source_blocks parse_blocks() result of the source content (may be empty).
+ * @param array $all_meta Raw source post meta.
+ * @param string[] $translated_keys Meta keys that went through translation.
+ */
+ public static function store_fingerprints( int $translated_post_id, array $source_blocks, array $all_meta, array $translated_keys ): void {
+ if ( $translated_post_id < 1 ) {
+ return;
+ }
+
+ $block_hashes = self::compute_block_hashes( $source_blocks );
+ if ( ! empty( $block_hashes ) ) {
+ update_post_meta( $translated_post_id, self::UNIT_HASHES_META_KEY, $block_hashes );
+ } else {
+ delete_post_meta( $translated_post_id, self::UNIT_HASHES_META_KEY );
+ }
+
+ $meta_hashes = self::compute_meta_hashes( $all_meta, $translated_keys );
+ if ( ! empty( $meta_hashes ) ) {
+ update_post_meta( $translated_post_id, self::META_HASHES_META_KEY, $meta_hashes );
+ } else {
+ delete_post_meta( $translated_post_id, self::META_HASHES_META_KEY );
+ }
+ }
+
+ /**
+ * Translatable meta keys whose source value is unchanged compared to the
+ * fingerprints stored on the existing translation.
+ *
+ * @param int $translated_post_id Existing translation.
+ * @param array $all_meta Raw source post meta.
+ * @param string[] $translate_keys Effective translate keys for the source post.
+ * @return string[] Keys that can keep their already-translated target value.
+ */
+ public static function get_unchanged_meta_keys( int $translated_post_id, array $all_meta, array $translate_keys ): array {
+ if ( $translated_post_id < 1 || empty( $translate_keys ) ) {
+ return array();
+ }
+
+ $stored = get_post_meta( $translated_post_id, self::META_HASHES_META_KEY, true );
+ if ( ! is_array( $stored ) || empty( $stored ) ) {
+ return array();
+ }
+
+ $current = self::compute_meta_hashes( $all_meta, $translate_keys );
+ $unchanged = array();
+ foreach ( $current as $key => $hash ) {
+ if ( isset( $stored[ $key ] ) && $stored[ $key ] === $hash ) {
+ $unchanged[] = $key;
+ }
+ }
+
+ return $unchanged;
+ }
+}
diff --git a/slytranslate/inc/TranslationQueue.php b/slytranslate/inc/TranslationQueue.php
new file mode 100644
index 0000000..28426a5
--- /dev/null
+++ b/slytranslate/inc/TranslationQueue.php
@@ -0,0 +1,287 @@
+ $job_id,
+ 'status' => 'queued',
+ 'target_language' => $target_language,
+ 'post_ids' => $post_ids,
+ 'options' => $options,
+ 'user_id' => get_current_user_id(),
+ 'total' => count( $post_ids ),
+ 'processed' => 0,
+ 'succeeded' => 0,
+ 'failed' => 0,
+ 'skipped' => 0,
+ 'results' => array(),
+ 'created_at' => time(),
+ );
+ self::save_job( $job );
+
+ foreach ( $post_ids as $post_id ) {
+ self::dispatch( array( $job_id, $post_id ) );
+ }
+
+ return array(
+ 'job_id' => $job_id,
+ 'total' => count( $post_ids ),
+ );
+ }
+
+ private static function dispatch( array $args ): void {
+ if ( null !== self::$scheduler_override ) {
+ call_user_func( self::$scheduler_override, self::WORKER_HOOK, $args );
+ return;
+ }
+
+ if ( function_exists( 'as_enqueue_async_action' ) ) {
+ as_enqueue_async_action( self::WORKER_HOOK, $args, 'slytranslate' );
+ return;
+ }
+
+ wp_schedule_single_event( time(), self::WORKER_HOOK, $args );
+ }
+
+ private static function generate_job_id(): string {
+ if ( function_exists( 'wp_generate_password' ) ) {
+ return strtolower( (string) wp_generate_password( 12, false, false ) );
+ }
+
+ return substr( md5( uniqid( (string) wp_rand(), true ) ), 0, 12 );
+ }
+
+ /* ---------------------------------------------------------------
+ * Worker
+ * ------------------------------------------------------------- */
+
+ /**
+ * Process one queued post translation. Hooked on WORKER_HOOK.
+ */
+ public static function process_queued_translation( $job_id, $post_id ): void {
+ $job_id = is_string( $job_id ) ? $job_id : '';
+ $post_id = absint( $post_id );
+ $job = self::get_job( $job_id );
+
+ if ( null === $job || $post_id < 1 ) {
+ return;
+ }
+ if ( in_array( $job['status'], array( 'cancelled', 'completed' ), true ) ) {
+ return;
+ }
+
+ // Cron/Action Scheduler runs have no authenticated user — restore the
+ // user who started the job so capability checks behave like the
+ // original request.
+ $user_id = absint( $job['user_id'] ?? 0 );
+ if ( $user_id > 0 && function_exists( 'wp_set_current_user' ) ) {
+ wp_set_current_user( $user_id );
+ }
+
+ $options = is_array( $job['options'] ?? null ) ? $job['options'] : array();
+ $run = static function () use ( $post_id, $job, $options ) {
+ return AI_Translate::translate_post(
+ $post_id,
+ (string) $job['target_language'],
+ (string) ( $options['post_status'] ?? '' ),
+ ! empty( $options['overwrite'] ),
+ array_key_exists( 'translate_title', $options ) ? (bool) $options['translate_title'] : true,
+ (string) ( $options['additional_prompt'] ?? '' ),
+ (string) ( $options['source_language'] ?? '' )
+ );
+ };
+
+ $model_slug = (string) ( $options['model_slug'] ?? '' );
+ $result = '' !== $model_slug
+ ? TranslationRuntime::with_model_slug_override( array( 'model_slug' => $model_slug ), $run )
+ : $run();
+
+ // Re-read the job: parallel workers or a cancel may have updated it.
+ $job = self::get_job( $job_id );
+ if ( null === $job ) {
+ return;
+ }
+
+ $job['processed']++;
+ if ( is_wp_error( $result ) ) {
+ $skip_codes = array( 'translation_exists', 'same_language' );
+ $is_skip = in_array( $result->get_error_code(), $skip_codes, true );
+ $job[ $is_skip ? 'skipped' : 'failed' ]++;
+ $job['results'][] = array(
+ 'source_post_id' => $post_id,
+ 'translated_post_id' => 0,
+ 'status' => $is_skip ? 'skipped' : 'failed',
+ 'error' => $result->get_error_message(),
+ );
+ } else {
+ $job['succeeded']++;
+ $job['results'][] = array(
+ 'source_post_id' => $post_id,
+ 'translated_post_id' => absint( $result ),
+ 'status' => 'success',
+ 'error' => null,
+ );
+ }
+
+ if ( 'cancelled' !== $job['status'] ) {
+ $job['status'] = $job['processed'] >= $job['total'] ? 'completed' : 'running';
+ }
+ self::save_job( $job );
+
+ TimingLogger::log( 'queue_post_done', array(
+ 'job' => $job_id,
+ 'post' => $post_id,
+ 'status' => $job['status'],
+ 'processed' => $job['processed'],
+ 'total' => $job['total'],
+ ) );
+ }
+
+ /* ---------------------------------------------------------------
+ * Status / cancellation
+ * ------------------------------------------------------------- */
+
+ /**
+ * Public job status for get-progress.
+ *
+ * @return array|null Null when the job is unknown.
+ */
+ public static function get_job_status( string $job_id ): ?array {
+ $job = self::get_job( $job_id );
+ if ( null === $job ) {
+ return null;
+ }
+
+ return array(
+ 'job_id' => $job['id'],
+ 'status' => $job['status'],
+ 'target_language' => $job['target_language'],
+ 'total' => $job['total'],
+ 'processed' => $job['processed'],
+ 'succeeded' => $job['succeeded'],
+ 'failed' => $job['failed'],
+ 'skipped' => $job['skipped'],
+ 'results' => $job['results'],
+ );
+ }
+
+ /**
+ * Cancel a job: mark it cancelled and unschedule all pending actions.
+ */
+ public static function cancel( string $job_id ): bool {
+ $job = self::get_job( $job_id );
+ if ( null === $job ) {
+ return false;
+ }
+
+ $job['status'] = 'cancelled';
+ self::save_job( $job );
+
+ foreach ( $job['post_ids'] as $post_id ) {
+ $args = array( $job['id'], absint( $post_id ) );
+ if ( function_exists( 'as_unschedule_action' ) ) {
+ as_unschedule_action( self::WORKER_HOOK, $args, 'slytranslate' );
+ }
+ if ( function_exists( 'wp_clear_scheduled_hook' ) ) {
+ wp_clear_scheduled_hook( self::WORKER_HOOK, $args );
+ }
+ }
+
+ return true;
+ }
+
+ /* ---------------------------------------------------------------
+ * Job storage
+ * ------------------------------------------------------------- */
+
+ private static function get_job( string $job_id ): ?array {
+ if ( '' === $job_id ) {
+ return null;
+ }
+
+ $job = get_option( self::JOB_OPTION_PREFIX . sanitize_key( $job_id ), null );
+
+ return is_array( $job ) ? $job : null;
+ }
+
+ private static function save_job( array $job ): void {
+ update_option( self::JOB_OPTION_PREFIX . sanitize_key( (string) $job['id'] ), $job, false );
+ }
+}
diff --git a/slytranslate/inc/TranslationRuntime.php b/slytranslate/inc/TranslationRuntime.php
index 3076440..cd3fe17 100644
--- a/slytranslate/inc/TranslationRuntime.php
+++ b/slytranslate/inc/TranslationRuntime.php
@@ -226,7 +226,7 @@ public static function should_skip_html_tag_validation(): bool {
* Prompt building
* ------------------------------------------------------------- */
- public static function build_prompt( string $to, string $from = 'en', string $additional_prompt = '' ): string {
+ public static function build_prompt( string $to, string $from = 'en', string $additional_prompt = '', string $source_text = '' ): string {
$template = get_option( 'slytranslate_prompt', AI_Translate::get_default_prompt() );
$base_prompt = str_replace(
array( '{FROM_CODE}', '{TO_CODE}' ),
@@ -241,6 +241,11 @@ public static function build_prompt( string $to, string $from = 'en', string $ad
$parts[] = trim( $global_addon );
}
+ $glossary_block = GlossaryService::build_prompt_block( $to, $source_text );
+ if ( '' !== $glossary_block ) {
+ $parts[] = $glossary_block;
+ }
+
if ( is_string( $additional_prompt ) && '' !== trim( $additional_prompt ) ) {
$parts[] = 'Additional style instructions (do NOT translate these lines, apply them to the user-provided content): ' . trim( $additional_prompt );
}
@@ -326,7 +331,7 @@ public static function translate_text( $text, string $to, string $from = 'en', s
self::$last_diagnostics = null;
try {
- $prompt = self::build_prompt( $to, $from, $additional_prompt );
+ $prompt = self::build_prompt( $to, $from, $additional_prompt, (string) $text );
return self::translate_with_chunk_limit( $text, $prompt, self::get_chunk_char_limit() );
} finally {
self::$source_lang = null;
@@ -345,7 +350,7 @@ public static function translate_text_raw( $text, string $to, string $from = 'en
self::$last_diagnostics = null;
try {
- $prompt = self::build_prompt( $to, $from, $additional_prompt );
+ $prompt = self::build_prompt( $to, $from, $additional_prompt, (string) $text );
return self::translate_chunk( $text, $prompt, 0, true );
} finally {
self::$source_lang = null;
diff --git a/slytranslate/inc/WpMultilangAdapter.php b/slytranslate/inc/WpMultilangAdapter.php
index cbef1ea..f9f0373 100644
--- a/slytranslate/inc/WpMultilangAdapter.php
+++ b/slytranslate/inc/WpMultilangAdapter.php
@@ -246,6 +246,17 @@ private function get_default_language_code(): string {
return '' !== $default_language ? $default_language : '';
}
+ /**
+ * Public mutation helper: write a target-language variant into an existing
+ * (possibly markup-less) value, preserving all other language segments.
+ * Used by AcfOptionsTranslationService for option values.
+ */
+ public function merge_language_variant( string $existing_value, string $source_language, string $target_language, string $target_value ): string {
+ $default_language = $this->get_default_language_code();
+ $source_fallback = $this->extract_language_value( $existing_value, sanitize_key( $source_language ), $default_language );
+ return $this->merge_language_value( $existing_value, $source_language, $target_language, $target_value, $source_fallback );
+ }
+
private function extract_language_value( string $value, string $language_code, string $default_language ): string {
$value = (string) $value;
if ( '' === $value ) {
diff --git a/slytranslate/inc/WpglobusAdapter.php b/slytranslate/inc/WpglobusAdapter.php
index 12a2d5e..e28910c 100644
--- a/slytranslate/inc/WpglobusAdapter.php
+++ b/slytranslate/inc/WpglobusAdapter.php
@@ -450,6 +450,17 @@ private function resolve_language_name( string $language_code ): string {
return $language_code;
}
+ /**
+ * Public mutation helper: write a target-language variant into an existing
+ * (possibly markup-less) value, preserving all other language segments.
+ * Used by AcfOptionsTranslationService for option values.
+ */
+ public function merge_language_variant( string $existing_value, string $source_language, string $target_language, string $target_value ): string {
+ $default_language = $this->get_default_language_code();
+ $source_fallback = $this->extract_language_value( $existing_value, sanitize_key( $source_language ), $default_language );
+ return $this->merge_language_value( $existing_value, $source_language, $target_language, $target_value, $source_fallback );
+ }
+
private function has_wpglobus_markup( string $value ): bool {
// WPGlobus format: {:lang}text{:} — detect opening language tag.
return (bool) preg_match( '/\{:[a-z]{2,10}\}/', $value );
diff --git a/slytranslate/languages/slytranslate-de_DE.mo b/slytranslate/languages/slytranslate-de_DE.mo
index 9b2fee7..d897fbe 100644
Binary files a/slytranslate/languages/slytranslate-de_DE.mo and b/slytranslate/languages/slytranslate-de_DE.mo differ
diff --git a/slytranslate/languages/slytranslate-de_DE.po b/slytranslate/languages/slytranslate-de_DE.po
index 6afecda..4201648 100644
--- a/slytranslate/languages/slytranslate-de_DE.po
+++ b/slytranslate/languages/slytranslate-de_DE.po
@@ -861,3 +861,287 @@ msgstr "Du musst angemeldet sein, um Einstellungen zu speichern."
#~ msgid "Translate %d items to %s"
#~ msgstr "%d Einträge nach %s übersetzen"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "AI client available"
+msgstr "AI-Client verfügbar"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "AI client missing — install an AI connector for in-WordPress translation"
+msgstr "AI-Client fehlt — installiere einen AI-Connector für Übersetzungen innerhalb von WordPress"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Add glossary entry"
+msgstr "Glossareintrag hinzufügen"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Additional instructions (site-wide)"
+msgstr "Zusätzliche Anweisungen (site-weit)"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Additional keys"
+msgstr "Zusätzliche Schlüssel"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Advanced"
+msgstr "Erweitert"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Appended to every translation request, e.g. tone or brand wording."
+msgstr "Wird an jede Übersetzungsanfrage angehängt, z. B. Tonalität oder Markenbegriffe."
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Automatically translate new posts on publish (as draft)"
+msgstr "Neue Beiträge beim Veröffentlichen automatisch übersetzen (als Entwurf)"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Automation"
+msgstr "Automatisierung"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Built-in default"
+msgstr "Eingebauter Standard"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Clear (space-separated meta keys)"
+msgstr "Leeren (Meta-Schlüssel, durch Leerzeichen getrennt)"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Cleared on translation"
+msgstr "Beim Übersetzen geleert"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Concurrency probe failed."
+msgstr "Parallelitätstest fehlgeschlagen."
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Context window override (tokens, 0 = automatic)"
+msgstr "Kontextfenster-Override (Tokens, 0 = automatisch)"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Default model"
+msgstr "Standard-Modell"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Detected automatically from your SEO and field plugins. Unchecking a field excludes it from translation; the filter API can still override this."
+msgstr "Automatisch aus deinen SEO- und Feld-Plugins erkannt. Ein abgewähltes Feld wird von der Übersetzung ausgeschlossen; die Filter-API kann das weiterhin überstimmen."
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Direct API URL (OpenAI-compatible, optional)"
+msgstr "Direct-API-URL (OpenAI-kompatibel, optional)"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Direct API: chat_template_kwargs not detected"
+msgstr "Direct API: chat_template_kwargs nicht erkannt"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Direct API: chat_template_kwargs supported"
+msgstr "Direct API: chat_template_kwargs unterstützt"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Effective:"
+msgstr "Effektiv:"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Errors"
+msgstr "Fehler"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Exclude from AI translation"
+msgstr "Von KI-Übersetzung ausschließen"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Failed to load settings."
+msgstr "Einstellungen konnten nicht geladen werden."
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Field plugins"
+msgstr "Feld-Plugins"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Filter API"
+msgstr "Filter-API"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Fixed translation"
+msgstr "Feste Übersetzung"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Glossary / do not translate"
+msgstr "Glossar / Nicht übersetzen"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Keep unchanged"
+msgstr "Unverändert lassen"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Level"
+msgstr "Stufe"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Load preview"
+msgstr "Vorschau laden"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Loading settings …"
+msgstr "Einstellungen werden geladen …"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Manual key"
+msgstr "Manueller Schlüssel"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Meta fields"
+msgstr "Meta-Felder"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Mode"
+msgstr "Modus"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Model"
+msgstr "Modell"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "No SEO plugin detected"
+msgstr "Kein SEO-Plugin erkannt"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "No language plugin detected"
+msgstr "Kein Sprach-Plugin erkannt"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "No parallel transport available."
+msgstr "Kein paralleler Transport verfügbar."
+
+#: slytranslate/inc/SettingsPage.php
+msgid "No translatable meta fields detected for this context."
+msgstr "Keine übersetzbaren Meta-Felder in diesem Kontext erkannt."
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Only used by model profiles that require a direct endpoint (e.g. TranslateGemma). Saving runs a capability probe."
+msgstr "Wird nur von Modellprofilen genutzt, die einen direkten Endpunkt benötigen (z. B. TranslateGemma). Beim Speichern läuft eine Fähigkeits-Probe."
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Placeholders: {FROM_CODE} and {TO_CODE}."
+msgstr "Platzhalter: {FROM_CODE} und {TO_CODE}."
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Preview for post ID"
+msgstr "Vorschau für Beitrags-ID"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Probe: chat_template_kwargs not detected."
+msgstr "Probe: chat_template_kwargs nicht erkannt."
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Probe: chat_template_kwargs supported."
+msgstr "Probe: chat_template_kwargs unterstützt."
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Prompt template"
+msgstr "Prompt-Vorlage"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Recommended concurrency:"
+msgstr "Empfohlene Parallelität:"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Remove"
+msgstr "Entfernen"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Reset to default"
+msgstr "Auf Standard zurücksetzen"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "SEO plugin"
+msgstr "SEO-Plugin"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Save settings"
+msgstr "Einstellungen speichern"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Saving failed."
+msgstr "Speichern fehlgeschlagen."
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Settings saved."
+msgstr "Einstellungen gespeichert."
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Showing resolution for post"
+msgstr "Zeige Auflösung für Beitrag"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Speedup"
+msgstr "Beschleunigung"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Status"
+msgstr "Status"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "String-table concurrency (TranslatePress batches)"
+msgstr "String-Table-Parallelität (TranslatePress-Batches)"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Term"
+msgstr "Begriff"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Test concurrency"
+msgstr "Parallelität testen"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Translate (space-separated meta keys)"
+msgstr "Übersetzen (Meta-Schlüssel, durch Leerzeichen getrennt)"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Translate post slugs"
+msgstr "Beitrags-Slugs übersetzen"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Translate taxonomy terms alongside content"
+msgstr "Taxonomie-Begriffe zusammen mit Inhalten übersetzen"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Translation"
+msgstr "Übersetzung"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Translations (en=…, fr=…)"
+msgstr "Übersetzungen (en=…, fr=…)"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Transport diagnostics"
+msgstr "Transport-Diagnose"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Wall time (ms)"
+msgstr "Laufzeit (ms)"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "When enabled, SlyTranslate skips this field during automatic translation."
+msgstr "Wenn aktiviert, überspringt SlyTranslate dieses Feld bei der automatischen Übersetzung."
+
+#: slytranslate/inc/SettingsPage.php
+msgid "chunk size:"
+msgstr "Chunk-Größe:"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "detected"
+msgstr "erkannt"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "learned:"
+msgstr "gelernt:"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "recommended:"
+msgstr "empfohlen:"
+
+#: slytranslate/inc/SettingsPage.php
+msgid "tokens"
+msgstr "Tokens"
diff --git a/slytranslate/languages/slytranslate.pot b/slytranslate/languages/slytranslate.pot
index 67076a1..036b727 100644
--- a/slytranslate/languages/slytranslate.pot
+++ b/slytranslate/languages/slytranslate.pot
@@ -687,3 +687,287 @@ msgstr ""
#: slytranslate/slytranslate.php:489
msgid "You must be logged in to save preferences."
msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "AI client available"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "AI client missing — install an AI connector for in-WordPress translation"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Add glossary entry"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Additional instructions (site-wide)"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Additional keys"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Advanced"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Appended to every translation request, e.g. tone or brand wording."
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Automatically translate new posts on publish (as draft)"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Automation"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Built-in default"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Clear (space-separated meta keys)"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Cleared on translation"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Concurrency probe failed."
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Context window override (tokens, 0 = automatic)"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Default model"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Detected automatically from your SEO and field plugins. Unchecking a field excludes it from translation; the filter API can still override this."
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Direct API URL (OpenAI-compatible, optional)"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Direct API: chat_template_kwargs not detected"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Direct API: chat_template_kwargs supported"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Effective:"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Errors"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Exclude from AI translation"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Failed to load settings."
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Field plugins"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Filter API"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Fixed translation"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Glossary / do not translate"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Keep unchanged"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Level"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Load preview"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Loading settings …"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Manual key"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Meta fields"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Mode"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Model"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "No SEO plugin detected"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "No language plugin detected"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "No parallel transport available."
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "No translatable meta fields detected for this context."
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Only used by model profiles that require a direct endpoint (e.g. TranslateGemma). Saving runs a capability probe."
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Placeholders: {FROM_CODE} and {TO_CODE}."
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Preview for post ID"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Probe: chat_template_kwargs not detected."
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Probe: chat_template_kwargs supported."
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Prompt template"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Recommended concurrency:"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Remove"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Reset to default"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "SEO plugin"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Save settings"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Saving failed."
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Settings saved."
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Showing resolution for post"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Speedup"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Status"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "String-table concurrency (TranslatePress batches)"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Term"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Test concurrency"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Translate (space-separated meta keys)"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Translate post slugs"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Translate taxonomy terms alongside content"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Translation"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Translations (en=…, fr=…)"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Transport diagnostics"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "Wall time (ms)"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "When enabled, SlyTranslate skips this field during automatic translation."
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "chunk size:"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "detected"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "learned:"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "recommended:"
+msgstr ""
+
+#: slytranslate/inc/SettingsPage.php
+msgid "tokens"
+msgstr ""
diff --git a/slytranslate/readme.txt b/slytranslate/readme.txt
index 7c35692..fd62603 100644
--- a/slytranslate/readme.txt
+++ b/slytranslate/readme.txt
@@ -4,7 +4,7 @@ Tags: ai, translation, abilities-api, polylang, wp-multilang
Requires at least: 6.9
Tested up to: 7.0
Requires PHP: 8.1
-Stable tag: 1.10.0
+Stable tag: 1.11.0
License: MIT
License URI: https://opensource.org/licenses/MIT
@@ -22,7 +22,7 @@ It works with any LLM available through a WordPress AI connector and natively su
* Translates selected text or entire Gutenberg blocks inline, without leaving the editor
* Exposes the same functionality as MCP abilities, so external LLM tools (Claude Code, Codex, and others) can drive translations programmatically
* Carries SEO metadata (title, description) through the same translation workflow as the post content
-* Translates Advanced Custom Fields (ACF) `text`, `textarea`, and `wysiwyg` fields automatically — no configuration required when ACF is active
+* Translates custom fields from ACF (including ACF blocks and options pages), Meta Box, and Pods automatically — no configuration required when the field plugin is active
* Handles long and structured content with chunking and output validation
* Supports model-specific profiles that tune prompt style and retry behavior for known model families
@@ -121,11 +121,15 @@ In the MCP workflow the LLM Wrapper acts as the translation engine — the AI Co
`ai-translate/translate-content` — Create or update one translated post/page/CPT entry
-`ai-translate/translate-content-bulk` — Bulk-translate multiple entries
+`ai-translate/translate-content-bulk` — Bulk-translate multiple entries (set `background=true` to queue the batch via Action Scheduler/WP-Cron and poll it with `get-progress`)
-`ai-translate/get-progress` — Return live progress for a running translation
+`ai-translate/translate-terms` — Bulk-translate taxonomy terms and link them as translations (Polylang only)
-`ai-translate/cancel-translation` — Cancel a running translation
+`ai-translate/get-translatable-fields` — Introspect which meta fields a translation would translate or clear, with source attribution (manual/SEO/ACF/default/filter)
+
+`ai-translate/get-progress` — Return live progress for a running translation or a queued background job (`job_id`)
+
+`ai-translate/cancel-translation` — Cancel a running translation or a queued background job (`job_id`)
`ai-translate/get-available-models` — List models from configured connectors
@@ -154,7 +158,9 @@ For reliable results in agent workflows:
**Field plugins** (custom fields translated alongside content)
-* Advanced Custom Fields (ACF) — Free and Pro, including Repeater and Flexible Content
+* Advanced Custom Fields (ACF) — Free and Pro, including Repeater and Flexible Content, ACF blocks in Gutenberg, and options pages
+* Meta Box (metabox.io)
+* Pods
**SEO plugins** (metadata translated alongside content)
@@ -212,10 +218,20 @@ To extend which field types are translated, add a filter to your theme's `functi
`add_filter( 'slytranslate_acf_translatable_field_types', function ( $types ) { $types[] = 'url'; return $types; } );`
-To exclude a specific field key from translation:
+To exclude fields there are three options: enable the "Exclude from AI translation" toggle in the ACF field editor, add the meta keys to the `meta_keys_exclude` setting (via the `configure` MCP ability), or use the `slytranslate_translate_meta_key` filter, which has the final say:
`add_filter( 'slytranslate_translate_meta_key', function ( $translate, $meta_key ) { return $meta_key === 'my_field' ? false : $translate; }, 10, 2 );`
+= Are ACF blocks and options pages covered? =
+
+Yes. Field data stored inside `acf/*` Gutenberg blocks is translated automatically along with the post content, including repeater sub-fields. ACF `link` fields translate only the link title — URL and target are never sent to the model.
+
+ACF fields on options pages are translated through the `ai-translate/translate-options` MCP ability. With WPGlobus or WP Multilang the translated variant is merged into the option value; TranslatePress needs no separate step (its string table already translates option output). Polylang has no language-aware option storage — opt in to per-language storage via the `slytranslate_acf_options_post_id` filter.
+
+= Do Meta Box or Pods fields get translated automatically? =
+
+Yes. Translatable field types (`text`, `textarea`, `wysiwyg` for Meta Box; `text`, `paragraph`, `wysiwyg` for Pods) are detected through each plugin's field registry. The type lists are extensible via the `slytranslate_metabox_translatable_field_types` and `slytranslate_pods_translatable_field_types` filters.
+
= Does this work without a language plugin? =
Yes, for inline text and block translation. Content translation workflows (full post/page) require a supported language plugin.
@@ -228,6 +244,26 @@ In WordPress Settings > Connectors, not inside SlyTranslate.
Yes. Select items in wp-admin, pick the bulk translation action, choose language and model, and confirm.
+= Can bulk jobs run in the background? =
+
+Yes. Call `ai-translate/translate-content-bulk` with `background=true` and the batch is queued with one post per scheduled action — via Action Scheduler when available (bundled by WooCommerce), otherwise via WP-Cron. Poll with `get-progress` (`job_id`) and cancel with `cancel-translation` (`job_id`). Jobs survive closed browser tabs and PHP timeouts. Without a working cron the browser-driven mode keeps working as before.
+
+= Are taxonomy terms (categories, tags) translated? =
+
+With Polylang, enable `translate_terms` in `ai-translate/configure`: terms without a target-language translation are then created and linked automatically during post translation instead of being dropped. For existing sites, `ai-translate/translate-terms` bulk-translates whole taxonomies (use `dry_run=true` to preview).
+
+= Are image alt texts translated? =
+
+Yes. Alt texts embedded in the content are translated with the content. The attachment meta alt text (`_wp_attachment_image_alt`) is translated by default, and with Polylang's media translation enabled, duplicating a media item also translates its alt text, caption, and description.
+
+= Can I protect brand names or enforce fixed terminology? =
+
+Yes. Configure the `glossary` setting via `ai-translate/configure`: entries with `mode=keep` are never translated; entries with `mode=translate` always use your fixed per-language translation.
+
+= Can new posts be translated automatically on publish? =
+
+Yes. Enable `auto_translate_new` in `ai-translate/configure`. Publishing a post in the source language queues draft translations for all missing target languages in the background — they are never auto-published, so editorial review stays in place.
+
= Does this work inside the TranslatePress visual editor? =
Yes. On pages opened with `?trp-edit-translation=true`, SlyTranslate adds a sidebar panel with the same model, overwrite, progress, and cancel controls used elsewhere.
@@ -242,7 +278,7 @@ Yes, when using Polylang. Call `ai-translate/set-post-language` with `post_id` a
= How do I control the prompt and translation style? =
-Use `ai-translate/configure` for persistent defaults. Pass `additional_prompt` on any `translate-*` call for per-request instructions.
+Use the settings page (Settings → SlyTranslate) or `ai-translate/configure` for persistent defaults — both write through the same backend. Pass `additional_prompt` on any `translate-*` call for per-request instructions.
= Why does execute-ability fail even when discovery looks correct? =
diff --git a/slytranslate/slytranslate.php b/slytranslate/slytranslate.php
index 545ce28..3bc27b5 100644
--- a/slytranslate/slytranslate.php
+++ b/slytranslate/slytranslate.php
@@ -3,7 +3,7 @@
Plugin Name: SlyTranslate - AI Translation Abilities
Plugin URI: https://github.com/SlyBase/wordpress-slytranslate/
Description: AI translation abilities for WordPress using native AI Connectors as a core feature, plus the AI Client and Abilities API for text and content translation.
-Version: 1.10.0
+Version: 1.11.0
Author: Timon Först
Author URI: https://slybase.com
Requires at least: 6.9
@@ -110,13 +110,23 @@ public static function current_user_can_access_translation_abilities(): bool {
public static function add_hooks(): void {
self::maybe_migrate_legacy_options();
+ // Priority 5 so third-party callbacks at default priority 10 can still
+ // overrule the user-configured exclusion list.
+ add_filter( 'slytranslate_translate_meta_key', array( MetaTranslationService::class, 'filter_excluded_meta_key' ), 5, 2 );
add_action( 'enqueue_block_editor_assets', array( EditorBootstrap::class, 'enqueue_editor_plugin' ) );
add_action( 'admin_init', array( Settings::class, 'register' ) );
+ SettingsPage::add_hooks();
add_action( 'rest_api_init', array( self::class, 'register_editor_rest_routes' ) );
add_action( 'wp_abilities_api_categories_init', array( AbilityRegistrar::class, 'register_ability_category' ) );
add_action( 'wp_abilities_api_init', array( AbilityRegistrar::class, 'register_abilities' ) );
TranslatePressEditorIntegration::add_hooks();
ListTableTranslation::add_hooks();
+ // Background queue worker (one post per scheduled action).
+ add_action( TranslationQueue::WORKER_HOOK, array( TranslationQueue::class, 'process_queued_translation' ), 10, 2 );
+ // Auto-translate on publish (consumes the slytranslate_new_post option).
+ add_action( 'transition_post_status', array( AutoPublishTranslationService::class, 'handle_transition_post_status' ), 10, 3 );
+ // Polylang media duplication: translate alt text, caption, description.
+ add_action( 'pll_translate_media', array( MediaTranslationService::class, 'handle_pll_translate_media' ), 10, 3 );
}
/**
@@ -198,10 +208,22 @@ public static function register_editor_rest_routes(): void {
'callback' => array( self::class, 'execute_translate_posts' ),
'permission_callback' => $translation_permission,
),
+ '/ai-translate/translate-terms/run' => array(
+ 'callback' => array( self::class, 'execute_translate_terms' ),
+ 'permission_callback' => $translation_permission,
+ ),
+ '/ai-translate/get-translatable-fields/run' => array(
+ 'callback' => array( self::class, 'execute_get_translatable_fields' ),
+ 'permission_callback' => $translation_permission,
+ ),
'/ai-translate/configure/run' => array(
'callback' => array( self::class, 'execute_configure' ),
'permission_callback' => $admin_permission,
),
+ '/ai-translate/probe-string-table-concurrency/run' => array(
+ 'callback' => array( self::class, 'execute_probe_string_table_concurrency' ),
+ 'permission_callback' => $admin_permission,
+ ),
'/ai-translate/get-progress/run' => array(
'callback' => array( self::class, 'execute_get_progress' ),
'permission_callback' => $translation_permission,
@@ -571,6 +593,33 @@ function () use ( $input ) {
$additional_prompt = '';
}
+ // Background mode: hand the job to the queue (one action per
+ // post) so it survives closed tabs and PHP timeouts. Falls
+ // through to the synchronous loop when no transport exists.
+ if ( ! empty( $input['background'] ) && TranslationQueue::is_available() ) {
+ $queued = TranslationQueue::enqueue_bulk( $post_ids, $target_language, array(
+ 'post_status' => self::get_optional_sanitized_key_input( $input, 'post_status' ),
+ 'overwrite' => $overwrite,
+ 'translate_title' => $input['translate_title'] ?? true,
+ 'additional_prompt' => $additional_prompt,
+ 'source_language' => $requested_source_language,
+ 'model_slug' => isset( $input['model_slug'] ) && is_string( $input['model_slug'] ) ? $input['model_slug'] : '',
+ ) );
+ if ( is_wp_error( $queued ) ) {
+ return $queued;
+ }
+
+ return array(
+ 'results' => array(),
+ 'total' => $queued['total'],
+ 'succeeded' => 0,
+ 'failed' => 0,
+ 'skipped' => 0,
+ 'queued' => true,
+ 'job_id' => $queued['job_id'],
+ );
+ }
+
foreach ( $post_ids as $post_id ) {
if ( ! current_user_can( 'edit_post', $post_id ) ) {
$failed++;
@@ -611,6 +660,21 @@ function () use ( $input ) {
);
}
+ public static function execute_translate_options( $input ) {
+ $input = is_array( $input ) ? $input : array();
+ return AcfOptionsTranslationService::translate_options( $input );
+ }
+
+ public static function execute_translate_terms( $input ) {
+ return TermTranslationService::execute_translate_terms( is_array( $input ) ? $input : array() );
+ }
+
+ public static function execute_get_translatable_fields( $input ): array {
+ $input = is_array( $input ) ? $input : array();
+ $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0;
+ return MetaTranslationService::get_translatable_fields_report( $post_id );
+ }
+
public static function execute_configure( $input ) {
$input = is_array( $input ) ? $input : array();
$config_result = ConfigurationService::save( $input );
@@ -630,7 +694,11 @@ public static function execute_configure( $input ) {
'prompt_addon' => get_option( 'slytranslate_prompt_addon', '' ),
'meta_keys_translate' => get_option( 'slytranslate_meta_translate', '' ),
'meta_keys_clear' => get_option( 'slytranslate_meta_clear', '' ),
+ 'meta_keys_exclude' => get_option( 'slytranslate_meta_keys_exclude', '' ),
'auto_translate_new' => get_option( 'slytranslate_new_post', '0' ) === '1',
+ 'translate_terms' => get_option( 'slytranslate_translate_terms', '0' ) === '1',
+ 'translate_slugs' => get_option( 'slytranslate_translate_slugs', '0' ) === '1',
+ 'glossary' => GlossaryService::get_entries(),
'context_window_tokens' => absint( get_option( 'slytranslate_context_window_tokens', 0 ) ),
'string_table_concurrency' => ConfigurationService::get_string_table_concurrency_setting(),
'string_table_concurrency_effective' => ConfigurationService::get_effective_string_table_concurrency()['effective'],
@@ -657,6 +725,20 @@ public static function execute_configure( $input ) {
);
}
+ /**
+ * Settings-UI action: run the string-table concurrency probe for the
+ * given (or configured) model and return the per-level results.
+ */
+ public static function execute_probe_string_table_concurrency( $input ): array {
+ $input = is_array( $input ) ? $input : array();
+ $model_slug = isset( $input['model_slug'] ) && is_string( $input['model_slug'] ) ? $input['model_slug'] : '';
+ if ( '' === $model_slug ) {
+ $model_slug = TranslationRuntime::get_requested_model_slug();
+ }
+
+ return ConfigurationService::probe_string_table_concurrency( $model_slug );
+ }
+
public static function execute_string_table_worker( $input ) {
$input = is_array( $input ) ? $input : array();
$token = isset( $input['token'] ) && is_string( $input['token'] ) ? $input['token'] : '';
@@ -726,7 +808,17 @@ static function () use ( $input, $action ) {
}
public static function execute_get_progress( $input ): array {
- $input = is_array( $input ) ? $input : array();
+ $input = is_array( $input ) ? $input : array();
+
+ // Queue-job progress (background bulk translation).
+ $job_id = isset( $input['job_id'] ) && is_string( $input['job_id'] ) ? sanitize_key( $input['job_id'] ) : '';
+ if ( '' !== $job_id ) {
+ $job_status = TranslationQueue::get_job_status( $job_id );
+ if ( null !== $job_status ) {
+ return $job_status;
+ }
+ }
+
$post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0;
return TranslationProgressTracker::get_progress( $post_id );
}
@@ -736,7 +828,12 @@ public static function execute_cancel_translation( $input ): array {
$input = is_array( $input ) ? $input : array();
$post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0;
TranslationProgressTracker::clear_progress( $post_id );
- return array( 'cancelled' => true );
+
+ // Queue-job cancellation: unschedule all pending actions of the job.
+ $job_id = isset( $input['job_id'] ) && is_string( $input['job_id'] ) ? sanitize_key( $input['job_id'] ) : '';
+ $job_cancelled = '' !== $job_id ? TranslationQueue::cancel( $job_id ) : false;
+
+ return array( 'cancelled' => true, 'job_cancelled' => $job_cancelled );
}
public static function execute_get_available_models( $input ): array {
@@ -859,6 +956,9 @@ public static function on_activate(): void {
'slytranslate_meta_translate',
'slytranslate_meta_clear',
'slytranslate_new_post',
+ 'slytranslate_translate_terms',
+ 'slytranslate_translate_slugs',
+ 'slytranslate_glossary',
'slytranslate_context_window_tokens',
'slytranslate_model_slug',
'slytranslate_direct_api_url',
diff --git a/slytranslate/tests/Unit/AbilityRegistrationTest.php b/slytranslate/tests/Unit/AbilityRegistrationTest.php
index d84fea5..4a7b1a7 100644
--- a/slytranslate/tests/Unit/AbilityRegistrationTest.php
+++ b/slytranslate/tests/Unit/AbilityRegistrationTest.php
@@ -53,7 +53,7 @@ static function ( string $slug, array $args ) use ( &$registered_categories ): v
public function test_register_abilities_registers_expected_ability_contracts(): void {
$registered_abilities = $this->capture_registered_abilities();
- $this->assertCount( 17, $registered_abilities );
+ $this->assertCount( 19, $registered_abilities );
$this->assertSame( array_keys( $this->expected_ability_contracts() ), array_keys( $registered_abilities ) );
$this->assertArrayNotHasKey( 'ai-translate/translate-post', $registered_abilities );
@@ -601,7 +601,7 @@ private function expected_ability_contracts(): array {
'execute_callback' => array( AI_Translate::class, 'execute_translate_posts' ),
'input_schema' => array(
'type' => 'object',
- 'property_keys' => array( 'post_ids', 'post_type', 'limit', 'source_language', 'target_language', 'post_status', 'translate_title', 'overwrite', 'additional_prompt', 'model_slug' ),
+ 'property_keys' => array( 'post_ids', 'post_type', 'limit', 'source_language', 'target_language', 'post_status', 'translate_title', 'overwrite', 'additional_prompt', 'model_slug', 'background' ),
'required' => array( 'target_language' ),
'properties' => array(
'post_ids' => array(
@@ -653,11 +653,15 @@ private function expected_ability_contracts(): array {
'type' => 'string',
'description' => 'Model slug/identifier to use for this translation batch. Overrides the site-wide default.',
),
+ 'background' => array(
+ 'type' => 'boolean',
+ 'default' => false,
+ ),
),
),
'output_schema' => array(
'type' => 'object',
- 'property_keys' => array( 'results', 'total', 'succeeded', 'failed', 'skipped' ),
+ 'property_keys' => array( 'results', 'total', 'succeeded', 'failed', 'skipped', 'queued', 'job_id' ),
'properties' => array(
'results' => array(
'type' => 'array',
@@ -677,27 +681,113 @@ private function expected_ability_contracts(): array {
'succeeded' => array( 'type' => 'integer' ),
'failed' => array( 'type' => 'integer' ),
'skipped' => array( 'type' => 'integer' ),
+ 'queued' => array( 'type' => 'boolean' ),
+ 'job_id' => array( 'type' => 'string' ),
+ ),
+ ),
+ 'meta' => $this->expected_public_mcp_meta(),
+ ),
+ 'ai-translate/translate-options' => array(
+ 'description' => 'Translates ACF fields on options pages (global content such as footer texts or CTAs) into one target language. Registered only when ACF options pages exist. With TranslatePress this returns not_required because its string table already covers option output. Use dry_run=true to preview which fields would be translated.',
+ 'execute_callback' => array( AI_Translate::class, 'execute_translate_options' ),
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'property_keys' => array( 'target_language', 'source_language', 'page_slug', 'dry_run' ),
+ 'required' => array( 'target_language' ),
+ 'properties' => array(
+ 'target_language' => array( 'type' => 'string' ),
+ 'source_language' => array( 'type' => 'string' ),
+ 'page_slug' => array( 'type' => 'string' ),
+ 'dry_run' => array( 'type' => 'boolean', 'default' => false ),
+ ),
+ ),
+ 'output_schema' => array(
+ 'type' => 'object',
+ 'property_keys' => array( 'status', 'message', 'target_language', 'source_language', 'dry_run', 'results', 'total', 'translated', 'skipped', 'failed' ),
+ 'properties' => array(
+ 'status' => array( 'type' => 'string' ),
+ 'message' => array( 'type' => 'string' ),
+ 'target_language' => array( 'type' => 'string' ),
+ 'source_language' => array( 'type' => 'string' ),
+ 'dry_run' => array( 'type' => 'boolean' ),
+ 'results' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'property_keys' => array( 'field', 'field_label', 'field_type', 'page', 'status', 'reason' ),
+ ),
+ ),
+ 'total' => array( 'type' => 'integer' ),
+ 'translated' => array( 'type' => 'integer' ),
+ 'skipped' => array( 'type' => 'integer' ),
+ 'failed' => array( 'type' => 'integer' ),
),
),
'meta' => $this->expected_public_mcp_meta(),
),
+ 'ai-translate/get-translatable-fields' => array(
+ 'execute_callback' => array( AI_Translate::class, 'execute_get_translatable_fields' ),
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'property_keys' => array( 'post_id' ),
+ 'properties' => array(
+ 'post_id' => array( 'type' => 'integer' ),
+ ),
+ ),
+ 'output_schema' => array(
+ 'type' => 'object',
+ 'property_keys' => array( 'post_id', 'fields', 'seo_plugin', 'seo_plugin_label', 'active_resolvers' ),
+ 'required' => array( 'fields' ),
+ 'properties' => array(
+ 'post_id' => array( 'type' => 'integer' ),
+ 'fields' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'property_keys' => array( 'key', 'action', 'source', 'field_type', 'field_label', 'excluded' ),
+ 'required' => array( 'key', 'action', 'source' ),
+ 'properties' => array(
+ 'key' => array( 'type' => 'string' ),
+ 'action' => array( 'type' => 'string', 'enum' => array( 'translate', 'clear' ) ),
+ 'source' => array( 'type' => 'string', 'enum' => array( 'manual', 'seo', 'acf', 'default', 'filter' ) ),
+ 'field_type' => array( 'type' => 'string' ),
+ 'field_label' => array( 'type' => 'string' ),
+ 'excluded' => array( 'type' => 'boolean' ),
+ ),
+ ),
+ ),
+ 'seo_plugin' => array( 'type' => 'string' ),
+ 'seo_plugin_label' => array( 'type' => 'string' ),
+ 'active_resolvers' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ),
+ ),
+ ),
+ 'meta' => $this->expected_public_mcp_meta( array( 'readonly' => true ) ),
+ ),
'ai-translate/get-progress' => array(
'execute_callback' => array( AI_Translate::class, 'execute_get_progress' ),
'input_schema' => array(
'type' => 'object',
- 'property_keys' => array( 'post_id' ),
+ 'property_keys' => array( 'post_id', 'job_id' ),
'properties' => array(
'post_id' => array( 'type' => 'integer' ),
+ 'job_id' => array( 'type' => 'string' ),
),
),
'output_schema' => array(
'type' => 'object',
- 'property_keys' => array( 'phase', 'percent', 'current_chunk', 'total_chunks' ),
+ 'property_keys' => array( 'phase', 'percent', 'current_chunk', 'total_chunks', 'job_id', 'status', 'total', 'processed', 'succeeded', 'failed', 'skipped' ),
'properties' => array(
'phase' => array( 'type' => 'string' ),
'percent' => array( 'type' => 'integer' ),
'current_chunk' => array( 'type' => 'integer' ),
'total_chunks' => array( 'type' => 'integer' ),
+ 'job_id' => array( 'type' => 'string' ),
+ 'status' => array( 'type' => 'string' ),
+ 'total' => array( 'type' => 'integer' ),
+ 'processed' => array( 'type' => 'integer' ),
+ 'succeeded' => array( 'type' => 'integer' ),
+ 'failed' => array( 'type' => 'integer' ),
+ 'skipped' => array( 'type' => 'integer' ),
),
),
'meta' => $this->expected_public_mcp_meta( array( 'readonly' => true ) ),
@@ -706,17 +796,19 @@ private function expected_ability_contracts(): array {
'execute_callback' => array( AI_Translate::class, 'execute_cancel_translation' ),
'input_schema' => array(
'type' => 'object',
- 'property_keys' => array( 'post_id' ),
+ 'property_keys' => array( 'post_id', 'job_id' ),
'properties' => array(
'post_id' => array( 'type' => 'integer' ),
+ 'job_id' => array( 'type' => 'string' ),
),
),
'output_schema' => array(
'type' => 'object',
- 'property_keys' => array( 'cancelled' ),
+ 'property_keys' => array( 'cancelled', 'job_cancelled' ),
'required' => array( 'cancelled' ),
'properties' => array(
- 'cancelled' => array( 'type' => 'boolean' ),
+ 'cancelled' => array( 'type' => 'boolean' ),
+ 'job_cancelled' => array( 'type' => 'boolean' ),
),
),
'meta' => $this->expected_public_mcp_meta(),
@@ -767,7 +859,7 @@ private function expected_ability_contracts(): array {
'execute_callback' => array( AI_Translate::class, 'execute_configure' ),
'input_schema' => array(
'type' => 'object',
- 'property_keys' => array( 'prompt_template', 'prompt_addon', 'meta_keys_translate', 'meta_keys_clear', 'auto_translate_new', 'context_window_tokens', 'string_table_concurrency', 'model_slug', 'direct_api_url', 'force_direct_api' ),
+ 'property_keys' => array( 'prompt_template', 'prompt_addon', 'meta_keys_translate', 'meta_keys_clear', 'meta_keys_exclude', 'auto_translate_new', 'translate_terms', 'translate_slugs', 'glossary', 'context_window_tokens', 'string_table_concurrency', 'model_slug', 'direct_api_url', 'force_direct_api' ),
'properties' => array(
'prompt_template' => array(
'type' => 'string',
@@ -785,9 +877,27 @@ private function expected_ability_contracts(): array {
'type' => 'string',
'description' => 'Persistent setting: whitespace-separated list of meta keys to clear. Use a plain string, not an array.',
),
+ 'meta_keys_exclude' => array(
+ 'type' => 'string',
+ 'description' => 'Persistent setting: whitespace-separated list of meta keys to exclude from translation. Applies to manual, SEO-detected, and auto-resolved field-plugin keys alike.',
+ ),
'auto_translate_new' => array(
'type' => 'boolean',
- 'description' => 'Persistent setting: auto-translate new translation posts created by the active language plugin.',
+ 'description' => 'Persistent setting: queue draft translations for all missing target languages when a source-language post is published. Requires a background transport (Action Scheduler or WP-Cron).',
+ ),
+ 'translate_terms' => array(
+ 'type' => 'boolean',
+ ),
+ 'translate_slugs' => array(
+ 'type' => 'boolean',
+ ),
+ 'glossary' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'property_keys' => array( 'term', 'mode', 'to' ),
+ 'required' => array( 'term', 'mode' ),
+ ),
),
'context_window_tokens' => array(
'type' => 'integer',
@@ -822,7 +932,11 @@ private function expected_ability_contracts(): array {
'prompt_addon',
'meta_keys_translate',
'meta_keys_clear',
+ 'meta_keys_exclude',
'auto_translate_new',
+ 'translate_terms',
+ 'translate_slugs',
+ 'glossary',
'context_window_tokens',
'string_table_concurrency',
'string_table_concurrency_effective',
@@ -852,7 +966,11 @@ private function expected_ability_contracts(): array {
'prompt_addon' => array( 'type' => 'string' ),
'meta_keys_translate' => array( 'type' => 'string' ),
'meta_keys_clear' => array( 'type' => 'string' ),
+ 'meta_keys_exclude' => array( 'type' => 'string' ),
'auto_translate_new' => array( 'type' => 'boolean' ),
+ 'translate_terms' => array( 'type' => 'boolean' ),
+ 'translate_slugs' => array( 'type' => 'boolean' ),
+ 'glossary' => array( 'type' => 'array' ),
'context_window_tokens' => array( 'type' => 'integer' ),
'string_table_concurrency' => array( 'type' => 'integer' ),
'string_table_concurrency_effective' => array( 'type' => 'integer' ),
diff --git a/slytranslate/tests/Unit/AcfBlockTranslatorTest.php b/slytranslate/tests/Unit/AcfBlockTranslatorTest.php
new file mode 100644
index 0000000..22e42f3
--- /dev/null
+++ b/slytranslate/tests/Unit/AcfBlockTranslatorTest.php
@@ -0,0 +1,323 @@
+setStaticProperty( TranslationRuntime::class, 'model_profile_cache', array() );
+ $this->setStaticProperty( TranslationRuntime::class, 'model_slug_override', null );
+
+ $this->stubWpFunction( 'apply_filters', static function ( string $tag, $value, ...$args ) {
+ return $value;
+ } );
+ $this->stubWpFunctionReturn( 'get_option', '' );
+ $this->stubWpFunctionReturn( 'get_current_user_id', 1 );
+
+ $this->stubWpFunction( 'acf_get_field', static function ( $ref ) {
+ $fields = array(
+ 'field_headline' => array( 'type' => 'text' ),
+ 'field_body' => array( 'type' => 'textarea' ),
+ 'field_caption' => array( 'type' => 'text' ),
+ 'field_image' => array( 'type' => 'image' ),
+ 'field_link' => array( 'type' => 'link' ),
+ );
+ return $fields[ $ref ] ?? false;
+ } );
+ }
+
+ protected function tearDown(): void {
+ $this->setStaticProperty( TranslationRuntime::class, 'model_profile_cache', array() );
+ $this->setStaticProperty( TranslationRuntime::class, 'model_slug_override', null );
+ parent::tearDown();
+ }
+
+ /**
+ * Stub wp_ai_client_prompt with a fixed response text.
+ */
+ private function stubAiResponse( string $response_text, array &$calls = array() ): void {
+ $this->stubWpFunction(
+ 'wp_ai_client_prompt',
+ static function ( string $text ) use ( $response_text, &$calls ) {
+ $calls[] = $text;
+ return new class( $response_text ) {
+ private string $response;
+
+ public function __construct( string $response ) {
+ $this->response = $response;
+ }
+
+ public function using_system_instruction( string $p ): static { return $this; }
+ public function using_temperature( float $t ): static { return $this; }
+ public function using_model_preference( string $s ): static { return $this; }
+ public function using_max_tokens( int $n ): static { return $this; }
+ public function using_max_output_tokens( int $n ): static { return $this; }
+
+ public function generate_text(): string {
+ return '' . $this->response . '';
+ }
+ };
+ }
+ );
+ }
+
+ /* ---------------------------------------------------------------
+ * collect_translatable_entries
+ * ------------------------------------------------------------- */
+
+ public function test_collects_only_referenced_translatable_string_values(): void {
+ $data = array(
+ 'headline' => 'Hello',
+ '_headline' => 'field_headline',
+ 'image' => '42',
+ '_image' => 'field_image',
+ 'empty' => ' ',
+ '_empty' => 'field_body',
+ 'unref' => 'No reference key',
+ 'mode' => 'preview',
+ );
+
+ $entries = AcfBlockTranslator::collect_translatable_entries( $data );
+
+ $this->assertSame( array( 'headline' => 'Hello' ), $entries );
+ }
+
+ public function test_repeater_sub_keys_are_collected_via_their_own_reference(): void {
+ $data = array(
+ 'slides_0_caption' => 'First slide',
+ '_slides_0_caption' => 'field_caption',
+ 'slides_1_caption' => 'Second slide',
+ '_slides_1_caption' => 'field_caption',
+ 'slides' => '2',
+ '_slides' => 'field_image',
+ );
+
+ $entries = AcfBlockTranslator::collect_translatable_entries( $data );
+
+ $this->assertSame(
+ array(
+ 'slides_0_caption' => 'First slide',
+ 'slides_1_caption' => 'Second slide',
+ ),
+ $entries
+ );
+ }
+
+ public function test_structured_link_values_are_not_collected(): void {
+ $data = array(
+ 'cta' => 'https://example.com',
+ '_cta' => 'field_link',
+ );
+
+ $this->assertSame( array(), AcfBlockTranslator::collect_translatable_entries( $data ) );
+ }
+
+ /* ---------------------------------------------------------------
+ * translate_block_data / translate_blocks_data
+ * ------------------------------------------------------------- */
+
+ public function test_translate_block_data_batches_multiple_values_in_one_call(): void {
+ $calls = array();
+ $this->stubAiResponse( '{"headline":"Hallo","body":"Willkommen auf unserer Seite"}', $calls );
+
+ $data = array(
+ 'headline' => 'Hello',
+ '_headline' => 'field_headline',
+ 'body' => 'Welcome to our site',
+ '_body' => 'field_body',
+ 'mode' => 'preview',
+ 'id' => 'block_abc',
+ );
+
+ $result = AcfBlockTranslator::translate_block_data( $data, 'de', 'en' );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'Hallo', $result['headline'] );
+ $this->assertSame( 'Willkommen auf unserer Seite', $result['body'] );
+ // References and non-translatable entries stay untouched.
+ $this->assertSame( 'field_headline', $result['_headline'] );
+ $this->assertSame( 'preview', $result['mode'] );
+ $this->assertSame( 'block_abc', $result['id'] );
+ // One single batched AI call.
+ $this->assertCount( 1, $calls );
+ }
+
+ public function test_translate_block_data_single_value_uses_individual_call(): void {
+ $this->stubAiResponse( 'Hallo' );
+
+ $data = array(
+ 'headline' => 'Hello',
+ '_headline' => 'field_headline',
+ );
+
+ $result = AcfBlockTranslator::translate_block_data( $data, 'de', 'en' );
+
+ $this->assertSame( 'Hallo', $result['headline'] );
+ }
+
+ public function test_translate_block_data_without_translatable_entries_makes_no_ai_call(): void {
+ $calls = array();
+ $this->stubAiResponse( 'should not be used', $calls );
+
+ $data = array(
+ 'image' => '42',
+ '_image' => 'field_image',
+ );
+
+ $result = AcfBlockTranslator::translate_block_data( $data, 'de', 'en' );
+
+ $this->assertSame( $data, $result );
+ $this->assertCount( 0, $calls );
+ }
+
+ public function test_translate_blocks_data_recurses_into_inner_blocks(): void {
+ $this->stubAiResponse( 'Hallo' );
+
+ $blocks = array(
+ array(
+ 'blockName' => 'core/group',
+ 'attrs' => array(),
+ 'innerBlocks' => array(
+ array(
+ 'blockName' => 'acf/hero',
+ 'attrs' => array(
+ 'name' => 'acf/hero',
+ 'data' => array(
+ 'headline' => 'Hello',
+ '_headline' => 'field_headline',
+ ),
+ 'mode' => 'preview',
+ ),
+ 'innerBlocks' => array(),
+ ),
+ ),
+ ),
+ array(
+ 'blockName' => 'core/paragraph',
+ 'attrs' => array(),
+ 'innerBlocks' => array(),
+ ),
+ );
+
+ $result = AcfBlockTranslator::translate_blocks_data( $blocks, 'de', 'en' );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'Hallo', $result[0]['innerBlocks'][0]['attrs']['data']['headline'] );
+ $this->assertSame( 'field_headline', $result[0]['innerBlocks'][0]['attrs']['data']['_headline'] );
+ $this->assertSame( 'preview', $result[0]['innerBlocks'][0]['attrs']['mode'] );
+ // Non-ACF blocks pass through untouched.
+ $this->assertSame( 'core/paragraph', $result[1]['blockName'] );
+ }
+
+ /* ---------------------------------------------------------------
+ * Client workflow units (round trip)
+ * ------------------------------------------------------------- */
+
+ private function sample_parsed_blocks(): array {
+ return array(
+ array(
+ 'blockName' => 'acf/hero',
+ 'attrs' => array(
+ 'name' => 'acf/hero',
+ 'data' => array(
+ 'headline' => 'Hello',
+ '_headline' => 'field_headline',
+ ),
+ 'mode' => 'preview',
+ ),
+ 'innerBlocks' => array(),
+ ),
+ array(
+ 'blockName' => 'core/group',
+ 'attrs' => array(),
+ 'innerBlocks' => array(
+ array(
+ 'blockName' => 'acf/slide',
+ 'attrs' => array(
+ 'name' => 'acf/slide',
+ 'data' => array(
+ 'caption' => 'A caption',
+ '_caption' => 'field_caption',
+ ),
+ ),
+ 'innerBlocks' => array(),
+ ),
+ ),
+ ),
+ );
+ }
+
+ public function test_build_block_units_encodes_block_paths(): void {
+ $this->stubWpFunctionReturn( 'parse_blocks', $this->sample_parsed_blocks() );
+
+ $units = AcfBlockTranslator::build_block_units( '' );
+
+ $this->assertCount( 2, $units );
+ $this->assertSame( 'acf_block:0:headline', $units[0]['id'] );
+ $this->assertSame( 'Hello', $units[0]['source'] );
+ $this->assertSame( 'acf_block', $units[0]['field'] );
+ $this->assertSame( 'acf_block:1.0:caption', $units[1]['id'] );
+ $this->assertSame( 'A caption', $units[1]['source'] );
+ }
+
+ public function test_apply_block_unit_translations_round_trip(): void {
+ $this->stubWpFunctionReturn( 'parse_blocks', $this->sample_parsed_blocks() );
+ $this->stubWpFunction( 'serialize_blocks', static function ( array $blocks ): string {
+ return (string) json_encode( $blocks );
+ } );
+
+ $result = AcfBlockTranslator::apply_block_unit_translations(
+ '',
+ array(
+ 'acf_block:0:headline' => 'Hallo',
+ 'acf_block:1.0:caption' => 'Eine Bildunterschrift',
+ 'title' => 'ignored non-acf unit',
+ )
+ );
+
+ $decoded = json_decode( $result, true );
+ $this->assertIsArray( $decoded );
+ $this->assertSame( 'Hallo', $decoded[0]['attrs']['data']['headline'] );
+ $this->assertSame( 'field_headline', $decoded[0]['attrs']['data']['_headline'] );
+ $this->assertSame( 'Eine Bildunterschrift', $decoded[1]['innerBlocks'][0]['attrs']['data']['caption'] );
+ }
+
+ public function test_apply_block_unit_translations_skips_mismatched_paths(): void {
+ $this->stubWpFunctionReturn( 'parse_blocks', $this->sample_parsed_blocks() );
+ $this->stubWpFunction( 'serialize_blocks', static function ( array $blocks ): string {
+ return (string) json_encode( $blocks );
+ } );
+
+ $content = '';
+ $result = AcfBlockTranslator::apply_block_unit_translations(
+ $content,
+ array(
+ 'acf_block:9:headline' => 'wrong index',
+ 'acf_block:0:nonexistent' => 'wrong key',
+ 'acf_block:1:caption' => 'wrong depth (block 1 is core/group)',
+ )
+ );
+
+ // Nothing applied → original content returned unchanged.
+ $this->assertSame( $content, $result );
+ }
+
+ public function test_apply_block_unit_translations_without_acf_units_returns_content(): void {
+ $content = 'Hello
';
+
+ $this->assertSame(
+ $content,
+ AcfBlockTranslator::apply_block_unit_translations( $content, array( 'title' => 'Hallo' ) )
+ );
+ }
+}
diff --git a/slytranslate/tests/Unit/AcfFieldIntrospectorTest.php b/slytranslate/tests/Unit/AcfFieldIntrospectorTest.php
new file mode 100644
index 0000000..593934f
--- /dev/null
+++ b/slytranslate/tests/Unit/AcfFieldIntrospectorTest.php
@@ -0,0 +1,81 @@
+stubWpFunction( 'apply_filters', static function ( string $tag, $value, ...$args ) {
+ return $value;
+ } );
+
+ $this->stubWpFunction( 'acf_get_field', static function ( $ref ) {
+ $fields = array(
+ 'field_text' => array( 'type' => 'text', 'label' => 'Text' ),
+ 'field_link' => array( 'type' => 'link', 'label' => 'CTA' ),
+ 'field_number' => array( 'type' => 'number', 'label' => 'Count' ),
+ 'field_excluded' => array( 'type' => 'text', 'label' => 'Hidden', 'slytranslate_exclude' => 1 ),
+ );
+ return $fields[ $ref ] ?? false;
+ } );
+ }
+
+ public function test_default_translatable_types(): void {
+ $this->assertSame( array( 'text', 'textarea', 'wysiwyg' ), AcfFieldIntrospector::get_translatable_types() );
+ }
+
+ public function test_types_filter_extends_list(): void {
+ $this->stubWpFunction( 'apply_filters', static function ( string $tag, $value, ...$args ) {
+ if ( 'slytranslate_acf_translatable_field_types' === $tag ) {
+ $value = (array) $value;
+ $value[] = 'url';
+ }
+ return $value;
+ } );
+
+ $this->assertContains( 'url', AcfFieldIntrospector::get_translatable_types() );
+ }
+
+ public function test_get_field_for_ref_resolves_valid_refs_only(): void {
+ $this->assertNull( AcfFieldIntrospector::get_field_for_ref( null ) );
+ $this->assertNull( AcfFieldIntrospector::get_field_for_ref( 'not_a_field_ref' ) );
+ $this->assertNull( AcfFieldIntrospector::get_field_for_ref( 'field_unknown' ) );
+
+ $field = AcfFieldIntrospector::get_field_for_ref( 'field_text' );
+ $this->assertIsArray( $field );
+ $this->assertSame( 'text', $field['type'] );
+ }
+
+ public function test_text_ref_is_translatable(): void {
+ $this->assertTrue( AcfFieldIntrospector::is_translatable_ref( 'field_text' ) );
+ $this->assertTrue( AcfFieldIntrospector::is_translatable_ref( 'field_text', false ) );
+ }
+
+ public function test_number_ref_is_not_translatable(): void {
+ $this->assertFalse( AcfFieldIntrospector::is_translatable_ref( 'field_number' ) );
+ }
+
+ public function test_structured_link_ref_only_translatable_when_structured_included(): void {
+ $this->assertTrue( AcfFieldIntrospector::is_translatable_ref( 'field_link', true ) );
+ $this->assertFalse( AcfFieldIntrospector::is_translatable_ref( 'field_link', false ) );
+ }
+
+ public function test_excluded_field_is_never_translatable(): void {
+ $this->assertTrue( AcfFieldIntrospector::is_field_excluded( array( 'type' => 'text', 'slytranslate_exclude' => 1 ) ) );
+ $this->assertFalse( AcfFieldIntrospector::is_translatable_ref( 'field_excluded' ) );
+ }
+
+ public function test_value_spec_for_link_lists_title_subkey(): void {
+ $this->assertSame( array( 'subkeys' => array( 'title' ) ), AcfFieldIntrospector::get_value_spec( 'link' ) );
+ $this->assertSame( array(), AcfFieldIntrospector::get_value_spec( 'text' ) );
+ }
+}
diff --git a/slytranslate/tests/Unit/AcfMetaResolverTest.php b/slytranslate/tests/Unit/AcfMetaResolverTest.php
index 425bc3a..e3337e6 100644
--- a/slytranslate/tests/Unit/AcfMetaResolverTest.php
+++ b/slytranslate/tests/Unit/AcfMetaResolverTest.php
@@ -306,6 +306,84 @@ public function test_multiple_translatable_fields(): void {
$this->assertNotContains( 'count', $result );
}
+ /**
+ * Test that fields flagged with the editor opt-out are skipped.
+ */
+ public function test_field_with_exclude_flag_is_skipped(): void {
+ $this->stubWpFunction( 'acf_get_field', static function ( $ref ) {
+ if ( 'field_hidden' === $ref ) {
+ return array( 'type' => 'text', 'slytranslate_exclude' => 1 );
+ }
+ return false;
+ } );
+
+ $post_meta = array(
+ 'hidden_text' => array( 'value' ),
+ '_hidden_text' => array( 'field_hidden' ),
+ );
+
+ $result = AcfMetaResolver::add_acf_translatable_keys( array(), 42, 'de', 'en', $post_meta );
+
+ $this->assertNotContains( 'hidden_text', $result );
+ }
+
+ /**
+ * Test that structured link fields are added and register a value spec.
+ */
+ public function test_link_field_is_added_with_value_spec(): void {
+ \SlyTranslate\MetaTranslationService::reset_cache();
+
+ $this->stubWpFunction( 'acf_get_field', static function ( $ref ) {
+ if ( 'field_link' === $ref ) {
+ return array( 'type' => 'link', 'label' => 'CTA Link' );
+ }
+ return false;
+ } );
+
+ $post_meta = array(
+ 'cta' => array( 'a:3:{...}' ),
+ '_cta' => array( 'field_link' ),
+ );
+
+ $result = AcfMetaResolver::add_acf_translatable_keys( array(), 42, 'de', 'en', $post_meta );
+
+ $this->assertContains( 'cta', $result );
+ $this->assertSame(
+ array( 'subkeys' => array( 'title' ) ),
+ \SlyTranslate\MetaTranslationService::get_meta_value_spec( 'cta' )['spec']
+ );
+
+ \SlyTranslate\MetaTranslationService::reset_cache();
+ }
+
+ /**
+ * Test that resolved field info (label/type) is recorded per post.
+ */
+ public function test_resolved_field_info_is_recorded(): void {
+ AcfMetaResolver::reset_cache();
+
+ $this->stubWpFunction( 'acf_get_field', static function ( $ref ) {
+ if ( 'field_hero' === $ref ) {
+ return array( 'type' => 'text', 'label' => 'Hero Text' );
+ }
+ return false;
+ } );
+
+ $post_meta = array(
+ 'hero' => array( 'Big headline' ),
+ '_hero' => array( 'field_hero' ),
+ );
+
+ AcfMetaResolver::add_acf_translatable_keys( array(), 42, 'de', 'en', $post_meta );
+
+ $info = AcfMetaResolver::get_resolved_field_info( 42 );
+ $this->assertSame( 'text', $info['hero']['field_type'] );
+ $this->assertSame( 'Hero Text', $info['hero']['field_label'] );
+ $this->assertSame( array(), AcfMetaResolver::get_resolved_field_info( 99 ) );
+
+ AcfMetaResolver::reset_cache();
+ }
+
/**
* Test that existing keys in translate list are preserved.
*/
diff --git a/slytranslate/tests/Unit/AcfOptionsTranslationServiceTest.php b/slytranslate/tests/Unit/AcfOptionsTranslationServiceTest.php
new file mode 100644
index 0000000..bb4d000
--- /dev/null
+++ b/slytranslate/tests/Unit/AcfOptionsTranslationServiceTest.php
@@ -0,0 +1,237 @@
+setStaticProperty( AI_Translate::class, 'adapter', null );
+ $this->setStaticProperty( TranslationRuntime::class, 'model_profile_cache', array() );
+ $this->setStaticProperty( TranslationRuntime::class, 'model_slug_override', null );
+
+ $this->stubWpFunction( 'apply_filters', static function ( string $tag, $value, ...$args ) {
+ return $value;
+ } );
+ $this->stubWpFunctionReturn( 'get_option', '' );
+ $this->stubWpFunctionReturn( 'get_current_user_id', 1 );
+ }
+
+ protected function tearDown(): void {
+ $this->setStaticProperty( AI_Translate::class, 'adapter', null );
+ parent::tearDown();
+ }
+
+ private function stubOptionsPagesWithFields(): void {
+ $this->stubWpFunctionReturn( 'acf_get_options_pages', array(
+ array( 'menu_slug' => 'site-settings', 'page_title' => 'Site Settings' ),
+ array( 'menu_slug' => 'other-page', 'page_title' => 'Other' ),
+ ) );
+
+ $this->stubWpFunction( 'acf_get_field_groups', static function ( $filter = array() ) {
+ return array( array( 'key' => 'group_' . ( $filter['options_page'] ?? '' ) ) );
+ } );
+
+ $this->stubWpFunction( 'acf_get_fields', static function ( $group ) {
+ if ( 'group_site-settings' === ( $group['key'] ?? '' ) ) {
+ return array(
+ array( 'key' => 'field_footer', 'name' => 'footer_text', 'label' => 'Footer Text', 'type' => 'text' ),
+ array( 'key' => 'field_logo', 'name' => 'logo', 'label' => 'Logo', 'type' => 'image' ),
+ array( 'key' => 'field_hidden', 'name' => 'hidden_text', 'label' => 'Hidden', 'type' => 'text', 'slytranslate_exclude' => 1 ),
+ );
+ }
+ if ( 'group_other-page' === ( $group['key'] ?? '' ) ) {
+ return array(
+ array( 'key' => 'field_cta', 'name' => 'cta_text', 'label' => 'CTA', 'type' => 'textarea' ),
+ );
+ }
+ return array();
+ } );
+ }
+
+ private function stubAiResponse( string $response_text ): void {
+ $this->stubWpFunction(
+ 'wp_ai_client_prompt',
+ static function ( string $text ) use ( $response_text ) {
+ return new class( $response_text ) {
+ private string $response;
+
+ public function __construct( string $response ) {
+ $this->response = $response;
+ }
+
+ public function using_system_instruction( string $p ): static { return $this; }
+ public function using_temperature( float $t ): static { return $this; }
+ public function using_model_preference( string $s ): static { return $this; }
+ public function using_max_tokens( int $n ): static { return $this; }
+ public function using_max_output_tokens( int $n ): static { return $this; }
+
+ public function generate_text(): string {
+ return '' . $this->response . '';
+ }
+ };
+ }
+ );
+ }
+
+ public function test_missing_target_language_returns_error(): void {
+ $result = AcfOptionsTranslationService::translate_options( array() );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'missing_target_language', $result->get_error_code() );
+ }
+
+ public function test_same_source_and_target_language_returns_error(): void {
+ $result = AcfOptionsTranslationService::translate_options( array(
+ 'target_language' => 'en',
+ 'source_language' => 'en',
+ ) );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'same_language', $result->get_error_code() );
+ }
+
+ public function test_translatepress_reports_not_required(): void {
+ $this->setStaticProperty( AI_Translate::class, 'adapter', new TranslatePressAdapter() );
+
+ $result = AcfOptionsTranslationService::translate_options( array( 'target_language' => 'de' ) );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'not_required', $result['status'] );
+ $this->assertSame( 0, $result['total'] );
+ }
+
+ public function test_collect_filters_translatable_fields_and_respects_page_slug(): void {
+ $this->stubOptionsPagesWithFields();
+
+ $all = AcfOptionsTranslationService::collect_translatable_option_fields();
+ $this->assertCount( 2, $all );
+ $this->assertSame( 'footer_text', $all[0]['field']['name'] );
+ $this->assertSame( 'cta_text', $all[1]['field']['name'] );
+
+ $scoped = AcfOptionsTranslationService::collect_translatable_option_fields( 'site-settings' );
+ $this->assertCount( 1, $scoped );
+ $this->assertSame( 'footer_text', $scoped[0]['field']['name'] );
+ }
+
+ public function test_without_language_aware_storage_fields_are_skipped(): void {
+ $this->stubOptionsPagesWithFields();
+ $this->stubWpFunctionReturn( 'get_field', 'Footer content' );
+
+ $result = AcfOptionsTranslationService::translate_options( array( 'target_language' => 'de' ) );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 2, $result['skipped'] );
+ $this->assertSame( 0, $result['translated'] );
+ $this->assertSame( 'requires_options_post_id_filter', $result['results'][0]['reason'] );
+ }
+
+ public function test_dry_run_with_post_id_filter_reports_pending_without_writes(): void {
+ $this->stubOptionsPagesWithFields();
+ $this->stubWpFunctionReturn( 'get_field', 'Footer content' );
+
+ $updates = array();
+ $this->stubWpFunction( 'update_field', static function ( ...$args ) use ( &$updates ) {
+ $updates[] = $args;
+ return true;
+ } );
+
+ $this->stubWpFunction( 'apply_filters', static function ( string $tag, $value, ...$args ) {
+ if ( 'slytranslate_acf_options_post_id' === $tag ) {
+ return 'options_' . $args[0];
+ }
+ return $value;
+ } );
+
+ $result = AcfOptionsTranslationService::translate_options( array(
+ 'target_language' => 'de',
+ 'dry_run' => true,
+ ) );
+
+ $this->assertSame( 2, $result['translated'] );
+ $this->assertSame( 'pending', $result['results'][0]['status'] );
+ $this->assertCount( 0, $updates );
+ }
+
+ public function test_polylang_pattern_writes_to_filtered_post_id(): void {
+ $this->stubOptionsPagesWithFields();
+ $this->stubWpFunctionReturn( 'get_field', 'Footer content' );
+ $this->stubAiResponse( 'Footer-Inhalt' );
+
+ $updates = array();
+ $this->stubWpFunction( 'update_field', static function ( ...$args ) use ( &$updates ) {
+ $updates[] = $args;
+ return true;
+ } );
+
+ $this->stubWpFunction( 'apply_filters', static function ( string $tag, $value, ...$args ) {
+ if ( 'slytranslate_acf_options_post_id' === $tag ) {
+ return 'options_' . $args[0];
+ }
+ return $value;
+ } );
+
+ $result = AcfOptionsTranslationService::translate_options( array(
+ 'target_language' => 'de',
+ 'page_slug' => 'site-settings',
+ ) );
+
+ $this->assertSame( 1, $result['translated'] );
+ $this->assertSame( 'translated', $result['results'][0]['status'] );
+ $this->assertCount( 1, $updates );
+ $this->assertSame( array( 'field_footer', 'Footer-Inhalt', 'options_de' ), $updates[0] );
+ }
+
+ public function test_inline_markup_adapter_merges_language_variant(): void {
+ $this->stubOptionsPagesWithFields();
+ $this->setStaticProperty( AI_Translate::class, 'adapter', new WpMultilangAdapter() );
+
+ $this->stubWpFunctionReturn( 'wpm_get_languages', array(
+ 'en' => array( 'name' => 'English' ),
+ 'de' => array( 'name' => 'Deutsch' ),
+ ) );
+ $this->stubWpFunctionReturn( 'wpm_get_default_language', 'en' );
+ $this->stubWpFunctionReturn( 'get_field', '[:en]Footer content[:]' );
+ $this->stubAiResponse( 'Footer-Inhalt' );
+
+ $updates = array();
+ $this->stubWpFunction( 'update_field', static function ( ...$args ) use ( &$updates ) {
+ $updates[] = $args;
+ return true;
+ } );
+
+ $result = AcfOptionsTranslationService::translate_options( array(
+ 'target_language' => 'de',
+ 'page_slug' => 'site-settings',
+ ) );
+
+ $this->assertSame( 1, $result['translated'] );
+ $this->assertCount( 1, $updates );
+ $this->assertSame( 'field_footer', $updates[0][0] );
+ $this->assertStringContainsString( '[:en]Footer content', $updates[0][1] );
+ $this->assertStringContainsString( '[:de]Footer-Inhalt', $updates[0][1] );
+ $this->assertSame( 'options', $updates[0][2] );
+ }
+
+ public function test_empty_option_values_are_skipped(): void {
+ $this->stubOptionsPagesWithFields();
+ $this->stubWpFunctionReturn( 'get_field', '' );
+
+ $result = AcfOptionsTranslationService::translate_options( array( 'target_language' => 'de' ) );
+
+ $this->assertSame( 2, $result['skipped'] );
+ $this->assertSame( 'empty_value', $result['results'][0]['reason'] );
+ }
+}
diff --git a/slytranslate/tests/Unit/AutoPublishTranslationServiceTest.php b/slytranslate/tests/Unit/AutoPublishTranslationServiceTest.php
new file mode 100644
index 0000000..d1374e8
--- /dev/null
+++ b/slytranslate/tests/Unit/AutoPublishTranslationServiceTest.php
@@ -0,0 +1,130 @@
+ */
+ private array $dispatched = array();
+
+ /** @var array */
+ private array $stored_options = array();
+
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->dispatched = array();
+ $this->stored_options = array( 'slytranslate_new_post' => '1' );
+
+ TranslationQueue::set_scheduler_for_testing( function ( string $hook, array $args ): void {
+ $this->dispatched[] = array( $hook, $args );
+ } );
+
+ $this->setStaticProperty( AI_Translate::class, 'adapter', new AutoPublishPolylangAdapterDouble() );
+
+ $this->stubWpFunction( 'get_option', function ( $option, $default = false ) {
+ return $this->stored_options[ $option ] ?? $default;
+ } );
+ $this->stubWpFunction( 'update_option', function ( $option, $value ) {
+ $this->stored_options[ $option ] = $value;
+ return true;
+ } );
+ $this->stubWpFunctionReturn( 'post_type_exists', true );
+ $this->stubWpFunctionReturn( 'get_post_meta', '' );
+ $this->stubWpFunctionReturn( 'pll_default_language', 'en' );
+ }
+
+ protected function tearDown(): void {
+ TranslationQueue::set_scheduler_for_testing( null );
+ $this->setStaticProperty( AI_Translate::class, 'adapter', null );
+ parent::tearDown();
+ }
+
+ private function publish_post( int $id = 5 ): \WP_Post {
+ return new \WP_Post( array( 'ID' => $id, 'post_type' => 'post', 'post_status' => 'publish' ) );
+ }
+
+ public function test_publish_queues_drafts_for_missing_languages_only(): void {
+ AutoPublishTranslationService::handle_transition_post_status( 'publish', 'draft', $this->publish_post() );
+
+ // Languages: en (source), de (existing translation), fr (missing) → one job for fr.
+ $this->assertCount( 1, $this->dispatched );
+ $this->assertSame( TranslationQueue::WORKER_HOOK, $this->dispatched[0][0] );
+ $this->assertSame( 5, $this->dispatched[0][1][1] );
+
+ $job_id = $this->dispatched[0][1][0];
+ $status = TranslationQueue::get_job_status( $job_id );
+ $this->assertSame( 'fr', $status['target_language'] );
+
+ // Translations are always queued as drafts — never auto-published.
+ $job = $this->stored_options[ 'slytranslate_queue_job_' . $job_id ];
+ $this->assertSame( 'draft', $job['options']['post_status'] );
+ }
+
+ public function test_disabled_option_queues_nothing(): void {
+ $this->stored_options['slytranslate_new_post'] = '0';
+
+ AutoPublishTranslationService::handle_transition_post_status( 'publish', 'draft', $this->publish_post() );
+
+ $this->assertSame( array(), $this->dispatched );
+ }
+
+ public function test_non_publish_transitions_queue_nothing(): void {
+ AutoPublishTranslationService::handle_transition_post_status( 'draft', 'publish', $this->publish_post() );
+ AutoPublishTranslationService::handle_transition_post_status( 'publish', 'publish', $this->publish_post() );
+
+ $this->assertSame( array(), $this->dispatched );
+ }
+
+ public function test_generated_translations_do_not_retrigger_translation(): void {
+ $this->stubWpFunction( 'get_post_meta', static function ( $post_id, $key = '' ) {
+ return AutoPublishTranslationService::GENERATED_META_KEY === $key ? '1' : '';
+ } );
+
+ AutoPublishTranslationService::handle_transition_post_status( 'publish', 'draft', $this->publish_post() );
+
+ $this->assertSame( array(), $this->dispatched );
+ }
+
+ public function test_secondary_language_posts_queue_nothing(): void {
+ // Post language en, but the site default is de → not a source post.
+ $this->stubWpFunctionReturn( 'pll_default_language', 'de' );
+
+ AutoPublishTranslationService::handle_transition_post_status( 'publish', 'draft', $this->publish_post() );
+
+ $this->assertSame( array(), $this->dispatched );
+ }
+
+ public function test_untranslatable_post_type_queues_nothing(): void {
+ $this->stubWpFunctionReturn( 'post_type_exists', false );
+
+ AutoPublishTranslationService::handle_transition_post_status( 'publish', 'draft', $this->publish_post() );
+
+ $this->assertSame( array(), $this->dispatched );
+ }
+}
+
+class AutoPublishPolylangAdapterDouble extends PolylangAdapter {
+ public function is_available(): bool {
+ return true;
+ }
+
+ public function get_languages(): array {
+ return array( 'en' => 'English', 'de' => 'Deutsch', 'fr' => 'Français' );
+ }
+
+ public function get_post_language( int $post_id ): ?string {
+ return 'en';
+ }
+
+ public function get_post_translations( int $post_id ): array {
+ return array( 'en' => $post_id, 'de' => 9 );
+ }
+}
diff --git a/slytranslate/tests/Unit/BackgroundBulkTranslationTest.php b/slytranslate/tests/Unit/BackgroundBulkTranslationTest.php
new file mode 100644
index 0000000..4193457
--- /dev/null
+++ b/slytranslate/tests/Unit/BackgroundBulkTranslationTest.php
@@ -0,0 +1,138 @@
+ */
+ private array $stored_options = array();
+
+ /** @var array */
+ private array $dispatched = array();
+
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->stored_options = array();
+ $this->dispatched = array();
+
+ TranslationQueue::set_scheduler_for_testing( function ( string $hook, array $args ): void {
+ $this->dispatched[] = array( $hook, $args );
+ } );
+
+ $this->setStaticProperty( AI_Translate::class, 'adapter', new BackgroundBulkPolylangAdapterDouble() );
+
+ $this->stubWpFunction( 'get_option', function ( $option, $default = false ) {
+ return $this->stored_options[ $option ] ?? $default;
+ } );
+ $this->stubWpFunction( 'update_option', function ( $option, $value ) {
+ $this->stored_options[ $option ] = $value;
+ return true;
+ } );
+ $this->stubWpFunctionReturn( 'current_user_can', true );
+ $this->stubWpFunctionReturn( 'get_current_user_id', 17 );
+ }
+
+ protected function tearDown(): void {
+ TranslationQueue::set_scheduler_for_testing( null );
+ $this->setStaticProperty( AI_Translate::class, 'adapter', null );
+ parent::tearDown();
+ }
+
+ public function test_background_bulk_returns_job_id_without_running_translations(): void {
+ $this->stubWpFunction( 'wp_ai_client_prompt', static function () {
+ throw new \RuntimeException( 'Background mode must not translate synchronously.' );
+ } );
+
+ $result = AI_Translate::execute_translate_posts( array(
+ 'post_ids' => array( 10, 20 ),
+ 'target_language' => 'de',
+ 'background' => true,
+ 'post_status' => 'draft',
+ ) );
+
+ $this->assertIsArray( $result );
+ $this->assertTrue( $result['queued'] );
+ $this->assertSame( 2, $result['total'] );
+ $this->assertNotSame( '', $result['job_id'] );
+ $this->assertSame( array(), $result['results'] );
+
+ $this->assertCount( 2, $this->dispatched );
+ $this->assertSame( array( $result['job_id'], 10 ), $this->dispatched[0][1] );
+
+ $job = $this->stored_options[ 'slytranslate_queue_job_' . $result['job_id'] ];
+ $this->assertSame( 'draft', $job['options']['post_status'] );
+ $this->assertSame( 17, $job['user_id'] );
+ }
+
+ public function test_get_progress_returns_job_status_for_known_job_id(): void {
+ $result = AI_Translate::execute_translate_posts( array(
+ 'post_ids' => array( 10 ),
+ 'target_language' => 'de',
+ 'background' => true,
+ ) );
+
+ $progress = AI_Translate::execute_get_progress( array( 'job_id' => $result['job_id'] ) );
+
+ $this->assertSame( $result['job_id'], $progress['job_id'] );
+ $this->assertSame( 'queued', $progress['status'] );
+ $this->assertSame( 1, $progress['total'] );
+ $this->assertSame( 0, $progress['processed'] );
+ }
+
+ public function test_get_progress_falls_back_to_post_progress_for_unknown_job_id(): void {
+ $progress = AI_Translate::execute_get_progress( array( 'job_id' => 'unknown', 'post_id' => 0 ) );
+
+ $this->assertArrayHasKey( 'phase', $progress );
+ $this->assertArrayNotHasKey( 'job_id', $progress );
+ }
+
+ public function test_cancel_translation_cancels_queue_job(): void {
+ $result = AI_Translate::execute_translate_posts( array(
+ 'post_ids' => array( 10 ),
+ 'target_language' => 'de',
+ 'background' => true,
+ ) );
+
+ $cancelled = AI_Translate::execute_cancel_translation( array( 'job_id' => $result['job_id'] ) );
+
+ $this->assertTrue( $cancelled['cancelled'] );
+ $this->assertTrue( $cancelled['job_cancelled'] );
+ $this->assertSame( 'cancelled', TranslationQueue::get_job_status( $result['job_id'] )['status'] );
+ }
+
+ public function test_cancel_translation_reports_unknown_job(): void {
+ $cancelled = AI_Translate::execute_cancel_translation( array( 'job_id' => 'unknown' ) );
+
+ $this->assertTrue( $cancelled['cancelled'] );
+ $this->assertFalse( $cancelled['job_cancelled'] );
+ }
+}
+
+class BackgroundBulkPolylangAdapterDouble extends PolylangAdapter {
+ public function is_available(): bool {
+ return true;
+ }
+
+ public function get_languages(): array {
+ return array( 'en' => 'English', 'de' => 'Deutsch' );
+ }
+
+ public function get_post_language( int $post_id ): ?string {
+ return 'en';
+ }
+
+ public function get_post_translations( int $post_id ): array {
+ return array( 'en' => $post_id );
+ }
+}
diff --git a/slytranslate/tests/Unit/DiffRetranslationTest.php b/slytranslate/tests/Unit/DiffRetranslationTest.php
new file mode 100644
index 0000000..28c0694
--- /dev/null
+++ b/slytranslate/tests/Unit/DiffRetranslationTest.php
@@ -0,0 +1,119 @@
+stubWpFunction( 'serialize_blocks', static function ( array $blocks ): string {
+ return implode( '', array_map( static fn( array $b ) => (string) ( $b['innerHTML'] ?? '' ), $blocks ) );
+ } );
+ }
+
+ private static function block( string $html ): array {
+ return array( 'blockName' => 'core/paragraph', 'innerHTML' => $html );
+ }
+
+ public function test_fully_unchanged_content_is_reassembled_without_ai_calls(): void {
+ $this->stubWpFunction( 'wp_ai_client_prompt', static function () {
+ throw new \RuntimeException( 'No AI call may happen when every block is reused.' );
+ } );
+
+ $blocks = array( self::block( 'Alpha' ), self::block( 'Beta' ) );
+ $map = array(
+ hash( 'sha256', 'Alpha' ) => 'Alpha-DE',
+ hash( 'sha256', 'Beta' ) => 'Beta-DE',
+ );
+
+ $content = $this->invokeStatic(
+ PostTranslationService::class,
+ 'translate_blocks_with_reuse',
+ array( $blocks, $map, 'de', 'en', '' )
+ );
+
+ $this->assertSame( 'Alpha-DEBeta-DE', $content );
+ }
+
+ public function test_changed_blocks_are_translated_and_merged_in_source_order(): void {
+ $ai_calls = array();
+ $this->stubWpFunction(
+ 'wp_ai_client_prompt',
+ static function ( string $text ) use ( &$ai_calls ) {
+ $ai_calls[] = $text;
+ return new class() {
+ public function using_system_instruction( string $p ): static { return $this; }
+ public function using_temperature( float $t ): static { return $this; }
+ public function using_model_preference( string $s ): static { return $this; }
+ public function using_max_tokens( int $n ): static { return $this; }
+ public function using_max_output_tokens( int $n ): static { return $this; }
+ public function generate_text(): string {
+ return 'Geänderter Absatz
';
+ }
+ };
+ }
+ );
+ // translate_block_sections re-parses the translated chunk.
+ $this->stubWpFunction( 'parse_blocks', static function ( string $content ): array {
+ return array( array( 'blockName' => 'core/paragraph', 'innerHTML' => $content, 'innerBlocks' => array(), 'innerContent' => array( $content ) ) );
+ } );
+
+ $blocks = array(
+ array_merge( self::block( 'Unchanged paragraph
' ), array( 'innerBlocks' => array(), 'innerContent' => array( 'Unchanged paragraph
' ) ) ),
+ array_merge( self::block( 'Changed paragraph!
' ), array( 'innerBlocks' => array(), 'innerContent' => array( 'Changed paragraph!
' ) ) ),
+ );
+ $map = array(
+ hash( 'sha256', 'Unchanged paragraph
' ) => 'Unveränderter Absatz
',
+ );
+
+ $content = $this->invokeStatic(
+ PostTranslationService::class,
+ 'translate_blocks_with_reuse',
+ array( $blocks, $map, 'de', 'en', '' )
+ );
+
+ $this->assertIsString( $content );
+ $this->assertStringContainsString( 'Unveränderter Absatz
', $content );
+ $this->assertStringContainsString( 'Geänderter Absatz', $content );
+ $this->assertStringStartsWith( 'Unveränderter Absatz
', $content );
+ $this->assertNotEmpty( $ai_calls );
+ // Only the changed block went to the model.
+ foreach ( $ai_calls as $call ) {
+ $this->assertStringNotContainsString( 'Unchanged paragraph', $call );
+ }
+ }
+
+ public function test_unit_hash_round_trip_marks_only_edited_blocks_for_translation(): void {
+ // Simulates the overwrite flow: hashes stored on the translation at
+ // create time, then one source block is edited.
+ $original_blocks = array( self::block( 'Alpha' ), self::block( 'Beta' ) );
+ $stored_hashes = TranslationFingerprint::compute_block_hashes( $original_blocks );
+
+ $this->stubWpFunction( 'get_post_meta', static function ( $post_id, $key = '' ) use ( $stored_hashes ) {
+ return TranslationFingerprint::UNIT_HASHES_META_KEY === $key ? $stored_hashes : '';
+ } );
+ $this->stubWpFunction( 'get_post', static function () {
+ return new \WP_Post( array( 'ID' => 9, 'post_content' => 'Alpha-DE Beta-DE' ) );
+ } );
+ $this->stubWpFunction( 'parse_blocks', static function (): array {
+ return array( self::block( 'Alpha-DE' ), self::block( 'Beta-DE' ) );
+ } );
+
+ $edited_blocks = array( self::block( 'Alpha' ), self::block( 'Beta edited' ) );
+ $map = TranslationFingerprint::plan_block_reuse( $edited_blocks, 9 );
+
+ $this->assertIsArray( $map );
+ $this->assertArrayHasKey( hash( 'sha256', 'Alpha' ), $map );
+ $this->assertArrayNotHasKey( hash( 'sha256', 'Beta edited' ), $map );
+ }
+}
diff --git a/slytranslate/tests/Unit/EditorRestRouteRegistrationTest.php b/slytranslate/tests/Unit/EditorRestRouteRegistrationTest.php
index 20cc9f6..b061bd7 100644
--- a/slytranslate/tests/Unit/EditorRestRouteRegistrationTest.php
+++ b/slytranslate/tests/Unit/EditorRestRouteRegistrationTest.php
@@ -45,7 +45,10 @@ static function ( string $namespace, string $route, array $args ) use ( &$regist
'/ai-translate/translate-blocks/run',
'/ai-translate/translate-content/run',
'/ai-translate/translate-content-bulk/run',
+ '/ai-translate/translate-terms/run',
+ '/ai-translate/get-translatable-fields/run',
'/ai-translate/configure/run',
+ '/ai-translate/probe-string-table-concurrency/run',
'/ai-translate/get-progress/run',
'/ai-translate/log-editor-event/run',
'/ai-translate/get-existing-translation/run',
diff --git a/slytranslate/tests/Unit/GetTranslatableFieldsTest.php b/slytranslate/tests/Unit/GetTranslatableFieldsTest.php
new file mode 100644
index 0000000..1352597
--- /dev/null
+++ b/slytranslate/tests/Unit/GetTranslatableFieldsTest.php
@@ -0,0 +1,87 @@
+setStaticProperty( MetaTranslationService::class, 'meta_translate', null );
+ $this->setStaticProperty( MetaTranslationService::class, 'meta_clear', null );
+ $this->setStaticProperty( MetaTranslationService::class, 'resolved_meta_key_config', array() );
+ $this->setStaticProperty(
+ MetaTranslationService::class,
+ 'seo_plugin_config',
+ array(
+ 'key' => 'yoast',
+ 'label' => 'Yoast SEO',
+ 'translate' => array( '_yoast_wpseo_title' ),
+ 'clear' => array( '_yoast_wpseo_focuskw' ),
+ )
+ );
+ }
+
+ protected function tearDown(): void {
+ $this->setStaticProperty( MetaTranslationService::class, 'seo_plugin_config', null );
+ $this->setStaticProperty( MetaTranslationService::class, 'meta_translate', null );
+ $this->setStaticProperty( MetaTranslationService::class, 'meta_clear', null );
+ parent::tearDown();
+ }
+
+ private function field_by_key( array $report, string $key, string $action ): ?array {
+ foreach ( $report['fields'] as $field ) {
+ if ( $field['key'] === $key && $field['action'] === $action ) {
+ return $field;
+ }
+ }
+ return null;
+ }
+
+ public function test_report_attributes_sources_and_actions(): void {
+ $this->stubWpFunction( 'get_option', static function ( $option, $default = false ) {
+ if ( 'slytranslate_meta_translate' === $option ) {
+ return 'subtitle';
+ }
+ if ( 'slytranslate_meta_keys_exclude' === $option ) {
+ return '_yoast_wpseo_title';
+ }
+ return $default;
+ } );
+
+ $report = AI_Translate::execute_get_translatable_fields( array() );
+
+ $this->assertSame( 0, $report['post_id'] );
+ $this->assertSame( 'yoast', $report['seo_plugin'] );
+ $this->assertSame( 'Yoast SEO', $report['seo_plugin_label'] );
+ $this->assertContains( 'acf', $report['active_resolvers'] );
+
+ $manual = $this->field_by_key( $report, 'subtitle', 'translate' );
+ $this->assertSame( 'manual', $manual['source'] );
+ $this->assertFalse( $manual['excluded'] );
+
+ $default_key = $this->field_by_key( $report, '_wp_attachment_image_alt', 'translate' );
+ $this->assertSame( 'default', $default_key['source'] );
+
+ // Excluded SEO key is still reported, flagged as excluded.
+ $excluded = $this->field_by_key( $report, '_yoast_wpseo_title', 'translate' );
+ $this->assertSame( 'seo', $excluded['source'] );
+ $this->assertTrue( $excluded['excluded'] );
+
+ $clear = $this->field_by_key( $report, '_yoast_wpseo_focuskw', 'clear' );
+ $this->assertSame( 'seo', $clear['source'] );
+ }
+
+ public function test_report_passes_post_context_through(): void {
+ $this->stubWpFunctionReturn( 'get_post_meta', array() );
+
+ $report = AI_Translate::execute_get_translatable_fields( array( 'post_id' => 42 ) );
+
+ $this->assertSame( 42, $report['post_id'] );
+ }
+}
diff --git a/slytranslate/tests/Unit/GlossaryServiceTest.php b/slytranslate/tests/Unit/GlossaryServiceTest.php
new file mode 100644
index 0000000..71fc1ff
--- /dev/null
+++ b/slytranslate/tests/Unit/GlossaryServiceTest.php
@@ -0,0 +1,92 @@
+stubWpFunction( 'get_option', static function ( $option, $default = false ) use ( $entries ) {
+ return 'slytranslate_glossary' === $option ? $entries : $default;
+ } );
+ }
+
+ public function test_sanitize_entries_normalizes_modes_and_drops_invalid_rows(): void {
+ $sanitized = GlossaryService::sanitize_entries( array(
+ array( 'term' => 'SlyBase', 'mode' => 'keep' ),
+ array( 'term' => 'Anleitung', 'mode' => 'translate', 'to' => array( 'en' => 'Guide', '' => 'x' ) ),
+ array( 'term' => '', 'mode' => 'keep' ), // empty term → dropped
+ array( 'term' => 'Bogus', 'mode' => 'translate' ), // translate without map → dropped
+ array( 'term' => 'Fallback', 'mode' => 'nonsense' ), // unknown mode → keep
+ 'not-an-array',
+ ) );
+
+ $this->assertSame(
+ array(
+ array( 'term' => 'SlyBase', 'mode' => 'keep', 'to' => array() ),
+ array( 'term' => 'Anleitung', 'mode' => 'translate', 'to' => array( 'en' => 'Guide' ) ),
+ array( 'term' => 'Fallback', 'mode' => 'keep', 'to' => array() ),
+ ),
+ $sanitized
+ );
+ }
+
+ public function test_prompt_block_lists_keep_terms_and_fixed_translations(): void {
+ $this->stubGlossaryOption( array(
+ array( 'term' => 'SlyBase', 'mode' => 'keep' ),
+ array( 'term' => 'Anleitung', 'mode' => 'translate', 'to' => array( 'en' => 'Guide', 'fr' => 'Manuel' ) ),
+ ) );
+
+ $block = GlossaryService::build_prompt_block( 'en' );
+
+ $this->assertStringContainsString( 'Never translate the following terms, keep them exactly as written: "SlyBase".', $block );
+ $this->assertStringContainsString( 'Always translate "Anleitung" as "Guide".', $block );
+ $this->assertStringNotContainsString( 'Manuel', $block );
+ }
+
+ public function test_prompt_block_is_empty_without_glossary_or_applicable_entries(): void {
+ $this->stubGlossaryOption( array() );
+ $this->assertSame( '', GlossaryService::build_prompt_block( 'en' ) );
+
+ // A translate-only entry without a mapping for the target language.
+ $this->stubGlossaryOption( array(
+ array( 'term' => 'Anleitung', 'mode' => 'translate', 'to' => array( 'fr' => 'Manuel' ) ),
+ ) );
+ $this->assertSame( '', GlossaryService::build_prompt_block( 'en' ) );
+ }
+
+ public function test_large_glossaries_are_prefiltered_against_source_text(): void {
+ $entries = array();
+ for ( $i = 1; $i <= 30; $i++ ) {
+ $entries[] = array( 'term' => 'Begriff' . $i, 'mode' => 'keep' );
+ }
+ $this->stubGlossaryOption( $entries );
+
+ $block = GlossaryService::build_prompt_block( 'en', 'Der Text erwähnt nur BEGRIFF7 in Großbuchstaben.' );
+
+ $this->assertStringContainsString( '"Begriff7"', $block );
+ $this->assertStringNotContainsString( '"Begriff8"', $block );
+ $this->assertStringNotContainsString( '"Begriff1",', $block );
+ }
+
+ public function test_build_prompt_injects_glossary_block(): void {
+ $this->stubGlossaryOption( array(
+ array( 'term' => 'SlyBase', 'mode' => 'keep' ),
+ ) );
+
+ $prompt = TranslationRuntime::build_prompt( 'en', 'de' );
+
+ $this->assertStringContainsString( 'Glossary rules', $prompt );
+ $this->assertStringContainsString( '"SlyBase"', $prompt );
+ }
+
+ public function test_build_prompt_without_glossary_stays_unchanged(): void {
+ $prompt = TranslationRuntime::build_prompt( 'en', 'de' );
+
+ $this->assertStringNotContainsString( 'Glossary rules', $prompt );
+ }
+}
diff --git a/slytranslate/tests/Unit/HookRegistrationTest.php b/slytranslate/tests/Unit/HookRegistrationTest.php
index f964ccc..cfb0a6a 100644
--- a/slytranslate/tests/Unit/HookRegistrationTest.php
+++ b/slytranslate/tests/Unit/HookRegistrationTest.php
@@ -6,10 +6,15 @@
use SlyTranslate\AI_Translate;
use SlyTranslate\AbilityRegistrar;
+use SlyTranslate\AutoPublishTranslationService;
use SlyTranslate\EditorBootstrap;
use SlyTranslate\ListTableTranslation;
+use SlyTranslate\MediaTranslationService;
+use SlyTranslate\MetaTranslationService;
use SlyTranslate\Settings;
+use SlyTranslate\SettingsPage;
use SlyTranslate\TranslatePressEditorIntegration;
+use SlyTranslate\TranslationQueue;
class HookRegistrationTest extends TestCase {
@@ -46,6 +51,13 @@ static function ( string $hook, $callback, int $priority = 10, int $accepted_arg
$this->assertSame(
array(
+ array(
+ 'type' => 'filter',
+ 'hook' => 'slytranslate_translate_meta_key',
+ 'callback' => array( MetaTranslationService::class, 'filter_excluded_meta_key' ),
+ 'priority' => 5,
+ 'accepted_args' => 2,
+ ),
array(
'type' => 'action',
'hook' => 'enqueue_block_editor_assets',
@@ -60,6 +72,20 @@ static function ( string $hook, $callback, int $priority = 10, int $accepted_arg
'priority' => 10,
'accepted_args' => 1,
),
+ array(
+ 'type' => 'action',
+ 'hook' => 'admin_menu',
+ 'callback' => array( SettingsPage::class, 'register_settings_page' ),
+ 'priority' => 10,
+ 'accepted_args' => 1,
+ ),
+ array(
+ 'type' => 'action',
+ 'hook' => 'admin_enqueue_scripts',
+ 'callback' => array( SettingsPage::class, 'enqueue_assets' ),
+ 'priority' => 10,
+ 'accepted_args' => 1,
+ ),
array(
'type' => 'action',
'hook' => 'rest_api_init',
@@ -144,6 +170,27 @@ static function ( string $hook, $callback, int $priority = 10, int $accepted_arg
'priority' => 10,
'accepted_args' => 1,
),
+ array(
+ 'type' => 'action',
+ 'hook' => TranslationQueue::WORKER_HOOK,
+ 'callback' => array( TranslationQueue::class, 'process_queued_translation' ),
+ 'priority' => 10,
+ 'accepted_args' => 2,
+ ),
+ array(
+ 'type' => 'action',
+ 'hook' => 'transition_post_status',
+ 'callback' => array( AutoPublishTranslationService::class, 'handle_transition_post_status' ),
+ 'priority' => 10,
+ 'accepted_args' => 3,
+ ),
+ array(
+ 'type' => 'action',
+ 'hook' => 'pll_translate_media',
+ 'callback' => array( MediaTranslationService::class, 'handle_pll_translate_media' ),
+ 'priority' => 10,
+ 'accepted_args' => 3,
+ ),
),
$registered_hooks
);
diff --git a/slytranslate/tests/Unit/MediaTranslationServiceTest.php b/slytranslate/tests/Unit/MediaTranslationServiceTest.php
new file mode 100644
index 0000000..0f75391
--- /dev/null
+++ b/slytranslate/tests/Unit/MediaTranslationServiceTest.php
@@ -0,0 +1,138 @@
+stubWpFunction(
+ 'wp_ai_client_prompt',
+ static function ( string $text ) use ( $response_text, &$calls ) {
+ $calls[] = $text;
+ return new class( $response_text ) {
+ private string $response;
+
+ public function __construct( string $response ) {
+ $this->response = $response;
+ }
+
+ public function using_system_instruction( string $p ): static { return $this; }
+ public function using_temperature( float $t ): static { return $this; }
+ public function using_model_preference( string $s ): static { return $this; }
+ public function using_max_tokens( int $n ): static { return $this; }
+ public function using_max_output_tokens( int $n ): static { return $this; }
+
+ public function generate_text(): string {
+ return '' . $this->response . '';
+ }
+ };
+ }
+ );
+ }
+
+ public function test_media_duplication_translates_alt_text_and_caption(): void {
+ $this->stubAiResponse( 'A brown dog' );
+ $this->stubWpFunctionReturn( 'pll_get_post_language', 'de' );
+ $this->stubWpFunction( 'get_post_meta', static function ( $post_id, $key = '', $single = false ) {
+ return 11 === $post_id && MediaTranslationService::ALT_TEXT_META_KEY === $key ? 'Ein brauner Hund' : '';
+ } );
+ $this->stubWpFunction( 'get_post', static function () {
+ return new \WP_Post( array(
+ 'ID' => 12,
+ 'post_excerpt' => 'Bildunterschrift Hund',
+ 'post_content' => '',
+ ) );
+ } );
+
+ $meta_updates = array();
+ $post_updates = array();
+ $this->stubWpFunction( 'update_post_meta', static function ( $post_id, $key, $value ) use ( &$meta_updates ) {
+ $meta_updates[] = array( $post_id, $key, $value );
+ return true;
+ } );
+ $this->stubWpFunction( 'wp_update_post', static function ( $postarr ) use ( &$post_updates ) {
+ $post_updates[] = $postarr;
+ return $postarr['ID'] ?? 0;
+ } );
+
+ MediaTranslationService::handle_pll_translate_media( 11, 12, 'en' );
+
+ $this->assertSame( array( array( 12, MediaTranslationService::ALT_TEXT_META_KEY, 'A brown dog' ) ), $meta_updates );
+ $this->assertCount( 1, $post_updates );
+ $this->assertSame( 12, $post_updates[0]['ID'] );
+ $this->assertSame( 'A brown dog', $post_updates[0]['post_excerpt'] );
+ $this->assertArrayNotHasKey( 'post_content', $post_updates[0] );
+ }
+
+ public function test_media_duplication_skips_same_language_and_invalid_ids(): void {
+ $this->stubWpFunction( 'wp_ai_client_prompt', static function () {
+ throw new \RuntimeException( 'AI must not be called.' );
+ } );
+
+ // Same language.
+ $this->stubWpFunctionReturn( 'pll_get_post_language', 'en' );
+ MediaTranslationService::handle_pll_translate_media( 11, 12, 'en' );
+
+ // Identical IDs.
+ $this->stubWpFunctionReturn( 'pll_get_post_language', 'de' );
+ MediaTranslationService::handle_pll_translate_media( 11, 11, 'en' );
+
+ // Missing source language.
+ $this->stubWpFunctionReturn( 'pll_get_post_language', false );
+ MediaTranslationService::handle_pll_translate_media( 11, 12, 'en' );
+
+ $this->addToAssertionCount( 1 ); // No exception means no AI call happened.
+ }
+
+ public function test_featured_image_alt_is_backfilled_on_translated_post(): void {
+ $this->stubAiResponse( 'A brown dog' );
+ $this->stubWpFunction( 'get_post_thumbnail_id', static function ( $post_id ) {
+ return 5 === $post_id ? 21 : 22;
+ } );
+ $this->stubWpFunction( 'get_post_meta', static function ( $post_id, $key = '', $single = false ) {
+ // Source attachment alt is set, target alt is still empty.
+ return 21 === $post_id ? 'Ein brauner Hund' : '';
+ } );
+
+ $meta_updates = array();
+ $this->stubWpFunction( 'update_post_meta', static function ( $post_id, $key, $value ) use ( &$meta_updates ) {
+ $meta_updates[] = array( $post_id, $key, $value );
+ return true;
+ } );
+
+ MediaTranslationService::maybe_translate_featured_image_alt( 5, 9, 'en', 'de' );
+
+ $this->assertSame( array( array( 22, MediaTranslationService::ALT_TEXT_META_KEY, 'A brown dog' ) ), $meta_updates );
+ }
+
+ public function test_featured_image_alt_respects_polylang_media_support_gate(): void {
+ $this->stubWpFunctionReturn( 'pll_get_option', false );
+ $this->stubWpFunction( 'get_post_thumbnail_id', static function () {
+ throw new \RuntimeException( 'Thumbnail lookup must not happen when media translation is disabled.' );
+ } );
+
+ MediaTranslationService::maybe_translate_featured_image_alt( 5, 9, 'en', 'de' );
+
+ $this->addToAssertionCount( 1 );
+ }
+
+ public function test_featured_image_alt_keeps_existing_target_alt(): void {
+ $this->stubWpFunction( 'wp_ai_client_prompt', static function () {
+ throw new \RuntimeException( 'AI must not be called when the target alt already exists.' );
+ } );
+ $this->stubWpFunction( 'get_post_thumbnail_id', static function ( $post_id ) {
+ return 5 === $post_id ? 21 : 22;
+ } );
+ $this->stubWpFunction( 'get_post_meta', static function ( $post_id ) {
+ return 22 === $post_id ? 'Existing alt' : 'Ein brauner Hund';
+ } );
+
+ MediaTranslationService::maybe_translate_featured_image_alt( 5, 9, 'en', 'de' );
+
+ $this->addToAssertionCount( 1 );
+ }
+}
diff --git a/slytranslate/tests/Unit/MetaBoxMetaResolverTest.php b/slytranslate/tests/Unit/MetaBoxMetaResolverTest.php
new file mode 100644
index 0000000..9d94dd8
--- /dev/null
+++ b/slytranslate/tests/Unit/MetaBoxMetaResolverTest.php
@@ -0,0 +1,89 @@
+stubWpFunction( 'apply_filters', static function ( string $tag, $value, ...$args ) {
+ return $value;
+ } );
+
+ $this->stubWpFunction( 'rwmb_get_field_settings', static function ( $field_id, $args = array(), $post_id = null ) {
+ $fields = array(
+ 'mb_text' => array( 'type' => 'text' ),
+ 'mb_textarea' => array( 'type' => 'textarea' ),
+ 'mb_wysiwyg' => array( 'type' => 'wysiwyg' ),
+ 'mb_number' => array( 'type' => 'number' ),
+ 'mb_image' => array( 'type' => 'image_advanced' ),
+ );
+ return $fields[ $field_id ] ?? false;
+ } );
+ }
+
+ public function test_translatable_metabox_fields_are_added(): void {
+ $post_meta = array(
+ 'mb_text' => array( 'value' ),
+ 'mb_textarea' => array( 'value' ),
+ 'mb_wysiwyg' => array( 'value
' ),
+ );
+
+ $result = MetaBoxMetaResolver::add_metabox_translatable_keys( array(), 42, 'en', 'de', $post_meta );
+
+ $this->assertContains( 'mb_text', $result );
+ $this->assertContains( 'mb_textarea', $result );
+ $this->assertContains( 'mb_wysiwyg', $result );
+ }
+
+ public function test_non_translatable_types_and_unknown_keys_are_skipped(): void {
+ $post_meta = array(
+ 'mb_number' => array( '5' ),
+ 'mb_image' => array( '12' ),
+ 'random_key' => array( 'value' ),
+ '_internal' => array( 'value' ),
+ );
+
+ $result = MetaBoxMetaResolver::add_metabox_translatable_keys( array(), 42, 'en', 'de', $post_meta );
+
+ $this->assertSame( array(), $result );
+ }
+
+ public function test_existing_keys_are_not_duplicated(): void {
+ $post_meta = array( 'mb_text' => array( 'value' ) );
+
+ $result = MetaBoxMetaResolver::add_metabox_translatable_keys( array( 'mb_text' ), 42, 'en', 'de', $post_meta );
+
+ $this->assertSame( array( 'mb_text' ), $result );
+ }
+
+ public function test_types_filter_extends_list(): void {
+ $this->stubWpFunction( 'apply_filters', static function ( string $tag, $value, ...$args ) {
+ if ( 'slytranslate_metabox_translatable_field_types' === $tag ) {
+ $value = (array) $value;
+ $value[] = 'number';
+ }
+ return $value;
+ } );
+
+ $post_meta = array( 'mb_number' => array( '5' ) );
+
+ $result = MetaBoxMetaResolver::add_metabox_translatable_keys( array(), 42, 'en', 'de', $post_meta );
+
+ $this->assertContains( 'mb_number', $result );
+ }
+
+ public function test_empty_post_meta_returns_unchanged(): void {
+ $initial = array( 'existing' );
+ $this->assertSame( $initial, MetaBoxMetaResolver::add_metabox_translatable_keys( $initial, 42, 'en', 'de', array() ) );
+ }
+}
diff --git a/slytranslate/tests/Unit/MetaKeyExclusionTest.php b/slytranslate/tests/Unit/MetaKeyExclusionTest.php
new file mode 100644
index 0000000..58f8073
--- /dev/null
+++ b/slytranslate/tests/Unit/MetaKeyExclusionTest.php
@@ -0,0 +1,154 @@
+setStaticProperty( MetaTranslationService::class, 'seo_plugin_config', array(
+ 'key' => 'yoast',
+ 'label' => 'Yoast SEO',
+ 'translate' => array( '_yoast_wpseo_title' ),
+ 'clear' => array(),
+ ) );
+
+ $this->stubWpFunction( 'apply_filters', static function ( string $tag, $value, ...$args ) {
+ return $value;
+ } );
+ $this->stubWpFunction( 'get_option', static function ( string $option, $default = false ) {
+ if ( 'slytranslate_meta_keys_exclude' === $option ) {
+ return 'excluded_key hero_text';
+ }
+ if ( 'slytranslate_meta_translate' === $option ) {
+ return 'manual_key excluded_key';
+ }
+ return $default;
+ } );
+ }
+
+ protected function tearDown(): void {
+ MetaTranslationService::reset_cache();
+ parent::tearDown();
+ }
+
+ /* ---------------------------------------------------------------
+ * filter_excluded_meta_key
+ * ------------------------------------------------------------- */
+
+ public function test_excluded_key_is_vetoed(): void {
+ $this->assertFalse( MetaTranslationService::filter_excluded_meta_key( true, 'excluded_key' ) );
+ }
+
+ public function test_non_excluded_key_passes_through(): void {
+ $this->assertTrue( MetaTranslationService::filter_excluded_meta_key( true, 'manual_key' ) );
+ // An earlier false decision is preserved, not overwritten.
+ $this->assertFalse( MetaTranslationService::filter_excluded_meta_key( false, 'manual_key' ) );
+ }
+
+ public function test_exclusion_applies_in_effective_config_via_filter_dispatch(): void {
+ // Simulate the real filter wiring: the per-key filter dispatches to the
+ // priority-5 core callback.
+ $this->stubWpFunction( 'apply_filters', static function ( string $tag, $value, ...$args ) {
+ if ( 'slytranslate_translate_meta_key' === $tag ) {
+ return MetaTranslationService::filter_excluded_meta_key( $value, (string) $args[0] );
+ }
+ return $value;
+ } );
+
+ $config = $this->invokeStatic( MetaTranslationService::class, 'get_effective_meta_key_config', array( 0 ) );
+
+ $this->assertContains( 'manual_key', $config['translate'] );
+ $this->assertNotContains( 'excluded_key', $config['translate'] );
+ $this->assertContains( '_yoast_wpseo_title', $config['translate'] );
+ }
+
+ /* ---------------------------------------------------------------
+ * describe_effective_meta_keys
+ * ------------------------------------------------------------- */
+
+ public function test_describe_reports_sources_and_exclusions(): void {
+ $post_meta = array(
+ 'hero_text' => array( 'Big headline' ),
+ '_hero_text' => array( 'field_hero' ),
+ 'intro' => array( 'Intro copy' ),
+ '_intro' => array( 'field_intro' ),
+ );
+
+ $this->stubWpFunctionReturn( 'get_post_meta', $post_meta );
+ $this->stubWpFunction( 'acf_get_field', static function ( $ref ) {
+ $fields = array(
+ 'field_hero' => array( 'type' => 'text', 'label' => 'Hero Text' ),
+ 'field_intro' => array( 'type' => 'wysiwyg', 'label' => 'Intro' ),
+ );
+ return $fields[ $ref ] ?? false;
+ } );
+
+ // Dispatch both runtime filters like production wiring does.
+ $this->stubWpFunction( 'apply_filters', static function ( string $tag, $value, ...$args ) {
+ if ( 'slytranslate_meta_keys_translate' === $tag ) {
+ return AcfMetaResolver::add_acf_translatable_keys( (array) $value, (int) $args[0], (string) $args[1], (string) $args[2], (array) $args[3] );
+ }
+ if ( 'slytranslate_translate_meta_key' === $tag ) {
+ return MetaTranslationService::filter_excluded_meta_key( $value, (string) $args[0] );
+ }
+ return $value;
+ } );
+
+ $described = MetaTranslationService::describe_effective_meta_keys( 42 );
+ $by_key = array();
+ foreach ( $described as $entry ) {
+ $by_key[ $entry['key'] ] = $entry;
+ }
+
+ // Manual key, not excluded.
+ $this->assertSame( 'manual', $by_key['manual_key']['source'] );
+ $this->assertFalse( $by_key['manual_key']['excluded'] );
+
+ // SEO key from the detected plugin config.
+ $this->assertSame( 'seo', $by_key['_yoast_wpseo_title']['source'] );
+
+ // ACF keys carry label and type; hero_text is on the exclusion list.
+ $this->assertSame( 'acf', $by_key['intro']['source'] );
+ $this->assertSame( 'Intro', $by_key['intro']['field_label'] );
+ $this->assertSame( 'wysiwyg', $by_key['intro']['field_type'] );
+ $this->assertFalse( $by_key['intro']['excluded'] );
+
+ $this->assertSame( 'acf', $by_key['hero_text']['source'] );
+ $this->assertTrue( $by_key['hero_text']['excluded'] );
+
+ // Excluded manual key still listed, flagged as excluded.
+ $this->assertSame( 'manual', $by_key['excluded_key']['source'] );
+ $this->assertTrue( $by_key['excluded_key']['excluded'] );
+ }
+
+ public function test_describe_marks_unknown_filter_keys_as_filter_source(): void {
+ $this->stubWpFunction( 'apply_filters', static function ( string $tag, $value, ...$args ) {
+ if ( 'slytranslate_meta_keys_translate' === $tag ) {
+ $value = (array) $value;
+ $value[] = 'third_party_key';
+ return $value;
+ }
+ return $value;
+ } );
+
+ $described = MetaTranslationService::describe_effective_meta_keys();
+ $by_key = array();
+ foreach ( $described as $entry ) {
+ $by_key[ $entry['key'] ] = $entry;
+ }
+
+ $this->assertSame( 'filter', $by_key['third_party_key']['source'] );
+ }
+}
diff --git a/slytranslate/tests/Unit/MetaKeyRuntimeFilterTest.php b/slytranslate/tests/Unit/MetaKeyRuntimeFilterTest.php
index c179db7..4952a41 100644
--- a/slytranslate/tests/Unit/MetaKeyRuntimeFilterTest.php
+++ b/slytranslate/tests/Unit/MetaKeyRuntimeFilterTest.php
@@ -165,13 +165,14 @@ public function test_per_key_filter_true_keeps_key(): void {
}
/**
- * Test backward compatibility: without filters, result is empty (no SEO plugin, no user config).
+ * Test backward compatibility: without filters and without SEO plugin or
+ * user config, only the plugin default keys (image alt text) remain.
*/
public function test_no_filter_backward_compatible(): void {
$config = $this->invokeStatic( MetaTranslationService::class, 'get_effective_meta_key_config', array( 0 ) );
$this->assertIsArray( $config );
- $this->assertSame( array(), $config['translate'] );
+ $this->assertSame( MetaTranslationService::DEFAULT_TRANSLATE_META_KEYS, $config['translate'] );
$this->assertSame( array(), $config['clear'] );
}
diff --git a/slytranslate/tests/Unit/MetaValueSpecTest.php b/slytranslate/tests/Unit/MetaValueSpecTest.php
new file mode 100644
index 0000000..59d0e72
--- /dev/null
+++ b/slytranslate/tests/Unit/MetaValueSpecTest.php
@@ -0,0 +1,139 @@
+setStaticProperty( TranslationRuntime::class, 'model_profile_cache', array() );
+ $this->setStaticProperty( TranslationRuntime::class, 'model_slug_override', null );
+
+ $this->stubWpFunction( 'apply_filters', static function ( string $tag, $value, ...$args ) {
+ return $value;
+ } );
+ $this->stubWpFunctionReturn( 'get_option', '' );
+ $this->stubWpFunctionReturn( 'get_current_user_id', 1 );
+ $this->stubWpFunction( 'maybe_unserialize', static fn( $value ) => $value );
+ }
+
+ protected function tearDown(): void {
+ MetaTranslationService::reset_cache();
+ parent::tearDown();
+ }
+
+ private function stubAiResponse( string $response_text, array &$calls = array() ): void {
+ $this->stubWpFunction(
+ 'wp_ai_client_prompt',
+ static function ( string $text ) use ( $response_text, &$calls ) {
+ $calls[] = $text;
+ return new class( $response_text ) {
+ private string $response;
+
+ public function __construct( string $response ) {
+ $this->response = $response;
+ }
+
+ public function using_system_instruction( string $p ): static { return $this; }
+ public function using_temperature( float $t ): static { return $this; }
+ public function using_model_preference( string $s ): static { return $this; }
+ public function using_max_tokens( int $n ): static { return $this; }
+ public function using_max_output_tokens( int $n ): static { return $this; }
+
+ public function generate_text(): string {
+ return '' . $this->response . '';
+ }
+ };
+ }
+ );
+ }
+
+ public function test_spec_translates_only_listed_subkeys(): void {
+ $this->stubAiResponse( 'Hier klicken' );
+ MetaTranslationService::set_meta_value_spec( 'cta_link', array( 'subkeys' => array( 'title' ) ), 'link' );
+
+ $value = array(
+ 'title' => 'Click here',
+ 'url' => 'https://example.com/page',
+ 'target' => '_blank',
+ );
+
+ $result = MetaTranslationService::translate_meta_value_for_key( 'cta_link', $value, 'de', 'en' );
+
+ $this->assertSame( 'Hier klicken', $result['title'] );
+ $this->assertSame( 'https://example.com/page', $result['url'] );
+ $this->assertSame( '_blank', $result['target'] );
+ }
+
+ public function test_spec_with_non_array_value_returns_value_untranslated(): void {
+ $calls = array();
+ $this->stubAiResponse( 'should not be called', $calls );
+ MetaTranslationService::set_meta_value_spec( 'cta_link', array( 'subkeys' => array( 'title' ) ), 'link' );
+
+ $result = MetaTranslationService::translate_meta_value_for_key( 'cta_link', 'https://example.com', 'de', 'en' );
+
+ $this->assertSame( 'https://example.com', $result );
+ $this->assertCount( 0, $calls );
+ }
+
+ public function test_keys_without_spec_use_generic_translation(): void {
+ $this->stubAiResponse( 'Hallo Welt' );
+
+ $result = MetaTranslationService::translate_meta_value_for_key( 'plain_key', 'Hello World', 'de', 'en' );
+
+ $this->assertSame( 'Hallo Welt', $result );
+ }
+
+ public function test_spec_filter_can_provide_spec_for_unregistered_key(): void {
+ $this->stubAiResponse( 'Etikett' );
+
+ $this->stubWpFunction( 'apply_filters', static function ( string $tag, $value, ...$args ) {
+ if ( 'slytranslate_meta_value_translation_spec' === $tag && 'third_party_key' === ( $args[0] ?? '' ) ) {
+ return array( 'subkeys' => array( 'label' ) );
+ }
+ return $value;
+ } );
+
+ $value = array( 'label' => 'Label', 'icon' => 'star' );
+ $result = MetaTranslationService::translate_meta_value_for_key( 'third_party_key', $value, 'de', 'en' );
+
+ $this->assertSame( 'Etikett', $result['label'] );
+ $this->assertSame( 'star', $result['icon'] );
+ }
+
+ public function test_spec_keys_are_excluded_from_batching(): void {
+ MetaTranslationService::set_meta_value_spec( 'cta_link', array( 'subkeys' => array( 'title' ) ), 'link' );
+
+ $meta = array(
+ 'cta_link' => array( 'serialized-link-value' ),
+ 'plain_key' => array( 'Hello' ),
+ );
+
+ $count = MetaTranslationService::count_batch_eligible_candidates(
+ $meta,
+ array( 'translate' => array( 'cta_link', 'plain_key' ), 'clear' => array() )
+ );
+
+ $this->assertSame( 1, $count );
+ }
+
+ public function test_reset_cache_clears_specs(): void {
+ MetaTranslationService::set_meta_value_spec( 'cta_link', array( 'subkeys' => array( 'title' ) ), 'link' );
+ $this->assertNotEmpty( MetaTranslationService::get_meta_value_spec( 'cta_link' )['spec'] );
+
+ MetaTranslationService::reset_cache();
+
+ $this->assertSame( array(), MetaTranslationService::get_meta_value_spec( 'cta_link' )['spec'] );
+ }
+}
diff --git a/slytranslate/tests/Unit/PodsMetaResolverTest.php b/slytranslate/tests/Unit/PodsMetaResolverTest.php
new file mode 100644
index 0000000..5f26266
--- /dev/null
+++ b/slytranslate/tests/Unit/PodsMetaResolverTest.php
@@ -0,0 +1,98 @@
+stubWpFunction( 'apply_filters', static function ( string $tag, $value, ...$args ) {
+ return $value;
+ } );
+ $this->stubWpFunctionReturn( 'get_post_type', 'book' );
+
+ $this->stubWpFunction( 'pods_api', static function () {
+ return new class() {
+ public function load_pod( array $params ) {
+ if ( 'book' !== ( $params['name'] ?? '' ) ) {
+ return false;
+ }
+ return array(
+ 'name' => 'book',
+ 'fields' => array(
+ 'summary' => array( 'type' => 'paragraph' ),
+ 'subtitle' => array( 'type' => 'text' ),
+ 'review' => array( 'type' => 'wysiwyg' ),
+ 'page_count' => array( 'type' => 'number' ),
+ ),
+ );
+ }
+ };
+ } );
+ }
+
+ public function test_translatable_pods_fields_are_added(): void {
+ $post_meta = array(
+ 'summary' => array( 'A short summary' ),
+ 'subtitle' => array( 'A subtitle' ),
+ 'review' => array( 'Great.
' ),
+ );
+
+ $result = PodsMetaResolver::add_pods_translatable_keys( array(), 42, 'en', 'de', $post_meta );
+
+ $this->assertContains( 'summary', $result );
+ $this->assertContains( 'subtitle', $result );
+ $this->assertContains( 'review', $result );
+ }
+
+ public function test_non_translatable_and_unregistered_keys_are_skipped(): void {
+ $post_meta = array(
+ 'page_count' => array( '320' ),
+ 'random_key' => array( 'value' ),
+ '_internal' => array( 'value' ),
+ );
+
+ $result = PodsMetaResolver::add_pods_translatable_keys( array(), 42, 'en', 'de', $post_meta );
+
+ $this->assertSame( array(), $result );
+ }
+
+ public function test_unknown_post_type_returns_unchanged(): void {
+ $this->stubWpFunctionReturn( 'get_post_type', 'page' );
+
+ $post_meta = array( 'summary' => array( 'A short summary' ) );
+
+ $this->assertSame( array(), PodsMetaResolver::add_pods_translatable_keys( array(), 42, 'en', 'de', $post_meta ) );
+ }
+
+ public function test_missing_post_context_returns_unchanged(): void {
+ $post_meta = array( 'summary' => array( 'A short summary' ) );
+
+ $this->assertSame( array(), PodsMetaResolver::add_pods_translatable_keys( array(), 0, 'en', 'de', $post_meta ) );
+ }
+
+ public function test_types_filter_extends_list(): void {
+ $this->stubWpFunction( 'apply_filters', static function ( string $tag, $value, ...$args ) {
+ if ( 'slytranslate_pods_translatable_field_types' === $tag ) {
+ $value = (array) $value;
+ $value[] = 'number';
+ }
+ return $value;
+ } );
+
+ $post_meta = array( 'page_count' => array( '320' ) );
+
+ $result = PodsMetaResolver::add_pods_translatable_keys( array(), 42, 'en', 'de', $post_meta );
+
+ $this->assertContains( 'page_count', $result );
+ }
+}
diff --git a/slytranslate/tests/Unit/PolylangCreateTranslationTest.php b/slytranslate/tests/Unit/PolylangCreateTranslationTest.php
new file mode 100644
index 0000000..0292d4c
--- /dev/null
+++ b/slytranslate/tests/Unit/PolylangCreateTranslationTest.php
@@ -0,0 +1,193 @@
+ */
+ private array $stored_options = array();
+
+ /** @var array */
+ private array $meta_updates = array();
+
+ /** @var array */
+ private array $post_updates = array();
+
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->stored_options = array();
+ $this->meta_updates = array();
+ $this->post_updates = array();
+
+ $this->stubWpFunction( 'get_option', function ( $option, $default = false ) {
+ return $this->stored_options[ $option ] ?? $default;
+ } );
+ $this->stubWpFunction( 'get_post', static function ( $post_id = null ) {
+ return new \WP_Post( array(
+ 'ID' => 5,
+ 'post_title' => 'Hallo Welt',
+ 'post_type' => 'post',
+ ) );
+ } );
+ $this->stubWpFunctionReturn( 'pll_get_post', 0 );
+ $this->stubWpFunctionReturn( 'wp_insert_post', 99 );
+ $this->stubWpFunction( 'wp_update_post', function ( $postarr ) {
+ $this->post_updates[] = $postarr;
+ return is_array( $postarr ) && isset( $postarr['ID'] ) ? (int) $postarr['ID'] : 0;
+ } );
+ $this->stubWpFunction( 'update_post_meta', function ( $post_id, $key, $value ) {
+ $this->meta_updates[] = array( (int) $post_id, (string) $key, $value );
+ return true;
+ } );
+ $this->stubWpFunctionReturn( 'get_object_taxonomies', array() );
+ }
+
+ protected function tearDown(): void {
+ $this->setStaticProperty( AI_Translate::class, 'adapter', null );
+ parent::tearDown();
+ }
+
+ private function make_adapter(): CreateTranslationPolylangDouble {
+ $adapter = new CreateTranslationPolylangDouble();
+ $this->setStaticProperty( AI_Translate::class, 'adapter', $adapter );
+ return $adapter;
+ }
+
+ public function test_create_translation_marks_generated_posts(): void {
+ $adapter = $this->make_adapter();
+
+ $result = $adapter->create_translation( 5, 'en', array( 'post_title' => 'Hello World' ) );
+
+ $this->assertSame( 99, $result );
+ $this->assertContains(
+ array( 99, AutoPublishTranslationService::GENERATED_META_KEY, '1' ),
+ $this->meta_updates
+ );
+ }
+
+ public function test_slug_translation_is_off_by_default(): void {
+ $adapter = $this->make_adapter();
+
+ $adapter->create_translation( 5, 'en', array( 'post_title' => 'Hello World' ) );
+
+ $this->assertArrayNotHasKey( 'post_name', $this->post_updates[0] );
+ }
+
+ public function test_slug_is_derived_from_translated_title_when_opted_in(): void {
+ $this->stored_options['slytranslate_translate_slugs'] = '1';
+ $adapter = $this->make_adapter();
+
+ $adapter->create_translation( 5, 'en', array( 'post_title' => 'Hello World' ) );
+
+ $this->assertSame( 'hello-world', $this->post_updates[0]['post_name'] );
+ }
+
+ public function test_existing_translation_slug_is_left_untouched_unless_auto_stub(): void {
+ $this->stored_options['slytranslate_translate_slugs'] = '1';
+ $this->stubWpFunctionReturn( 'pll_get_post', 99 ); // translation already exists
+ $adapter = $this->make_adapter();
+
+ // Live slug, manually curated → keep it.
+ $this->stubWpFunctionReturn( 'get_post_field', 'curated-slug' );
+ $adapter->create_translation( 5, 'en', array( 'post_title' => 'Hello World', 'overwrite' => true ) );
+ $this->assertArrayNotHasKey( 'post_name', $this->post_updates[0] );
+
+ // Slug still the auto-generated stub ("Hallo Welt (en)") → replace it.
+ $this->stubWpFunctionReturn( 'get_post_field', 'hallo-welt-en' );
+ $adapter->create_translation( 5, 'en', array( 'post_title' => 'Hello World', 'overwrite' => true ) );
+ $this->assertSame( 'hello-world', $this->post_updates[1]['post_name'] );
+ }
+
+ public function test_missing_terms_are_translated_when_opted_in(): void {
+ $this->stored_options['slytranslate_translate_terms'] = '1';
+ $adapter = $this->make_adapter();
+
+ $this->stubWpFunctionReturn( 'get_object_taxonomies', array( 'category' ) );
+ $this->stubWpFunctionReturn( 'wp_get_object_terms', array( 7 ) );
+ $this->stubWpFunctionReturn( 'pll_get_term', 0 );
+ $this->stubWpFunction( 'get_term', static function () {
+ return new \WP_Term( array( 'term_id' => 7, 'name' => 'Anleitungen', 'taxonomy' => 'category' ) );
+ } );
+ $this->stubWpFunction( 'wp_insert_term', static function () {
+ return array( 'term_id' => 55 );
+ } );
+ $this->stubWpFunction(
+ 'wp_ai_client_prompt',
+ static function () {
+ return new class() {
+ public function using_system_instruction( string $p ): static { return $this; }
+ public function using_temperature( float $t ): static { return $this; }
+ public function using_model_preference( string $s ): static { return $this; }
+ public function using_max_tokens( int $n ): static { return $this; }
+ public function using_max_output_tokens( int $n ): static { return $this; }
+ public function generate_text(): string {
+ return 'Guides';
+ }
+ };
+ }
+ );
+
+ $assigned_terms = array();
+ $this->stubWpFunction( 'wp_set_object_terms', static function ( $object_id, $terms, $taxonomy ) use ( &$assigned_terms ) {
+ $assigned_terms[] = array( $object_id, $terms, $taxonomy );
+ return $terms;
+ } );
+
+ $adapter->create_translation( 5, 'en', array( 'post_title' => 'Hello World' ) );
+
+ $this->assertSame( array( array( 99, array( 55 ), 'category' ) ), $assigned_terms );
+ }
+
+ public function test_missing_terms_are_dropped_when_option_disabled(): void {
+ $adapter = $this->make_adapter();
+
+ $this->stubWpFunctionReturn( 'get_object_taxonomies', array( 'category' ) );
+ $this->stubWpFunctionReturn( 'wp_get_object_terms', array( 7 ) );
+ $this->stubWpFunctionReturn( 'pll_get_term', 0 );
+
+ $assigned_terms = array();
+ $this->stubWpFunction( 'wp_set_object_terms', static function ( $object_id, $terms, $taxonomy ) use ( &$assigned_terms ) {
+ $assigned_terms[] = array( $object_id, $terms, $taxonomy );
+ return $terms;
+ } );
+
+ $adapter->create_translation( 5, 'en', array( 'post_title' => 'Hello World' ) );
+
+ // Previous behaviour stays the default: the unmatched term is dropped.
+ $this->assertSame( array(), $assigned_terms );
+ }
+}
+
+class CreateTranslationPolylangDouble extends PolylangAdapter {
+ public function is_available(): bool {
+ return true;
+ }
+
+ public function get_post_language( int $post_id ): ?string {
+ return 'de';
+ }
+
+ public function set_post_language( int $post_id, string $target_language ) {
+ return true;
+ }
+
+ public function link_translation( int $source_post_id, int $translated_post_id, string $target_lang ): bool {
+ return true;
+ }
+
+ public function supports_term_translation(): bool {
+ return true;
+ }
+}
diff --git a/slytranslate/tests/Unit/SettingsPageTest.php b/slytranslate/tests/Unit/SettingsPageTest.php
new file mode 100644
index 0000000..63d58af
--- /dev/null
+++ b/slytranslate/tests/Unit/SettingsPageTest.php
@@ -0,0 +1,139 @@
+setStaticProperty( AI_Translate::class, 'adapter', null );
+ $this->stubWpFunction( 'apply_filters', static function ( string $tag, $value, ...$args ) {
+ return $value;
+ } );
+ $this->stubWpFunctionReturn( 'get_option', '' );
+ }
+
+ protected function tearDown(): void {
+ $this->setStaticProperty( AI_Translate::class, 'adapter', null );
+ parent::tearDown();
+ }
+
+ public function test_add_hooks_registers_menu_and_enqueue_hooks(): void {
+ $registered = array();
+ $this->stubWpFunction( 'add_action', static function ( string $hook, $callback ) use ( &$registered ): void {
+ $registered[ $hook ] = $callback;
+ } );
+
+ SettingsPage::add_hooks();
+
+ $this->assertSame( array( SettingsPage::class, 'register_settings_page' ), $registered['admin_menu'] );
+ $this->assertSame( array( SettingsPage::class, 'enqueue_assets' ), $registered['admin_enqueue_scripts'] );
+ }
+
+ public function test_register_settings_page_uses_manage_options_and_slug(): void {
+ $captured = array();
+ $this->stubWpFunction( 'add_options_page', static function ( ...$args ) use ( &$captured ) {
+ $captured = $args;
+ return 'settings_page_slytranslate';
+ } );
+
+ SettingsPage::register_settings_page();
+
+ $this->assertSame( 'manage_options', $captured[2] );
+ $this->assertSame( 'slytranslate', $captured[3] );
+ $this->assertSame( array( SettingsPage::class, 'render_page' ), $captured[4] );
+ }
+
+ public function test_enqueue_assets_skips_other_admin_pages(): void {
+ $enqueued = array();
+ $this->stubWpFunction( 'wp_enqueue_script', static function ( ...$args ) use ( &$enqueued ): void {
+ $enqueued[] = $args;
+ } );
+
+ SettingsPage::enqueue_assets( 'edit.php' );
+ SettingsPage::enqueue_assets( 'settings_page_other' );
+
+ $this->assertSame( array(), $enqueued );
+ }
+
+ public function test_enqueue_assets_loads_app_on_own_page_with_bootstrap_data(): void {
+ $enqueued = array();
+ $localized = array();
+ $this->stubWpFunction( 'wp_enqueue_script', static function ( ...$args ) use ( &$enqueued ): void {
+ $enqueued[] = $args;
+ } );
+ $this->stubWpFunction( 'wp_localize_script', static function ( string $handle, string $object_name, array $data ) use ( &$localized ): bool {
+ $localized = array( 'handle' => $handle, 'object' => $object_name, 'data' => $data );
+ return true;
+ } );
+
+ SettingsPage::enqueue_assets( 'settings_page_slytranslate' );
+
+ $this->assertCount( 1, $enqueued );
+ $this->assertSame( SettingsPage::SCRIPT_HANDLE, $enqueued[0][0] );
+ $this->assertStringContainsString( 'assets/settings-page.js', $enqueued[0][1] );
+ $this->assertContains( 'wp-api-fetch', $enqueued[0][2] );
+ $this->assertContains( 'wp-components', $enqueued[0][2] );
+ $this->assertContains( 'wp-element', $enqueued[0][2] );
+
+ $this->assertSame( SettingsPage::SCRIPT_HANDLE, $localized['handle'] );
+ $this->assertSame( 'slyTranslateSettings', $localized['object'] );
+ $this->assertSame( '/' . Plugin::REST_NAMESPACE . '/', $localized['data']['abilitiesRunBasePath'] );
+ $this->assertArrayHasKey( 'languagePlugin', $localized['data'] );
+ $this->assertArrayHasKey( 'serverTranslationAvailable', $localized['data'] );
+ $this->assertArrayHasKey( 'isStringTableAdapter', $localized['data'] );
+ $this->assertArrayHasKey( 'defaultPromptTemplate', $localized['data'] );
+
+ // Translated UI strings travel with the bootstrap (no JS i18n files).
+ $this->assertArrayHasKey( 'strings', $localized['data'] );
+ $this->assertSame( 'Save settings', $localized['data']['strings']['Save settings'] );
+ }
+
+ public function test_bootstrap_data_reports_polylang_adapter(): void {
+ $this->setStaticProperty( AI_Translate::class, 'adapter', new PolylangAdapter() );
+
+ $data = SettingsPage::get_bootstrap_data();
+
+ $this->assertSame( 'Polylang', $data['languagePlugin'] );
+ $this->assertFalse( $data['isStringTableAdapter'] );
+ }
+
+ public function test_bootstrap_data_flags_string_table_adapter(): void {
+ $this->setStaticProperty( AI_Translate::class, 'adapter', new TranslatePressAdapter() );
+
+ $data = SettingsPage::get_bootstrap_data();
+
+ $this->assertSame( 'TranslatePress', $data['languagePlugin'] );
+ $this->assertTrue( $data['isStringTableAdapter'] );
+ }
+
+ public function test_render_page_outputs_app_root(): void {
+ ob_start();
+ SettingsPage::render_page();
+ $output = (string) ob_get_clean();
+
+ $this->assertStringContainsString( 'id="slytranslate-settings-root"', $output );
+ }
+
+ public function test_probe_executor_returns_probe_result_for_model(): void {
+ // No parallel transport in the test environment → unsupported result.
+ $result = AI_Translate::execute_probe_string_table_concurrency( array( 'model_slug' => 'test-model' ) );
+
+ $this->assertIsArray( $result );
+ $this->assertFalse( $result['supported'] );
+ $this->assertSame( 1, $result['recommended'] );
+ $this->assertSame( 'test-model', $result['model_slug'] );
+ }
+}
diff --git a/slytranslate/tests/Unit/TermTranslationServiceTest.php b/slytranslate/tests/Unit/TermTranslationServiceTest.php
new file mode 100644
index 0000000..74de98f
--- /dev/null
+++ b/slytranslate/tests/Unit/TermTranslationServiceTest.php
@@ -0,0 +1,197 @@
+setStaticProperty( AI_Translate::class, 'adapter', new TermTestPolylangAdapter() );
+ }
+
+ protected function tearDown(): void {
+ $this->setStaticProperty( AI_Translate::class, 'adapter', null );
+ parent::tearDown();
+ }
+
+ private function stubAiResponse( string $response_text, array &$calls = array() ): void {
+ $this->stubWpFunction(
+ 'wp_ai_client_prompt',
+ static function ( string $text ) use ( $response_text, &$calls ) {
+ $calls[] = $text;
+ return new class( $response_text ) {
+ private string $response;
+
+ public function __construct( string $response ) {
+ $this->response = $response;
+ }
+
+ public function using_system_instruction( string $p ): static { return $this; }
+ public function using_temperature( float $t ): static { return $this; }
+ public function using_model_preference( string $s ): static { return $this; }
+ public function using_max_tokens( int $n ): static { return $this; }
+ public function using_max_output_tokens( int $n ): static { return $this; }
+
+ public function generate_text(): string {
+ return '' . $this->response . '';
+ }
+ };
+ }
+ );
+ }
+
+ public function test_translate_term_creates_sets_language_and_links(): void {
+ $this->stubAiResponse( 'Guides' );
+ $this->stubWpFunctionReturn( 'pll_get_term', 0 );
+ $this->stubWpFunction( 'get_term', static function () {
+ return new \WP_Term( array(
+ 'term_id' => 7,
+ 'name' => 'Anleitungen',
+ 'taxonomy' => 'category',
+ 'description' => '',
+ ) );
+ } );
+
+ $inserted_args = array();
+ $language_calls = array();
+ $translation_links = array();
+
+ $this->stubWpFunction( 'wp_insert_term', static function ( $name, $taxonomy, $args ) use ( &$inserted_args ) {
+ $inserted_args = array( $name, $taxonomy, $args );
+ return array( 'term_id' => 55, 'term_taxonomy_id' => 55 );
+ } );
+ $this->stubWpFunction( 'pll_set_term_language', static function ( $term_id, $lang ) use ( &$language_calls ) {
+ $language_calls[] = array( $term_id, $lang );
+ return true;
+ } );
+ $this->stubWpFunction( 'pll_save_term_translations', static function ( $translations ) use ( &$translation_links ) {
+ $translation_links[] = $translations;
+ return true;
+ } );
+
+ $result = TermTranslationService::translate_term( 7, 'en', 'de' );
+
+ $this->assertSame( 55, $result );
+ $this->assertSame( 'Guides', $inserted_args[0] );
+ $this->assertSame( 'category', $inserted_args[1] );
+ $this->assertSame( 'guides', $inserted_args[2]['slug'] );
+ $this->assertSame( array( array( 55, 'en' ) ), $language_calls );
+ $this->assertSame( array( array( 'de' => 7, 'en' => 55 ) ), $translation_links );
+ }
+
+ public function test_translate_term_returns_existing_linked_translation_without_ai_call(): void {
+ $this->stubWpFunctionReturn( 'pll_get_term', 9 );
+ $this->stubWpFunction( 'wp_ai_client_prompt', static function () {
+ throw new \RuntimeException( 'AI must not be called for already translated terms.' );
+ } );
+ $this->stubWpFunction( 'wp_insert_term', static function () {
+ throw new \RuntimeException( 'wp_insert_term must not be called for already translated terms.' );
+ } );
+
+ $this->assertSame( 9, TermTranslationService::translate_term( 7, 'en', 'de' ) );
+ }
+
+ public function test_translate_term_reuses_term_when_translated_name_already_exists(): void {
+ $this->stubAiResponse( 'Guides' );
+ $this->stubWpFunctionReturn( 'pll_get_term', 0 );
+ $this->stubWpFunction( 'get_term', static function () {
+ return new \WP_Term( array( 'term_id' => 7, 'name' => 'Anleitungen', 'taxonomy' => 'category' ) );
+ } );
+ $this->stubWpFunction( 'wp_insert_term', static function () {
+ return new \WP_Error( 'term_exists', 'A term with the name provided already exists.', 12 );
+ } );
+
+ $translation_links = array();
+ $this->stubWpFunction( 'pll_save_term_translations', static function ( $translations ) use ( &$translation_links ) {
+ $translation_links[] = $translations;
+ return true;
+ } );
+
+ $this->assertSame( 12, TermTranslationService::translate_term( 7, 'en', 'de' ) );
+ $this->assertSame( array( array( 'de' => 7, 'en' => 12 ) ), $translation_links );
+ }
+
+ public function test_translate_term_fails_for_untranslated_taxonomy(): void {
+ $this->stubWpFunctionReturn( 'pll_get_term', 0 );
+ $this->stubWpFunctionReturn( 'pll_is_translated_taxonomy', false );
+ $this->stubWpFunction( 'get_term', static function () {
+ return new \WP_Term( array( 'term_id' => 7, 'name' => 'Anleitungen', 'taxonomy' => 'product_attr' ) );
+ } );
+
+ $result = TermTranslationService::translate_term( 7, 'en', 'de' );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'taxonomy_not_translated', $result->get_error_code() );
+ }
+
+ public function test_execute_translate_terms_dry_run_reports_without_writing(): void {
+ $this->stubWpFunction( 'wp_ai_client_prompt', static function () {
+ throw new \RuntimeException( 'AI must not be called in dry-run mode.' );
+ } );
+ $this->stubWpFunction( 'pll_get_term', static function ( $term_id ) {
+ return 2 === $term_id ? 77 : 0;
+ } );
+
+ $result = TermTranslationService::execute_translate_terms( array(
+ 'taxonomy' => 'category',
+ 'target_language' => 'en',
+ 'source_language' => 'de',
+ 'term_ids' => array( 1, 2 ),
+ 'dry_run' => true,
+ ) );
+
+ $this->assertIsArray( $result );
+ $this->assertTrue( $result['dry_run'] );
+ $this->assertSame( 2, $result['total'] );
+ $this->assertSame( 1, $result['skipped'] );
+ $this->assertSame( 0, $result['succeeded'] );
+ $this->assertSame( 'would_translate', $result['results'][0]['status'] );
+ $this->assertSame( 'skipped', $result['results'][1]['status'] );
+ $this->assertSame( 77, $result['results'][1]['translated_term_id'] );
+ }
+
+ public function test_execute_translate_terms_requires_supported_adapter(): void {
+ $this->setStaticProperty( AI_Translate::class, 'adapter', null );
+ $this->stubWpFunction( 'apply_filters', static fn( $tag, $value ) => $value );
+
+ $result = TermTranslationService::execute_translate_terms( array(
+ 'taxonomy' => 'category',
+ 'target_language' => 'en',
+ ) );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'term_translation_unsupported', $result->get_error_code() );
+ }
+
+ public function test_execute_translate_terms_uses_default_language_as_source(): void {
+ $this->stubWpFunctionReturn( 'pll_default_language', 'en' );
+
+ $result = TermTranslationService::execute_translate_terms( array(
+ 'taxonomy' => 'category',
+ 'target_language' => 'en',
+ 'term_ids' => array( 1 ),
+ ) );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'invalid_source_language', $result->get_error_code() );
+ }
+}
+
+/**
+ * PolylangAdapter double whose availability checks always pass.
+ */
+class TermTestPolylangAdapter extends PolylangAdapter {
+ public function is_available(): bool {
+ return true;
+ }
+
+ public function supports_term_translation(): bool {
+ return true;
+ }
+}
diff --git a/slytranslate/tests/Unit/TranslationFingerprintTest.php b/slytranslate/tests/Unit/TranslationFingerprintTest.php
new file mode 100644
index 0000000..74be344
--- /dev/null
+++ b/slytranslate/tests/Unit/TranslationFingerprintTest.php
@@ -0,0 +1,141 @@
+stubWpFunction( 'serialize_blocks', static function ( array $blocks ): string {
+ return implode( '', array_map( static fn( array $b ) => (string) ( $b['innerHTML'] ?? '' ), $blocks ) );
+ } );
+ }
+
+ private static function block( string $html ): array {
+ return array( 'blockName' => 'core/paragraph', 'innerHTML' => $html );
+ }
+
+ public function test_compute_block_hashes_hashes_each_top_level_block(): void {
+ $hashes = TranslationFingerprint::compute_block_hashes( array( self::block( 'Alpha' ), self::block( 'Beta' ) ) );
+
+ $this->assertSame(
+ array( hash( 'sha256', 'Alpha' ), hash( 'sha256', 'Beta' ) ),
+ $hashes
+ );
+ }
+
+ public function test_plan_block_reuse_maps_unchanged_blocks_by_content_hash(): void {
+ $source_blocks = array( self::block( 'Alpha' ), self::block( 'Changed!' ) );
+
+ $this->stubWpFunction( 'get_post_meta', static function ( $post_id, $key = '' ) {
+ return TranslationFingerprint::UNIT_HASHES_META_KEY === $key
+ ? array( hash( 'sha256', 'Alpha' ), hash( 'sha256', 'Beta' ) )
+ : '';
+ } );
+ $this->stubWpFunction( 'get_post', static function () {
+ return new \WP_Post( array( 'ID' => 9, 'post_content' => 'Alpha-DE Beta-DE' ) );
+ } );
+ $this->stubWpFunction( 'parse_blocks', static function (): array {
+ return array( self::block( 'Alpha-DE' ), self::block( 'Beta-DE' ) );
+ } );
+
+ $map = TranslationFingerprint::plan_block_reuse( $source_blocks, 9 );
+
+ $this->assertIsArray( $map );
+ $this->assertSame( 'Alpha-DE', $map[ hash( 'sha256', 'Alpha' ) ] );
+ $this->assertSame( 'Beta-DE', $map[ hash( 'sha256', 'Beta' ) ] );
+ }
+
+ public function test_plan_block_reuse_falls_back_when_translation_block_count_diverged(): void {
+ $this->stubWpFunction( 'get_post_meta', static function ( $post_id, $key = '' ) {
+ return TranslationFingerprint::UNIT_HASHES_META_KEY === $key
+ ? array( hash( 'sha256', 'Alpha' ), hash( 'sha256', 'Beta' ) )
+ : '';
+ } );
+ $this->stubWpFunction( 'get_post', static function () {
+ return new \WP_Post( array( 'ID' => 9, 'post_content' => 'Alpha-DE' ) );
+ } );
+ // Translation only has one block left although two hashes were stored.
+ $this->stubWpFunction( 'parse_blocks', static function (): array {
+ return array( self::block( 'Alpha-DE' ) );
+ } );
+
+ $this->assertNull( TranslationFingerprint::plan_block_reuse( array( self::block( 'Alpha' ) ), 9 ) );
+ }
+
+ public function test_plan_block_reuse_returns_null_without_stored_hashes_or_matches(): void {
+ $this->stubWpFunctionReturn( 'get_post_meta', '' );
+ $this->assertNull( TranslationFingerprint::plan_block_reuse( array( self::block( 'Alpha' ) ), 9 ) );
+
+ // Stored hashes exist but nothing in the new source matches.
+ $this->stubWpFunction( 'get_post_meta', static function ( $post_id, $key = '' ) {
+ return TranslationFingerprint::UNIT_HASHES_META_KEY === $key
+ ? array( hash( 'sha256', 'Old content' ) )
+ : '';
+ } );
+ $this->stubWpFunction( 'get_post', static function () {
+ return new \WP_Post( array( 'ID' => 9, 'post_content' => 'Old-DE' ) );
+ } );
+ $this->stubWpFunction( 'parse_blocks', static function (): array {
+ return array( self::block( 'Old-DE' ) );
+ } );
+
+ $this->assertNull( TranslationFingerprint::plan_block_reuse( array( self::block( 'All new' ) ), 9 ) );
+ }
+
+ public function test_get_unchanged_meta_keys_compares_stored_hashes(): void {
+ $all_meta = array(
+ 'subtitle' => array( 'Unchanged value' ),
+ 'description' => array( 'Edited value' ),
+ );
+
+ $this->stubWpFunction( 'get_post_meta', static function ( $post_id, $key = '' ) {
+ return TranslationFingerprint::META_HASHES_META_KEY === $key
+ ? array(
+ 'subtitle' => hash( 'sha256', 'Unchanged value' ),
+ 'description' => hash( 'sha256', 'Original value' ),
+ )
+ : '';
+ } );
+
+ $unchanged = TranslationFingerprint::get_unchanged_meta_keys( 9, $all_meta, array( 'subtitle', 'description' ) );
+
+ $this->assertSame( array( 'subtitle' ), $unchanged );
+ }
+
+ public function test_store_fingerprints_persists_block_and_meta_hashes(): void {
+ $updates = array();
+ $this->stubWpFunction( 'update_post_meta', static function ( $post_id, $key, $value ) use ( &$updates ) {
+ $updates[ $key ] = array( $post_id, $value );
+ return true;
+ } );
+
+ TranslationFingerprint::store_fingerprints(
+ 9,
+ array( self::block( 'Alpha' ) ),
+ array( 'subtitle' => array( 'Value' ) ),
+ array( 'subtitle', 'missing_key' )
+ );
+
+ $this->assertSame(
+ array( 9, array( hash( 'sha256', 'Alpha' ) ) ),
+ $updates[ TranslationFingerprint::UNIT_HASHES_META_KEY ]
+ );
+ $this->assertSame(
+ array( 9, array( 'subtitle' => hash( 'sha256', 'Value' ) ) ),
+ $updates[ TranslationFingerprint::META_HASHES_META_KEY ]
+ );
+ }
+
+ public function test_post_fingerprint_stays_md5_compatible_with_client_workflow(): void {
+ $post = new \WP_Post( array( 'post_title' => 'T', 'post_content' => 'C', 'post_excerpt' => 'E' ) );
+
+ $this->assertSame( md5( "T\x00C\x00E" ), TranslationFingerprint::compute_post_fingerprint( $post ) );
+ }
+}
diff --git a/slytranslate/tests/Unit/TranslationQueueTest.php b/slytranslate/tests/Unit/TranslationQueueTest.php
new file mode 100644
index 0000000..e9b99f6
--- /dev/null
+++ b/slytranslate/tests/Unit/TranslationQueueTest.php
@@ -0,0 +1,138 @@
+ */
+ private array $stored_options = array();
+
+ protected function setUp(): void {
+ parent::setUp();
+ $this->stored_options = array();
+ $this->stubWpFunction( 'get_option', function ( $option, $default = false ) {
+ return $this->stored_options[ $option ] ?? $default;
+ } );
+ $this->stubWpFunction( 'update_option', function ( $option, $value ) {
+ $this->stored_options[ $option ] = $value;
+ return true;
+ } );
+ }
+
+ protected function tearDown(): void {
+ TranslationQueue::set_scheduler_for_testing( null );
+ parent::tearDown();
+ }
+
+ public function test_enqueue_bulk_dispatches_one_action_per_post(): void {
+ $dispatched = array();
+ TranslationQueue::set_scheduler_for_testing( static function ( string $hook, array $args ) use ( &$dispatched ): void {
+ $dispatched[] = array( $hook, $args );
+ } );
+ $this->stubWpFunctionReturn( 'get_current_user_id', 17 );
+
+ $result = TranslationQueue::enqueue_bulk( array( 10, 20, 10, 0 ), 'de', array( 'post_status' => 'draft' ) );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 2, $result['total'] );
+ $this->assertNotSame( '', $result['job_id'] );
+
+ $this->assertCount( 2, $dispatched );
+ $this->assertSame( TranslationQueue::WORKER_HOOK, $dispatched[0][0] );
+ $this->assertSame( array( $result['job_id'], 10 ), $dispatched[0][1] );
+ $this->assertSame( array( $result['job_id'], 20 ), $dispatched[1][1] );
+
+ $status = TranslationQueue::get_job_status( $result['job_id'] );
+ $this->assertSame( 'queued', $status['status'] );
+ $this->assertSame( 'de', $status['target_language'] );
+ $this->assertSame( 0, $status['processed'] );
+ }
+
+ public function test_enqueue_bulk_rejects_empty_input(): void {
+ TranslationQueue::set_scheduler_for_testing( static function (): void {} );
+
+ $result = TranslationQueue::enqueue_bulk( array( 0 ), 'de' );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'queue_empty', $result->get_error_code() );
+ }
+
+ public function test_worker_records_failure_and_completes_job(): void {
+ TranslationQueue::set_scheduler_for_testing( static function (): void {} );
+ $this->stubWpFunctionReturn( 'get_current_user_id', 17 );
+ // No adapter is active in the test environment, so translate_post fails
+ // with no_translation_plugin — exactly what this test needs.
+ $this->stubWpFunction( 'apply_filters', static fn( $tag, $value ) => $value );
+
+ $restored_users = array();
+ $this->stubWpFunction( 'wp_set_current_user', static function ( $user_id ) use ( &$restored_users ) {
+ $restored_users[] = $user_id;
+ return null;
+ } );
+
+ $result = TranslationQueue::enqueue_bulk( array( 10 ), 'de' );
+ TranslationQueue::process_queued_translation( $result['job_id'], 10 );
+
+ $this->assertSame( array( 17 ), $restored_users );
+
+ $status = TranslationQueue::get_job_status( $result['job_id'] );
+ $this->assertSame( 'completed', $status['status'] );
+ $this->assertSame( 1, $status['processed'] );
+ $this->assertSame( 1, $status['failed'] );
+ $this->assertSame( 0, $status['succeeded'] );
+ $this->assertSame( 'failed', $status['results'][0]['status'] );
+ }
+
+ public function test_worker_skips_cancelled_jobs(): void {
+ TranslationQueue::set_scheduler_for_testing( static function (): void {} );
+
+ $result = TranslationQueue::enqueue_bulk( array( 10 ), 'de' );
+ TranslationQueue::cancel( $result['job_id'] );
+ TranslationQueue::process_queued_translation( $result['job_id'], 10 );
+
+ $status = TranslationQueue::get_job_status( $result['job_id'] );
+ $this->assertSame( 'cancelled', $status['status'] );
+ $this->assertSame( 0, $status['processed'] );
+ }
+
+ public function test_cancel_unschedules_pending_cron_events(): void {
+ TranslationQueue::set_scheduler_for_testing( static function (): void {} );
+
+ $cleared = array();
+ $this->stubWpFunction( 'wp_clear_scheduled_hook', static function ( $hook, $args = array() ) use ( &$cleared ) {
+ $cleared[] = array( $hook, $args );
+ return 1;
+ } );
+
+ $result = TranslationQueue::enqueue_bulk( array( 10, 20 ), 'de' );
+
+ $this->assertTrue( TranslationQueue::cancel( $result['job_id'] ) );
+ $this->assertSame(
+ array(
+ array( TranslationQueue::WORKER_HOOK, array( $result['job_id'], 10 ) ),
+ array( TranslationQueue::WORKER_HOOK, array( $result['job_id'], 20 ) ),
+ ),
+ $cleared
+ );
+ }
+
+ public function test_cancel_returns_false_for_unknown_job(): void {
+ $this->assertFalse( TranslationQueue::cancel( 'unknown' ) );
+ $this->assertNull( TranslationQueue::get_job_status( 'unknown' ) );
+ }
+
+ public function test_transport_detection_prefers_test_override_then_wp_cron(): void {
+ TranslationQueue::set_scheduler_for_testing( static function (): void {} );
+ $this->assertSame( 'test', TranslationQueue::get_transport() );
+
+ TranslationQueue::set_scheduler_for_testing( null );
+ // Action Scheduler functions are not defined in the test environment,
+ // wp_schedule_single_event is stubbed → WP-Cron transport.
+ $this->assertSame( 'wp_cron', TranslationQueue::get_transport() );
+ $this->assertTrue( TranslationQueue::is_available() );
+ }
+}
diff --git a/slytranslate/tests/bootstrap.php b/slytranslate/tests/bootstrap.php
index b745ff7..baa50f7 100644
--- a/slytranslate/tests/bootstrap.php
+++ b/slytranslate/tests/bootstrap.php
@@ -96,6 +96,33 @@ class WP_Post {
public string $post_title = '';
public string $post_content = '';
public string $post_excerpt = '';
+ public int $post_author = 0;
+ public int $post_parent = 0;
+ public string $post_name = '';
+ public string $post_date = '';
+ public string $post_date_gmt = '';
+ public string $post_modified = '';
+ public string $post_modified_gmt = '';
+
+ /** @param array $data */
+ public function __construct( array $data = [] ) {
+ foreach ( $data as $key => $value ) {
+ $this->$key = $value;
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// WP_Term stub class
+// ---------------------------------------------------------------------------
+if ( ! class_exists( 'WP_Term' ) ) {
+ class WP_Term {
+ public int $term_id = 0;
+ public string $name = '';
+ public string $slug = '';
+ public string $taxonomy = '';
+ public string $description = '';
/** @param array $data */
public function __construct( array $data = [] ) {
diff --git a/slytranslate/tests/stubs/wp-stubs.php b/slytranslate/tests/stubs/wp-stubs.php
index 116ec7f..f50afd4 100644
--- a/slytranslate/tests/stubs/wp-stubs.php
+++ b/slytranslate/tests/stubs/wp-stubs.php
@@ -199,6 +199,12 @@ function register_setting( ...$args ): void {
} );
}
+function add_options_page( ...$args ) {
+ return slytranslate_test_call_override( __FUNCTION__, $args, static function () {
+ return 'settings_page_slytranslate';
+ } );
+}
+
function wp_strip_all_tags( $string, bool $remove_breaks = false ): string {
$string = preg_replace( '@<(script|style)[^>]*?>.*?\\1>@si', '', (string) $string );
$string = strip_tags( $string );
@@ -685,3 +691,226 @@ function acf_get_field( $field ) {
return false;
} );
}
+
+function acf_render_field_setting( ...$args ): void {
+ slytranslate_test_call_override( __FUNCTION__, $args, static function () {
+ return null;
+ } );
+}
+
+function acf_get_options_pages() {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return array();
+ } );
+}
+
+function acf_get_field_groups( $filter = array() ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return array();
+ } );
+}
+
+function acf_get_fields( $group ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return array();
+ } );
+}
+
+function get_field( $selector, $post_id = false, $format_value = true ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return null;
+ } );
+}
+
+function update_field( $selector, $value, $post_id = false ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return true;
+ } );
+}
+
+// -----------------------------------------------------------------------
+// Meta Box / Pods stubs
+// -----------------------------------------------------------------------
+
+function rwmb_get_field_settings( $field_id, $args = array(), $post_id = null ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return false;
+ } );
+}
+
+function pods_api() {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return null;
+ } );
+}
+
+// -----------------------------------------------------------------------
+// Term / taxonomy stubs
+// -----------------------------------------------------------------------
+
+function get_term( $term, $taxonomy = '' ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return null;
+ } );
+}
+
+function get_terms( $args = array() ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return array();
+ } );
+}
+
+function wp_insert_term( $term, $taxonomy, $args = array() ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return new WP_Error( 'wp_insert_term_not_mocked', 'wp_insert_term was not mocked in this test.' );
+ } );
+}
+
+function get_object_taxonomies( $object_type, $output = 'names' ): array {
+ $result = slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return array();
+ } );
+ return is_array( $result ) ? $result : array();
+}
+
+function wp_get_object_terms( $object_ids, $taxonomies, $args = array() ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return array();
+ } );
+}
+
+function wp_set_object_terms( $object_id, $terms, $taxonomy, $append = false ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return array();
+ } );
+}
+
+function sanitize_title( $title, $fallback_title = '', $context = 'save' ): string {
+ return (string) slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function ( $title ) {
+ $slug = strtolower( trim( (string) $title ) );
+ $slug = preg_replace( '/[^a-z0-9]+/', '-', $slug );
+ return trim( (string) $slug, '-' );
+ } );
+}
+
+function wp_unique_post_slug( $slug, $post_id, $post_status, $post_type, $post_parent ): string {
+ return (string) slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function ( $slug ) {
+ return (string) $slug;
+ } );
+}
+
+function get_post_field( $field, $post = null, $context = 'display' ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return '';
+ } );
+}
+
+function wp_insert_post( $postarr, $wp_error = false, $fire_after_hooks = true ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return 0;
+ } );
+}
+
+function get_post_thumbnail_id( $post = null ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return 0;
+ } );
+}
+
+// -----------------------------------------------------------------------
+// Cron / scheduling stubs (Action Scheduler functions are intentionally NOT
+// stubbed so function_exists() based transport detection stays realistic)
+// -----------------------------------------------------------------------
+
+function wp_schedule_single_event( $timestamp, $hook, $args = array(), $wp_error = false ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return true;
+ } );
+}
+
+function wp_clear_scheduled_hook( $hook, $args = array(), $wp_error = false ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return 0;
+ } );
+}
+
+function wp_set_current_user( $id, $name = '' ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return null;
+ } );
+}
+
+// -----------------------------------------------------------------------
+// Polylang term / media stubs
+// -----------------------------------------------------------------------
+
+function pll_get_term( $term_id, $lang = '' ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return 0;
+ } );
+}
+
+function pll_set_term_language( $term_id, $lang ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return true;
+ } );
+}
+
+function pll_save_term_translations( $translations ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return true;
+ } );
+}
+
+function pll_get_term_translations( $term_id ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return array();
+ } );
+}
+
+function pll_is_translated_taxonomy( $taxonomy ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return true;
+ } );
+}
+
+function pll_default_language( $field = 'slug' ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return 'en';
+ } );
+}
+
+function pll_get_option( $option ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return true;
+ } );
+}
+
+function pll_get_post_language( $post_id, $field = 'slug' ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return false;
+ } );
+}
+
+function pll_get_post( $post_id, $lang = '' ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return 0;
+ } );
+}
+
+// -----------------------------------------------------------------------
+// Block parsing stubs
+// -----------------------------------------------------------------------
+
+function get_post_type( $post = null ) {
+ return slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return 'post';
+ } );
+}
+
+function parse_blocks( string $content ): array {
+ $result = slytranslate_test_call_override( __FUNCTION__, func_get_args(), static function () {
+ return array();
+ } );
+ return is_array( $result ) ? $result : array();
+}