From 050019da403b5e2a54fa193d818f49c1d5fba29e Mon Sep 17 00:00:00 2001 From: slydlake Date: Sun, 14 Jun 2026 00:55:40 +0200 Subject: [PATCH] feat(acf): ACF deepening, plugin-wide translation features, settings UI (v1.11.0) ACF (acf-vertiefung-plan.md): - New AcfFieldIntrospector shared by all ACF resolvers (AcfMetaResolver becomes a thin consumer; slytranslate_acf_translatable_field_types filter unchanged) - AcfBlockTranslator translates field data stored in acf/* Gutenberg block-comment JSON (attrs.data), including repeater/group sub-keys; client workflow exposes these as acf_block:{path}:{key} units - AcfOptionsTranslationService + new MCP ability ai-translate/translate-options for ACF options-page fields (WPGlobus/WP Multilang inline merge, Polylang via slytranslate_acf_options_post_id filter, TranslatePress not_required) - Structured field types: ACF link fields translate only the title sub-key (slytranslate_meta_value_translation_spec filter) - Meta Box and Pods resolvers (metabox.io / Pods field registries) - New slytranslate_meta_keys_exclude option + per-field "Exclude from AI translation" ACF toggle (slytranslate_exclude) - MetaTranslationService::describe_effective_meta_keys() reports every effective meta key with source/type/exclusion state Plugin-wide features (plugin-features-plan.md, B8-B15): - Taxonomy-term translation (opt-in translate_terms) + bulk MCP ability ai-translate/translate-terms - Image alt texts translated by default, incl. Polylang media duplication (alt/caption/description) - Opt-in slug translation (translate_slugs) for newly created translations - TranslationQueue: background bulk jobs via Action Scheduler or WP-Cron, one post per action; translate-content-bulk/get-progress/ cancel-translation gain job_id support - Auto-translate on publish (consumes the previously unused slytranslate_new_post option), queues draft translations with a _slytranslate_generated loop guard - Glossary / do-not-translate list (slytranslate_glossary) injected into prompts - Diff-based retranslation via shared TranslationFingerprint helper (_slytranslate_unit_hashes / _slytranslate_meta_hashes) - New readonly MCP ability ai-translate/get-translatable-fields Settings UI (settings-ui-plan.md, phases 1-4): - Admin page (Settings -> SlyTranslate): status row, model picker, prompt add-on, glossary editor, meta-field list with per-key exclusion checkboxes and per-post preview, automation toggles, and a collapsed Advanced section (prompt template, context window, direct-API URL + probe, string-table concurrency probe, transport diagnostics) - Saves through the existing ConfigurationService::save() path (same as MCP configure); new REST route ai-translate/probe-string-table-concurrency/run - German (de_DE) translations for the settings page and ACF toggle Bump plugin version to 1.11.0, regenerate language files, update CHANGELOG/readme. --- CHANGELOG.md | 25 + README.md | 37 +- slytranslate/assets/settings-page.js | 584 ++++++++++++++++++ slytranslate/changelog.txt | 25 + slytranslate/inc/AbilityRegistrar.php | 203 +++++- slytranslate/inc/AcfBlockTranslator.php | 389 ++++++++++++ slytranslate/inc/AcfFieldIntrospector.php | 111 ++++ slytranslate/inc/AcfMetaResolver.php | 80 ++- .../inc/AcfOptionsTranslationService.php | 267 ++++++++ .../inc/AutoPublishTranslationService.php | 94 +++ .../inc/ClientTranslationWorkflowService.php | 26 +- slytranslate/inc/ConfigurationService.php | 17 + slytranslate/inc/ContentTranslator.php | 9 + slytranslate/inc/GlossaryService.php | 137 ++++ slytranslate/inc/MediaTranslationService.php | 121 ++++ slytranslate/inc/MetaBoxMetaResolver.php | 63 ++ slytranslate/inc/MetaTranslationService.php | 232 ++++++- slytranslate/inc/Plugin.php | 8 +- slytranslate/inc/PodsMetaResolver.php | 112 ++++ slytranslate/inc/PolylangAdapter.php | 55 +- slytranslate/inc/PostTranslationService.php | 103 ++- slytranslate/inc/Settings.php | 4 + slytranslate/inc/SettingsPage.php | 232 +++++++ slytranslate/inc/TermTranslationService.php | 235 +++++++ slytranslate/inc/TranslationFingerprint.php | 206 ++++++ slytranslate/inc/TranslationQueue.php | 287 +++++++++ slytranslate/inc/TranslationRuntime.php | 11 +- slytranslate/inc/WpMultilangAdapter.php | 11 + slytranslate/inc/WpglobusAdapter.php | 11 + slytranslate/languages/slytranslate-de_DE.mo | Bin 18936 -> 24653 bytes slytranslate/languages/slytranslate-de_DE.po | 284 +++++++++ slytranslate/languages/slytranslate.pot | 284 +++++++++ slytranslate/readme.txt | 52 +- slytranslate/slytranslate.php | 106 +++- .../tests/Unit/AbilityRegistrationTest.php | 138 ++++- .../tests/Unit/AcfBlockTranslatorTest.php | 323 ++++++++++ .../tests/Unit/AcfFieldIntrospectorTest.php | 81 +++ .../tests/Unit/AcfMetaResolverTest.php | 78 +++ .../Unit/AcfOptionsTranslationServiceTest.php | 237 +++++++ .../AutoPublishTranslationServiceTest.php | 130 ++++ .../Unit/BackgroundBulkTranslationTest.php | 138 +++++ .../tests/Unit/DiffRetranslationTest.php | 119 ++++ .../Unit/EditorRestRouteRegistrationTest.php | 3 + .../tests/Unit/GetTranslatableFieldsTest.php | 87 +++ .../tests/Unit/GlossaryServiceTest.php | 92 +++ .../tests/Unit/HookRegistrationTest.php | 47 ++ .../Unit/MediaTranslationServiceTest.php | 138 +++++ .../tests/Unit/MetaBoxMetaResolverTest.php | 89 +++ .../tests/Unit/MetaKeyExclusionTest.php | 154 +++++ .../tests/Unit/MetaKeyRuntimeFilterTest.php | 5 +- slytranslate/tests/Unit/MetaValueSpecTest.php | 139 +++++ .../tests/Unit/PodsMetaResolverTest.php | 98 +++ .../Unit/PolylangCreateTranslationTest.php | 193 ++++++ slytranslate/tests/Unit/SettingsPageTest.php | 139 +++++ .../tests/Unit/TermTranslationServiceTest.php | 197 ++++++ .../tests/Unit/TranslationFingerprintTest.php | 141 +++++ .../tests/Unit/TranslationQueueTest.php | 138 +++++ slytranslate/tests/bootstrap.php | 27 + slytranslate/tests/stubs/wp-stubs.php | 229 +++++++ 59 files changed, 7522 insertions(+), 59 deletions(-) create mode 100644 slytranslate/assets/settings-page.js create mode 100644 slytranslate/inc/AcfBlockTranslator.php create mode 100644 slytranslate/inc/AcfFieldIntrospector.php create mode 100644 slytranslate/inc/AcfOptionsTranslationService.php create mode 100644 slytranslate/inc/AutoPublishTranslationService.php create mode 100644 slytranslate/inc/GlossaryService.php create mode 100644 slytranslate/inc/MediaTranslationService.php create mode 100644 slytranslate/inc/MetaBoxMetaResolver.php create mode 100644 slytranslate/inc/PodsMetaResolver.php create mode 100644 slytranslate/inc/SettingsPage.php create mode 100644 slytranslate/inc/TermTranslationService.php create mode 100644 slytranslate/inc/TranslationFingerprint.php create mode 100644 slytranslate/inc/TranslationQueue.php create mode 100644 slytranslate/tests/Unit/AcfBlockTranslatorTest.php create mode 100644 slytranslate/tests/Unit/AcfFieldIntrospectorTest.php create mode 100644 slytranslate/tests/Unit/AcfOptionsTranslationServiceTest.php create mode 100644 slytranslate/tests/Unit/AutoPublishTranslationServiceTest.php create mode 100644 slytranslate/tests/Unit/BackgroundBulkTranslationTest.php create mode 100644 slytranslate/tests/Unit/DiffRetranslationTest.php create mode 100644 slytranslate/tests/Unit/GetTranslatableFieldsTest.php create mode 100644 slytranslate/tests/Unit/GlossaryServiceTest.php create mode 100644 slytranslate/tests/Unit/MediaTranslationServiceTest.php create mode 100644 slytranslate/tests/Unit/MetaBoxMetaResolverTest.php create mode 100644 slytranslate/tests/Unit/MetaKeyExclusionTest.php create mode 100644 slytranslate/tests/Unit/MetaValueSpecTest.php create mode 100644 slytranslate/tests/Unit/PodsMetaResolverTest.php create mode 100644 slytranslate/tests/Unit/PolylangCreateTranslationTest.php create mode 100644 slytranslate/tests/Unit/SettingsPageTest.php create mode 100644 slytranslate/tests/Unit/TermTranslationServiceTest.php create mode 100644 slytranslate/tests/Unit/TranslationFingerprintTest.php create mode 100644 slytranslate/tests/Unit/TranslationQueueTest.php 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 9b2fee7cd284e509c362f3df041bfa0609b3fd39..d897fbe6a6b67d8c0f37f79c72558e7924a4d830 100644 GIT binary patch delta 8835 zcmb7{d2}4bnZQfFZ)0Q22R_<%e8|>}eBndJfNV*&@NF3zFu}BDN}5JJJwtboWSe6+ zLb8r+Ff@V9U_{4aPKoG@0YMmQI;Ms+zH&HQS2 z*idReoW_HDp=kUPtb{*>{oyBY0{kq#KY)iUUQLEIum;NeyZA}K4mcbhfrsGJuo|u& zr_?-{hY99achi^)UxNeTA7MZE1(X%_86Rmp9G*dcOx&Lj`BO{zk%=}#dH)h9idm4w zt1IH?hai9IUVhL;JqL?&<4t~K;NL);alFF=Wv zlkjrbpU9O7I-xA!N+=2*gh%0Ha1Oix?H0jnp~Td)ld(T>rhdQ;3Bq5(S+Fl(8*na^ z7uUhF;7<4!yaS5mE}I&;?0P5)?T6^D?u8O$&qGn@CAbH^1#f}t45glg?-yyjPU9vb zPfWIx&=-reLh)>)I?_U8$;e8O}s5{|<@L9;()XuLd zm4thsyjOgL2G&u>p-lV^lpy>JN)UYxWhDbVKdtWGv>r88$%rMfGzU8@Mr$WIY?65BXEK@^cP60hhwRz=d!zjuaEN!6N)K zY=?XCUmkt}MZp~ekbHL(ZigR1sSCC9B3ISJ@iM{=8YrjoP&B?B%E})OU%-b@O#U_$ z6Mq0Dt^NQdX8MuTjIEMTEO8+e(_63$-T<$K%M(g{AHDz&!4CW|rdYwsv4dznX?~Pu z2cfv47JCb~Km%qVB|+_jqQLzzk3lj~y$(gukKs`GIh60kY!WNuAYq^qP%OIz7N^m; znTBLRSIi$m`S4e8G&~9Az5a`$Aes$jzy>H2?u1S7GRU0j8OWddgdcIi#KnW zJ_^r+e}ytpQ*HF#g%Gc*6qJbrxD!4SKkv69vhavv+?WYjtU3#hhnL6vHXK6#0Z8hr z$D!o$TToo{2Ph`*Lv@+}7sK^%BNT=1f)B!@kT_IZRz{Y&8WMl1_yi3CQoRplg~P}} zaX}@N{^Ynn8%hi$q4<0=6ie)a;=27%6ukqAVh_MG;4yd_d>b0@qxgP5B9QTl%Ag@H zu7an-OJcgPKmBjQx8VUO6Yn}Vst*p7w7ne~@B|c>y$8kQed?lM8wX{a8Bi8h1v79y z94GmICyj~RcnZpZKY_%PItiImRS{v*w;R!0*bfYdZ~KYRuL z2vWP${(4dxcEL*cdpLplRsV)4J*L7c`sc-cAIggFgA!zqOTR*?|Ape~L2DuxO^4F2 zhSOkUd_MzaVJ_SU55gDWxV0Q=;5)GR1&s%r;vi#|DfAzQ67_FDS@FkkC!9uZ$wXH{ zndm$4eE2*RpN}N+WWwoiGF%DYg*%}Emu-lG{6Z+fdE*A`Kb^+C+>jV}8On;^gW|)_ zp!lp}V^mNEK~dx^I2GOjhr;7f6nYg7gr7k9{`YVm?7JyCN7TUY($7F~?aL{#+pF`ZHrfrEVpy6`*_rq7=ui!24@Kz2Ta1q78g-789aLTr5GwXyl{bNuR zoq@R|y=$OYYHg8*44i@z{r5mIJDWe-Mg7Pr(WB zEhsMg6pF70emz>iOgN1GawzXLLV52}I2&FG@v1rkaeYypq%nybV=sxg5XwZGpjada zC7rI1?>`E~La)K$@IA;H)Tb~B8}R2s=)+q0FkA*dgd^Y?mqsx%7Y>sAUr1vJ4^}`4 zwnm6$dagmVzKxuNq>un{5Q!66pZmaNO)b!Z#VOY z$EEw>&B#*ZUPP|-NDY!f?ndOg16hg8MIJ&*S0jy?ai=4u#6{^kP->L%McPFPt{n0d zB7T!=IpQJzh+KvAK^7n>?(T@a(k1RnRMxI1|f{=CL^(em=$L(G4AL8~!F_*$8uAh{3%L{Ni zbhw^Iu0xX2;i^M!K?WeB5V>9`t$zxgA;=cwOr&(JqS1nMA@h+dkn550$W4fed=IHa z)*x~nkD#(K_r(Qg!LHfl?eAje5+mx{kS6^Pxo?mU~WLuI~9BrUr3vZ;2&YZh8&3YK4 z;ODY@kYYu>-)qCsokl+A`JTa0`9h0rc^PAl=^1I)Z1qt&{x0VPFAqO+gk}2)%Q4cr z)hyV4@ZZC)Fz0xAGo=%r&YP|&>SwiYhU=?Rt8Hy!Fw0?7mKk<=gQ?0^!0nu;)#{uR zF4#%!G4gJ%MH{X7hxG?nj!4eayM3e6a?-g@BZmoG>}kyLbM4yks*U-^O2aHIuqt?P z#F}EHppng`wXN#4A6d5NrO*nkZZ2!=$rW6qxnUD-PNS)|(?;GdwBh`ualVtv=v2D| zgc(26yHj={tv$o?jqv-jg{(0A3|eC&z0Ni2HWM_&j^|qhQIFDo#`2P?-g40>eCPbF z>y0^^@NivIB9+VLaS}0EZRGM%s8j|2F`_2;-H1UIwZZ&pmE)b9kNu+sm5n10Cl@ht zz2!+1t9l|qHMF)ekJX`TRf8mfry8OKM9LG_W%{ee(BLs7+U%T}HazY7m;kLC%gsZN zg@)^-xm&{19o!R^!Q7GAx;3SYs4>zxLliH~rd5;EVcJ%@6uwEdPU}2bkjj`&8_QTv zq}##6BcC2bH0TaXcLv)|s~=WUh75fuxaqX1(>9n+A<8GUK>{yyp{F+B`0&RLzIWQr zP2%9lt-ZF}$oiP8V7BS6dM4p)+uDwF&ga~+iV^f3b^oHC#Yhc_qCT3uXNs^?h{L6N z_=Iq9cvNN4Apr{>_G;M^#ixWjQA1Gq8Pg9fLU<5z=_pD_{(R1&V$BItd|RCSdX3Iz z&8no)Y<5V9x&_DMqm-F9TP)l1<1|Vt39j%b)HvuK_0CK(Hj}f{+VyIUD;l?M+OTW& zruv4f!X&t2+oq@&+&X&g_|Wv>LZnO>P4#NCBxe|lW6EPO+52E}g${l`diJnv9*^s6 zo=T;={ychgMI`~AjcOSdDW!XDFnG-Ju~||?ZPhqfA}_8mUFVEB&}VRu^MW5&P99VW zf#BDbHxGzvM35P0RmEXycxH!G&E`ywf`M7Im$M6Dsg%miD!SerS6i_quXVbRSIxeO zXG?KOL6tjx!2>RzC;ZU6W!f5ZdK?gy{}$7yOn6nn_z6c&ce836)mR)}T3*-l6K?1~ z@o$&OVBu6fBs8u^;qih4Q)6zX`*&@bIj5pn zQU=XE0>oKKVpNme!YUO$4P!TLnQaapv{IhhA={d7WhtRquS)I6XopQp7MiX$n(aNM zF}2~fTX_jfLO808>4I3Lx7rRXH(l`FtO>!PSx+xWWeQHa;aPjBJ3X~Z_5w$zYZaxh z6b7{_s^MLAvv2QPqynyv_GU_aYxjw^7Sm;OF5PU^8QU-4nk`M5Xv;x;d8>Da?mpof z-FLST-rC<=pq@DF*p7BHrrlz6u>JIG-fCS_LTZI=t98MLbI+U@*DOZ$U9-)|SkB&p zsG^;qa^B3wiwa)%5r403;c}zS>C~1NjXx*a=e5PAdCE)8sf^uy!t>aN8eHDTrG8L5 zFHziKxoJ6ET&8hsxwf9^v@$mp0L?SqN1Qfx_q|DDRnpidhX>0?1p}pBV}t3k1-9rm z*P_xV)ka;=#wsRpyN@tHi?*`HcI|c_vL$0v7Rqk zClL}2iTRvla6$FL1?!^H%ldi8O>F8pJ#71m(*xR61^cQq#nR$R!BIK~@dEbacoc;l z`$~dMD{VA#a7b*94i20*+f5djG%%N8vdGTvBZMvmUS8uIgX1L%#e?OJmysj&Q=v&# zumfl#+Gi7To{(XUG^evDjtI`!e#eY^W3_cw(Bsy12 zaPW>_QvFK@i^^hw{mb?I?i2oAUlCEABq&a3#3@!=c+6<$m{H4(jICX@oz;<9UtXLz zJ9eME*y}2VrErgJN6FMqD*L!S(y2|`OU*Q0CIy^B%8VT!LuAR^XN*%U8q_5YHpMAz z)EFDXHI{|EI^SvQK4G&4$>uQYlH4OkNITr)b3~BfWH%_Z>fnRKJHt6daYiZd09}G- zlAFZKWx7X766N6w(K#hCHW*hkDj{bT)+o8tTNJ{KkriP!DiJ$RRF8t$HJ9yJN0`a! zG!CsE_eA&eu9?bw<)hN!g{gYC09F+j)!?R@Cu?O4tX0Z?YAq%%(}t2M@#pER5$8~8 z`e5b!@lpA5I6O-vM=HnX7)f!bK%kKQ#IH;vBe}X=eA>WihTXxZI62+N#PXDlxVnA$ zWE0kb=6qN|R|Rizf6P(i;N7jZ2K- z4doFRJh0?}!97L_?p!-1XkL2tvQyr{BVKGqKdCO#VZ0Yw?e1f;eHrEJ@A}!&>nko< z6&7Y)aI7|7P^ToaCr73*C&~{>+vNO^IAx7rJ}M$ z*Z-aS`hcz%*XW91W&L1EUl^i)^JtZ*kB(Matv3s;>~b8`!($bjoMB2?WCJA^qD;%n z`N==^loe)Kyg9IChxL`z!-oZz_9M@Fb6wpeJZR{Nj>?TD>*@E_ZuQ#%&h?{D{JhBNcq&pG$z zo_o&koO9vVZ`;1qw&pJc-w^ z77sIh-lZ;}6aR$E@hkiQm*gg9HicPmN#$S>&c@x?h|l6UzK-qrGum{4l2!_*fVx?(C)L7 ze|cbp3-Z8Gl$npC4CE4yVg}QY_6yjK7qK4gzc9OewkCi@&EmCuzYJcUl$zd%+& zeTFQn`k!UqZ2F`<7iC~8P@Z3hvZQ{L;~9-oX`u35&E%G8*A|-N(TRevfB$-mpUfWjPk<0bC?t!#)bG_+#;(|L!O$sFp2~C5w6Bn^iqw7 zQBrswGw=^69ej*3gMy00xptx4_oD2Towx~KwyxhmnMhh?!cr`hU9phL6mE2&?B*Vn znGT|)@JU>Q$596K50n>`%}abYDp68gi?YVen1x%=i6QIyGbovR4JE_B!Qb+Jb(Mg-@D#dcDq~#nDtVZrB6X{F6j_=?#d=^jD z(HS<^lU^Lh4LFb9sj34Qz@MWGtfn#X-KfVR+TECoVU$c9h*EJ-c^FkY!ev zt@gC0#22y@FLB+4hcKU`a}`hG7=D{sEVZ$8ElM4uJ=n_U7z^7H1GsGYZ<$4e5=;LXPfwViS=@u$SXScKS}DG2Ti{ zMyd}JGEi2Cy%yzPFDh)v_y&V`>E|~>$dY)86oSmg%QSvXB1p&{Swl#Ch|PqQVPY#G zr$g3#vaF=S-i)8Rki6eVV+*m3xSzO}7$Bq+5^@^$5(|l&%Y!uhL?5x+y74Gx5b{y$ zA^83%4nX|ll!=o?X1$sykok8~nMJVQ;{V}%06oNg1e-MesdQp5Av=90(M?FXg;-9= zQI+CI5dW7>s`*4ZA>RZk%?U`C$7zJap>V7x`|mb=EVovFnme;3=o|dO3~%M9eBZ$Nq*+Ey~pYvd_;q!|q@t;5PlCpfh5+ z&4EZ&Rh14GZPGJ~U)0x&bM#QjRlR>ki>@p!i)|^*wCTqji((%-p1397y~}Vmc|3l3 z*d1`*6AtzDoBGMJQGMsEZLuHDT5QWQ-C@&t(>rwi?Bli}eIu_VmVdjEqK{Sd=*-HB zn5XiEl-Sgo5}SUr_Ls3!3l1geA1`Utxl7CSo~4z#XW26S>av4+r^`NNeXlRzHzr;( zg8F?|jb5}2x2Nkzayx_GiMImWWZc2+ zVXjx{ry72zLyhhFO5iaBP*vT>9JJ~{kK)m=_9M3ifvl+h%Im8MeDJFpjYx5HW`Ae<76!9 e?ro|1Xy*=H)-^x&t*$evv2~j_*mUdG-~S)2c 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)[^>]*?>.*?@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(); +}