Skip to content

Auto-translate on copy (consolidated #106 + downstream UI fixes, rebased on main) - #115

Merged
LukeTowers merged 39 commits into
mainfrom
wip/auto-translate-copy
Aug 21, 2026
Merged

Auto-translate on copy (consolidated #106 + downstream UI fixes, rebased on main)#115
LukeTowers merged 39 commits into
mainfrom
wip/auto-translate-copy

Conversation

@LukeTowers

@LukeTowers LukeTowers commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

Consolidates the auto-translate on copy work into a single branch rebased cleanly onto the latest main, so it can be reviewed as a coherent unit. This supersedes:

All original commits and authorship are preserved. The branch is main + feature commits, with no unrelated changes.

What it does

Adds optional machine translation when copying content from one locale to another on a multilingual backend field:

  • A provider abstraction (providers/): a TranslationProvider interface + ProviderFactory, with Google Cloud Translation and DeepL implementations.
  • A copy → translate flow: each locale in a field's language selector now has an inline copy button. Choosing it seeds the current locale's input from that locale's value; when a provider is configured, a small popup lets the editor pick a translation method (None / Google / DeepL) and the value is auto-translated via an AJAX round-trip.
  • Integrated across text, textarea, markdown, rich editor, mediafinder, repeater, nested form, and blocks widgets (MLAutoTranslate trait + per-widget wiring).
  • Per-locale translation status indicators so editors can see, at a glance, which locales of a field are translated versus still empty.
  • A backend settings page for configuring the provider API keys (see below), in addition to env/config.

Provider keys are read server-side only. The providers call Laravel's Http client, which requires guzzlehttp/guzzle (not shipped by stock Winter) — hence the added dependency.

Translation Providers settings page (new)

Keys no longer have to live only in env/config. Settings → Translation Providers provides a guided screen:

  • A tab per provider pairing step-by-step setup instructions (with direct links to the Google Cloud Console / DeepL account) against a masked API-key field.
  • DeepL adds a Free / Pro plan selector, which picks the correct API endpoint.
  • An honest status callout: a provider already configured via the environment shows as configured with a blank field (the env secret is never pulled into the form or duplicated into the settings record) — you only enter a key to override it.
  • Values entered here take precedence over env/config; anything left blank falls back to env/config, so existing env-based installs keep working untouched.
  • Gated by a new winter.translate.manage_settings permission.
60-provider-settings-google 61-provider-settings-deepl

Behavior notes

  • Invisible by default: with no provider key set (via either method), the copy action stays a plain one-click copy — the translation-method popup only appears once a usable provider exists. Providers without a key are filtered out of the picker entirely.
  • Smart default: when exactly one provider is configured it is pre-selected in the popup; with several configured the popup defaults to None so translation stays a deliberate choice.
  • Overwrite is guarded: copying into a locale that already has a value prompts a styled confirmation before overwriting
63-styled-confirm - **Per-field opt-in for nested widgets:** inside repeater / nested form / blocks, only sub-fields declared `translatable: true` are machine-translated; everything else (images, switches, numbers, …) is copied verbatim. If no sub-field opts in, the widget is still copied between locales — the values simply aren't translated. - Proven in production by @AIC-BV (language-country locales like `nl-BE` / `fr-BE`), which is where the two UI fixes in IsaiahPaget#1 came from.

UI: inline copy in the language selector

The copy action was moved into the language selector rather than living in a separate always-visible button/dropdown:

  • Each locale row shows a trailing copy icon ("Copy from :locale"), disabled for the current locale and for locales with no value to copy.
  • The current locale is faintly highlighted in the list.
  • This retires the old standalone copy button and its separate dropdown (and the tall-field click-blocking that came with it — the mediafinder-overlay case @AIC-BV described).
Screenshot 2026-08-21 at 1 58 40 PM 05-copy-translate-popup

Translation status indicators

Editors previously had no way to see which locales of a field were already translated versus still empty — a long-standing gap in both this plugin and its RainLab upstream (cf. rainlab/translate-plugin#301, #462, #542). This adds an at-a-glance indicator, driven entirely client-side from the per-locale values the ML controls already render (no backend or model changes):

  • Each locale in a field's switcher shows a status dot: default/source (grey), translated (green), or empty / falls back to default (dashed outline).
  • The field's locale button gains an amber marker whenever any non-default locale is still untranslated, so gaps are visible without opening every field.
  • Refreshes live on edit, locale switch, copy, and auto-translate.

Applies to all ML widgets, since they share the same locale selector. Live-as-you-type on scalar fields; composite widgets reflect the saved state (refreshed on copy / switch / save).

Testing

  • tests/unit/traits/MLAutoTranslateTest.php covers provider resolution and the translation / whitelist helpers (including repeater-group and tab-organized nested-form field collection).
  • Manual: verified plugin boot + load on latest main; the settings page verified end-to-end in the backend (guided tabs render, keys save and round-trip, env-configured keys surface as configured via environment, config bridge resolves saved keys with env fallback, frontend/console boots unaffected).

Credits

Feature by @IsaiahPaget (#106), funded and production-tested by @AIC-BV, with UI fixes from IsaiahPaget#1. Consolidated/rebased, with the settings page, inline-copy redesign and status indicators added on top.

Isaiah Paget and others added 9 commits August 19, 2026 16:35
better errors using, select instead of radio group

fix the richeditor

Fixed mediafinder issue, updated config, updated the way whitelist is
defined
I wasn't using proper popups before and this resulted in the copy button
submitting forms that it was inside, which would close the file upload
popup and relation popup, and just generally cause issues.
Co-authored-by: Meindert <89913092+AIC-BV@users.noreply.github.com>
…king

The .ml-btn had a fixed 44px width sized for two-letter locale codes;
codes like nl-BE overflowed. The button now shrink-wraps its label
(min-width 44px, so two-letter codes render identically) and
multilingual.js publishes the rendered width as a --ml-btn-width CSS
variable via a ResizeObserver, which the copy dropdown uses to position
itself against the button.

The .ml-dropdown/.ml-copy-dropdown wrappers span the full field height
(height: 100%), so on tall fields (e.g. mediafinder) an invisible strip
swallowed clicks on the underlying UI whenever the dropdown was open.
The wrappers are now pointer-events: none with the button and menu
re-enabled, which also supersedes the height: 3px closed-state hacks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Locale pair as chips in the modal title, a real label on the provider
select, and a cancel button (reusing backend::lang.form.cancel). Moves
the modal-footer out of modal-body where it was accidentally nested.
Drops two dead rules: .lang-code-display (scoped under
.field-multilingual, which never matches the body-level popup) and
.ml-modal label (class applied nowhere).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The flag shipped with the copy feature (#99) but was only discoverable
by reading _locale_copy.htm. No behavior change; adds a
TRANSLATE_DISABLE_COPY env hook consistent with the other entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

This comment has been minimized.

@LukeTowers

LukeTowers commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

✅ Status update — most of this is now implemented

The items below have been addressed on the branch (all commits CI-green on PHP 8.1/8.2/8.3). This header supersedes the original review that follows.

Implemented

  • Security: popup escaping + posted-locale validation (reflected-XSS) — ad8f246
  • disable_copy init crash, response-guard ordering, noConflict/olda1e62ae
  • JS dedup: shared applyAutoTranslateResponse helper — 89f1a9f
  • Auto-hide UX (the "happy medium") — stock install keeps the original one-click copy; provider picker only appears once a key is set — 1092563
  • Providers: abstract base, batch chunking, DeepL locale mapping, HTTP timeouts, non-leaky errors, config-driven factory, DeepL enabled — ba96352
  • Batch-integrity validation + guzzle floor bump — c596591
  • urldecode corruption, "0" translatable, _provider cast, whitelist no-throw — across the above
  • Docs (README) — bef2884; tests (48 green) — 9717d56

Corrected — false positives, verified working live

  • "None copies nothing to text/textarea" → plain copy works.
  • "Repeater/nested doesn't re-render" → renders fine (see 06-repeater-translated.png).

Deferred (needs your call)

  • Guided provider-setup Settings screen — docs shipped; the backend screen is deferred (it would store API keys in the DB).
  • Cosmetic-churn revert in MLControl — low value.

On "disabled by default" (Marc / Isaiah): the auto-hide is the better realization of Marc's clutter concern — nothing new renders until a provider key is configured, and plain copy is untouched. disable_copy stays an opt-in flag defaulting to false (copy on by default); it is not the clutter mechanism and nothing is disabled out of the box.

Original review (for history) # Maintainer review — path to merge

Reviewed the consolidated feature across five lenses (functionality, the two production regressions, security, UX, and structure/staleness). Overall: the feature is sound and production-proven, and rebasing onto main already cleared two would-be blockers. What remains is one security fix, a UX "default-clutter" decision, and a maintainability cleanup pass. Grouped below with a checklist.

✅ Resolved by the rebase (was flagged on #106, gone here)

  • Stale reverts — the ThemeScanner refactor (+its 200-line test), Message-model centralization, Plugin.php SEO fix, version.yaml releases, and CI matrix are all untouched now (they were artifacts of a 13-commit-stale base).
  • PHP floor inconsistency — kept at >=8.1, so the provider classes' typed properties / arrow functions are fine (they would have fataled under the branch's attempted >=7.2).
  • Dead lang keys rendering rawcopy_confirm / copy_from_label are restored (union merge), so partials referencing them no longer print raw i18n keys.

🔴 Blockers (must fix before merge)

1. Reflected XSS in the translation-method popup. traits/mlcontrol/partials/_translation_method_popup.htm:55 echoes data-selected-locale="<?= $copy_from_locale ?>" unescaped, fed by unvalidated post('_copy_from_locale') (traits/MLControl.php onShowTranslationMethodSelector). Fix: e()-escape it (and $providerName on the option, ~L39–40), and validate _copy_from_locale/_current_locale against Locale::listAvailable() before use/echo. The header chips (L7/L9) are already escaped. No other critical security issues: no SSRF (provider host is config, not user input), no secret exposure, provider factory can't instantiate arbitrary classes, handlers are behind backend auth.

2. Confirm the fileupload-caption regression is actually fixed. The Jan-2026 "can't save fileupload useCaption captions" bug shares the same full-height overlay mechanism as the relations-Save bug, which AIC's pointer-events:none commit fixes — but it isn't handled explicitly in plugin code. Needs one manual re-test (a useCaption:true fileupload standalone and nested in an ML repeater/nestedform). The relations-Save regression is confirmed fixed.

🟡 UX — the "keep it easy, un-clutter the default" decision

Today, copying a locale went from a 1-click action to dropdown → item → modal → select → Copy (a modal + AJAX round-trip), and the provider selector renders even when no API key is configured (default config ships Google with an empty key → a non-functional option). This is the "clunky by default" concern.

Recommendation — auto-hide the auto-translate UI when no usable provider is configured:

  • Filter providers to those with a non-empty key in MLControl::prepareLocaleVars; pass the usable count to the partials/JS.
  • In multilingual.js, when 0 usable providers exist, skip the popup and do the original one-click copy; only open the method popup when ≥1 provider is configured.
  • Fix the whitelist so it doesn't throw on an empty default (translate-all nested leaves by default, or switch to a blacklist) — right now translating inside a repeater/nestedform errors out until an admin pre-populates autoTranslateWhiteList.

Net: a stock install sees zero new clutter and the original copy behaviour (satisfies "disabled by default"); the moment a key is set, the picker lights up (satisfies "easy to reach"). Optionally add an explicit enable_auto_translate flag on top. This is a better lever than the current disable_copy, which nukes plain copy entirely.

Current vs. recommended (visuals):

📎 Screenshots are in translate-pr-screenshots/ at the repo root.

Current (clunky) state — attach: 01-method-popup-current.png
The method modal appears for a plain copy, and lists "Google" even though no API key is configured.
‹ attach 01-method-popup-current.png here ›

Recommended stateto be captured after the auto-hide UX is built
‹ attach: default install shows the plain copy dropdown, no modal ›

🟢 Quality / maintainability (before or shortly after merge)

  • De-duplicate the per-widget JS. mltext.js/mltextarea.js/mlrepeater.js/mlnestedform.js/mlblocks.js repeat near-identical onCopyLocale/onAutoTranslateSuccess boilerplate — every fix must be applied 5–8×. Extract a shared base (or centralize in multilingual.js, which already owns autoTranslate) with widgets supplying only the selector/value-setter.
  • Make the provider factory config-driven. ProviderFactory hardcodes a google/deepl switch (with a dead break after return) and duplicates the config lookup that MLAutoTranslate::getProviderConfig() also does (the latter looks unused). Map provider→class in config so adding a provider is config-only.
  • Harden the outbound calls. Add an HTTP timeout; guard the response shape before dereferencing $json['data']['translations'] (Google) / $json['translations'] (DeepL) — an unexpected 200 body currently throws TypeError. Add a JS error handler so a failed translation surfaces to the user instead of silently leaving the field unchanged. Replace raw $response->body() in provider exceptions with a generic message (log detail server-side).
  • Drop guzzlehttp/guzzle: ^7.10 from require. The providers use the Http facade (already provided by the framework); pinning Guzzle in the plugin risks host-app constraint conflicts.
  • Normalize the "none" sentinel. It's represented as '' in three places; post('_provider') returning null could slip past !== '' guards in the widget onCopyItemLocale. Use a real constant.
  • Remove now-dead data-copy-confirm attributes / cosmetic churn. The confirm moved into the popup, so those attributes are unused; and MLControl.php carries unrelated brace/whitespace reformatting that inflates the diff.

Verification checklist

  • Re-test fileupload useCaption save (standalone + nested)
  • Run the plugin test suite on this branch
  • Manual UX pass across mltext / mltextarea / mlrepeater / mlnestedform / mlmediafinder with a real Google key and nl-BE/fr-BE/en
  • Confirm auto-hide behaviour once implemented

This is the concrete "revisions needed" list (per @IsaiahPaget's question to @mjauvin) and the cleanup pass @LukeTowers offered @AIC-BV in December — now scoped against a clean base.


Additional findings — consolidated from Copilot + CodeRabbit

Their inline comments on this PR have been triaged and resolved/hidden; the worthwhile items are folded in here so this stays the single active review.

Functional (High)

  • Locale-code mapping. Winter locale codes like nl-BE / fr-BE are not valid Google/DeepL language codes, so translation requests for exactly the locales this feature is used with in production will error. Map Winter codes to provider-supported codes — strip the region for the source, and for DeepL only pass regional variants where the target supports them. (CodeRabbit + Copilot ×2)
  • "None" copies nothing to text/textarea. CORRECTION — false positive (verified working). copyLocale() writes the value to the active field + placeholder before autoTranslate() no-ops for "None", so plain copy works. Tested live: nl-BE went from empty → the copied text with method "None". No change needed.
  • disable_copy: true breaks widget init. With copy disabled the partial omits $copyBtn/$copyDropdown, but init() always calls updateLayout(), which dereferences those null controls and throws — so the documented "off switch" prevents MLText/MLTextarea from initializing. Guard the optional copy controls in updateLayout. (CodeRabbit + Copilot)

Correctness (Medium)

  • Drop urldecode() on translated text. GoogleTranslateProvider does urldecode(html_entity_decode(...)); the urldecode corrupts literal percent sequences (20%2520%, A%2FBA/B). Keep only html_entity_decode; add a test with a literal %/URL. (CodeRabbit + Copilot; verified)
  • Chunk provider batches. Whitelisted repeater/block values are sent in a single provider call, but Google/DeepL cap items per request; large widgets fail wholesale. Chunk per provider limits and preserve order before expanding. (Copilot)
  • Validate the AJAX response before indexing. The success handlers read data.translatedValue[0] (and rely on translatedLocale) before checking they exist — a malformed/empty response throws client-side. Validate first. (CodeRabbit) — extends the "add a JS error handler / guard response shape" item above.
  • Treat "0" as translatable. The empty-content check rejects the string "0", so a field containing 0 can't be copied/translated. Check explicitly for null/''. (CodeRabbit)

Minor

  • old is never declared in mltext.js, so the noConflict() path throws ReferenceError — capture the previous plugin value like the sibling widgets do. (Copilot) — a symptom of the copy-paste JS duplication noted above.
  • DeepL is advertised as a built-in provider but its config block is commented out, so it can't be selected without editing the published config. Enable it or don't advertise it. (Copilot)

Corroborated (already covered above, now double-confirmed): the XSS on $copy_from_locale, hiding the provider UI when no usable credentials, and the null _providerTypeError were each independently flagged by both bots.

Flagged but not actioned:

  • Google API key on query-string vs POST body (Copilot): the key is currently in the POST body, which was AIC's deliberate, production-tested fix for Google's ~8KB request-line limit on long fields. Copilot's "move it to the query string" would reintroduce that limit; verify against the real endpoint before changing rather than accept blindly.
  • Compiled-CSS top:28px specificity (CodeRabbit): low/unclear — the repeater/nested-form open-menu rules already override with higher specificity.

Provider-key setup is undiscoverable (new — UX/onboarding)

To use this feature a user must know it exists, obtain a Google Cloud / DeepL key with no in-product link or instructions, SSH in to edit .env/config.php (and un-comment the DeepL block), and set TRANSLATE_PROVIDER. There is no backend UI, no Settings screen, no README mention — the only "documentation" is a runtime exception when a translate fails. Contrast LukeTowers.EasyFacebookFeed, which walks a user through the entire credential dance in-backend (numbered help callouts, deep links to the provider console, dependsOn-driven dynamic URLs, type: sensitive fields, a "you're done" step).

Recommendation — do both:

  • (a) Documentation (cheap, do regardless): a "Machine Translation" section in the README + enriched config/config.php comments covering how to get a Google key (enable Cloud Translation API → create a restricted key) and a DeepL key, the exact env vars, and the note to un-comment the DeepL provider. Fold into the docs PR.
  • (b) Guided backend setup screen (matches the "painless" bar): a System.Behaviors.SettingsModel (TranslateSetting) registered in Plugin.php alongside Locales/Messages, with a fields.yaml that has a provider dropdown + per-provider url/key (type: sensitive, trigger/dependsOn to show only the selected provider) + help partials like EasyFacebookFeed, and an onTestProvider "Test connection" button that runs a live sample translation and flashes success/the API error (turning today's runtime exceptions into an explicit affordance). Wire ProviderFactory::create() / MLAutoTranslate::getProviderConfig() to read the settings model first, falling back to config/env.
    • Security: gate behind manage_locales, type: sensitive, keep env/config as the recommended production path, and consider encrypting the stored key (these are billing-exposed org keys).

Screenshots for this are a TODO — they can only be captured once the setup screen (b) exists.
‹ attach: guided provider-setup Settings screen — after (b) is built ›


Repeater/nested translate doesn't re-render — CORRECTION: false positive

Verified working. Auto-translating a translatable repeater renders the translated rows correctly (row shows heading = "Onze missie", body = translated text; field-repeater-item present and visible). My earlier "doesn't re-render" finding was a measurement error — the DOM inspection queried .repeater-item when the actual class is .field-repeater-item, and the screenshot had scrolled away. No fix needed; onCopyItemLocalereprocessLocaleItems → partial re-render behaves the same as the working onSwitchItemLocale.

coderabbitai[bot]

This comment was marked as outdated.

This comment was marked as outdated.

…d guzzle dependency

The providers use Laravel's Http client which requires guzzlehttp/guzzle
(not shipped by a stock Winter/Laravel install), so guzzle is a genuine
dependency. Adding it forces a full composer re-resolve in CI (testing
against Winter develop), which the stable-only >=1.2.8 constraint could
not satisfy against the dev-develop modules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LukeTowers and others added 4 commits August 19, 2026 18:42
- Escape the provider name and the copy-from locale in the method popup,
  and validate posted locales against Locale::listAvailable() before use
  (fixes a reflected-XSS via _copy_from_locale).
- GoogleTranslateProvider: drop urldecode() (it corrupted literal percent
  sequences; Google returns HTML-entity-encoded text) and guard the
  response shape.
- Treat a literal "0" as translatable content instead of empty.
- Cast the posted _provider to string so a missing provider is treated as
  "none" instead of passing null to a string-typed translate().
- Allow guzzlehttp/guzzle ^8.0 in addition to ^7.10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Guard the (optional) copy controls in MLTextarea.updateLayout so the widget
  still initializes when disable_copy is set (was throwing 'Cannot read
  properties of null').
- Validate the auto-translate response before indexing translatedValue[0].
- Declare the previous plugin value so mlText.noConflict() doesn't ReferenceError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add a shared $.wn.translate.applyAutoTranslateResponse() helper in
  multilingual.js and route mltext/mltextarea/mlmarkdowneditor/mlricheditor
  through it, removing the repeated success-handling and fixing the markdown/
  rich editors that indexed translatedValue[0] before validating the response.
- Fix mlnestedform's noConflict typos (mlNEstedForm / $.fn.MLNestedForm).

Scoped intentionally: the remaining per-widget code (froala/ace/mediafinder/
repeater init, dispose and updateLayout) is genuinely widget-specific and the
plugin-registration scaffolding is standard Winter widget boilerplate, so a
full base-class rewrite would add risk without meaningful dedup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The happy medium between 'disabled by default' and 'easy to reach':
- MLControl only offers providers that actually have an API key, and the
  copy dropdown omits the method-popup handler when none are usable, so the
  frontend falls back to the original one-click copy (no modal, no clutter)
  on a stock install. Configure a key and the method picker appears.
- Fall back to the 'none' default when the configured default provider has
  no key.
- autoTranslateArray no longer throws when nothing is whitelisted; it keeps
  the plain copy (nested translation stays opt-in via autoTranslateWhiteList).
- Escape the copy-locale code and event handler in the partial.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
coderabbitai[bot]

This comment was marked as resolved.

LukeTowers and others added 3 commits August 19, 2026 19:50
…g-driven factory

- Add AbstractTranslationProvider with shared construction and automatic
  request batching (Google 100/req, DeepL 50/req) so large repeater/block
  collections no longer fail wholesale.
- Add per-request HTTP timeouts and guard the DeepL response shape.
- DeepL: normalise locales to the provider's codes (base code for source,
  region only for supported targets like EN-GB/PT-BR) so nl-BE/fr-BE work.
- Provider exceptions report the HTTP status instead of echoing the raw
  upstream body.
- ProviderFactory is now config-driven (providers.<name>.class) with a
  built-in fallback for google/deepl; no request data reaches instantiation.
- Enable the DeepL provider in config (auto-hidden until DEEPL_API_KEY is set)
  and document the class-based provider setup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a README section covering provider setup (Google/DeepL env vars + how
to obtain keys), the opt-in/auto-hidden behaviour, the nested-field
whitelist, and how to add a custom provider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
coderabbitai[bot]

This comment was marked as resolved.

LukeTowers and others added 2 commits August 20, 2026 14:20
Restore the descendant-combinator spacing, blank lines and calc()
spacing that an editor auto-formatter had rewritten, leaving only the
functional PR changes in the diff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Validate each provider batch returns exactly one string per input segment
  before merging, so a partial/malformed response fails loudly instead of
  silently keeping source values via flatten/expand.
- Google/DeepL: require a well-formed translations array (and string
  translatedText) rather than defaulting a missing value to an empty string.
- Bump guzzle floor to patched releases (^7.15.2 || ^8.0.1).
- README: tag the env code fence as dotenv.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
coderabbitai[bot]

This comment was marked as resolved.

Add array/string parameter and array return types (they were already the
effective contract via flatten() and translate()), and cast the posted
locales to string in the composite-widget callers so a missing post value
can't reach the typed boundary as null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@LukeTowers

Copy link
Copy Markdown
Member Author

@AIC-BV can you start using / testing this branch in production and we'll get the rest of the modifications you needed included into this and merged?

LukeTowers and others added 2 commits August 20, 2026 16:45
The defaultProvider / TRANSLATE_PROVIDER config only pre-selected an option in
the translation-method popup; it did nothing else. It earned its keep only when
2+ providers were configured AND the admin had a standing preference, and
pre-selecting a paid provider by default is a mild footgun.

Replace it with a zero-config rule: when exactly one provider is usable it is
pre-selected (an unambiguous default that saves a click); with several, the
picker stays on "None" so translation remains a deliberate choice. Removes the
config key, the TRANSLATE_PROVIDER env var, the validation branch, and the
vestigial test setup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the global autoTranslateWhiteList config with a per-field opt-in: a
composite widget's nested sub-field is machine-translated on copy only when its
field definition declares translatable: true; everything else is copied
verbatim.

This fixes the global whitelist's structural problems (one list bleeding across
every model/widget, and collisions between same-named fields) by declaring
translatability where it belongs — at the field. It also aligns with the
translatable: true convention from #76 / issue #21, so translatability is
expressed once and consistently.

getAutoTranslatableFields() now collects translatable field names by walking the
widget's field definitions: group mode (repeater groups / blocks) and single
form mode (repeater / nestedform), descending into nested forms and stripping
@context suffixes. The autoTranslateWhiteList config key is removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LukeTowers and others added 3 commits August 20, 2026 22:03
Surface which locales are translated directly in each ML field's locale
switcher, so editors can see what still needs translating without opening
every field one by one.

- Each locale in the switcher shows a status dot: default/source (grey),
  translated (green), or empty/falls-back-to-default (dashed outline).
- The field's locale button gains an amber marker whenever any non-default
  locale is still untranslated.
- Driven entirely client-side from the per-locale values the ML controls
  already render (no backend/model changes); refreshes live on edit, locale
  switch, copy, and auto-translate.

Applies to the scalar ML controls (text/textarea/richeditor/markdown/url/
mediafinder) that use the shared locale selector; composite widgets
(repeater/nestedform/blocks) can follow in a later pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Provides a reusable way to answer "how translated is this record": for every
enabled non-default locale it counts how many translatable attributes hold a
value, returning an overall percentage, a fully-complete locale count, and a
per-locale breakdown.

Intended for surfacing translation status outside the edit form — e.g. a
backend list column or badge — complementing the per-field switcher indicators.
Read-only; disabled locales are excluded (they aren't part of the target set).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@AIC-BV

AIC-BV commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

@AIC-BV can you start using / testing this branch in production and we'll get the rest of the modifications you needed included into this and merged?

What do I have to do to be able to translate repeater fields now?
I see you removed the whitelist config

Ok much easier to setup & maintain without the whitelist and with translatable: true
But it was missing support for nested forms which I'm using in my repeater (see PR)

@mjauvin

mjauvin commented Aug 21, 2026

Copy link
Copy Markdown
Member

Has this been tested on tailwindui-plugin with dark theme?

@mjauvin

mjauvin commented Aug 21, 2026

Copy link
Copy Markdown
Member

CSS for tailwindui in darkmode needs love
image

@mjauvin

This comment was marked as resolved.

@mjauvin

This comment was marked as resolved.

LukeTowers and others added 12 commits August 21, 2026 13:38
The per-locale status dots and the untranslated marker were added to the
compiled multilingual.css only, not the multilingual.less source, so recompiling
the bundle would silently drop them. Move them into the .less and regenerate the
compiled .css from source — which also reconciles drift that had accumulated in
the committed CSS (a stale height:100% on the dropdown wrappers and colour-case
differences).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two fixes for multilingual controls under the fancy form layout, where the
tabless header inputs are transparent and taller than a normal input:

- Hide the locale/copy controls while the field is focused. Raising the input's
  z-index only masks them when the input is opaque; the fancy layout's
  transparent input left them showing over the text. The CSS fix here is
  @mjauvin's (Marc Jauvin).
- Size and restyle the controls for the taller fancy inputs: fill the height and
  use light text on a translucent frosted background so they blend into the
  header instead of floating as short pale boxes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the separate always-visible copy button + its own dropdown with a small
copy button on each locale row of the language selector. The button copies that
row's locale value into the currently-active locale (the reverse of switching),
is disabled on the current row, and carries a "Copy from :locale" tooltip.

Benefits: one control instead of two floating over the input (removing the whole
class of overlap/positioning issues), the copy action sits contextually next to
each language, and it pairs with the per-locale status dots — dot + name + copy
per row.

Also adds a browser confirmation before the destructive overwrite: copying only
prompts when the active locale already has content that would be replaced.

Prototype for review. Follow-ups: retire the now-dead .ml-copy-btn/.ml-copy-dropdown
CSS, verify/polish across composite widgets, and consider a styled confirm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Disable a locale row's copy button when that locale has no value to copy from
  (in addition to the active-locale row), so you can't trigger an empty copy.
- Make the copy button a full-height hit area with the padding on the button
  around the icon, rather than a small button surrounded by dead space.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Faintly highlight (subtle background + bold) the row of the locale currently
being edited so it's obvious which one is active in the list.

Also switch the row-targeting rules to descendant selectors: the backend's
dropdown widget rewraps the list (adding li.dropdown-container / first-item
wrappers), which silently broke the direct-child (> li > a) selectors — including
the copy-button spacing rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With the copy action merged into the language selector, the separate .ml-copy-btn
and .ml-copy-dropdown no longer render. Remove all their now-dead CSS (and simplify
the .ml-dropdown rules that special-cased them), and drop the ResizeObserver /
updateActiveButtonWidth --ml-btn-width tracking that only existed to position the
old copy dropdown next to the locale button.

Verified the merged selector still renders and copies correctly across the scalar
and composite (repeater / nested form) widgets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The copy-from action is now a lightweight, contextual control inside the language
selector, so there's no need to hide it. Drop the disable_copy config option
(and TRANSLATE_DISABLE_COPY env) and the per-widget guards it required, including
the now-dead .ml-copy-btn/.ml-copy-dropdown positioning in the textarea, rich
editor, and markdown editor widgets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the native window.confirm() with $.wn.confirm() (the backend's sweet-alert
dialog) for the destructive copy-overwrite warning, falling back to the native
prompt if it's unavailable. Splits copyLocale() into the confirm gate and an
applyCopyLocale() step so the async dialog can proceed on confirm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed nested forms (#118)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Adds a backend Settings screen (System → Settings → Translation Providers)
for configuring the Google Cloud Translation and DeepL API credentials,
so keys no longer have to be set via env/config only.

- Winter\Translate\Models\Setting (SettingsModel): stores the per-provider
  key/endpoint and the DeepL plan, and on boot pushes them into
  winter.translate::providers.* so the existing ProviderFactory and
  getUsableTranslateProviders() resolve them unchanged. Empty fields fall
  back to the file/env config, so an env-configured key keeps working.
- Guided setup UX: a tab per provider pairing step-by-step instructions
  (with direct console links) against masked (sensitive) key inputs, a
  Free/Pro plan selector for DeepL, and an honest status callout that
  reflects when a provider is already configured via the environment.
- Secrets are never pulled into the form: an env key shows as
  "configured via environment" with a blank field rather than being copied
  into the settings record.
- Config is only applied on backend requests (the feature is backend-only)
  and guarded so install/migrate before the settings table exists is a
  no-op.
- New winter.translate.manage_settings permission gating the screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- README: lead the provider setup with the new backend Translation
  Providers screen, keeping env/config as the deployment alternative.
- version.yaml: add 2.4.0 covering the auto-translate-on-copy feature,
  the settings page, the inline copy selector, status indicators and
  nested-field translation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@LukeTowers
LukeTowers requested a balanced review from Copilot August 21, 2026 20:52

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@LukeTowers
LukeTowers merged commit c8281be into main Aug 21, 2026
4 checks passed
@LukeTowers
LukeTowers deleted the wip/auto-translate-copy branch August 21, 2026 20:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants