diff --git a/pontoon/machinery/tests/test_composed.py b/pontoon/machinery/tests/test_composed.py new file mode 100644 index 0000000000..73087bde13 --- /dev/null +++ b/pontoon/machinery/tests/test_composed.py @@ -0,0 +1,250 @@ +import json + +from textwrap import dedent +from unittest.mock import patch + +import pytest + +from django.urls import reverse + +from pontoon.test.factories import ( + EntityFactory, + ResourceFactory, + TranslationMemoryFactory, +) + + +@pytest.fixture +def fluent_resource(project_a): + return ResourceFactory(project=project_a, path="resource.ftl", format="fluent") + + +@pytest.mark.django_db +def test_composed_bad_request(client, locale_a): + """Missing or invalid params should return 400.""" + url = reverse("pontoon.machinery_composed") + + response = client.get(url) + assert response.status_code == 400 + + response = client.get(url, {"entity": "not-a-number", "locale": locale_a.code}) + assert response.status_code == 400 + + response = client.get(url, {"entity": "999999999", "locale": locale_a.code}) + assert response.status_code == 400 + + +@pytest.mark.django_db +def test_composed_unsupported_format(client, entity_a, locale_a): + """Non-composable formats (e.g. gettext-without-MF2-context: still allowed) skip cleanly. + + `entity_a` uses the `resource_a` gettext fixture, which IS in COMPOSED_FORMATS, + so we should NOT get an empty response for it. Use a DTD fixture instead — DTD + is not in the composable set, so we expect an empty `{}`. + """ + dtd_resource = ResourceFactory( + project=entity_a.resource.project, path="r.dtd", format="dtd" + ) + dtd_entity = EntityFactory(resource=dtd_resource, string="Hello") + + url = reverse("pontoon.machinery_composed") + response = client.get( + url, + { + "entity": str(dtd_entity.pk), + "locale": locale_a.code, + "service": "translation-memory", + }, + ) + assert response.status_code == 200 + assert json.loads(response.content) == {} + + +@pytest.mark.django_db +def test_composed_unknown_service(client, fluent_resource, locale_a): + fluent_entity = EntityFactory(resource=fluent_resource, string="hello = Hello\n") + url = reverse("pontoon.machinery_composed") + response = client.get( + url, + { + "entity": str(fluent_entity.pk), + "locale": locale_a.code, + "service": "bogus", + }, + ) + assert response.status_code == 400 + + +@pytest.mark.django_db +def test_composed_mt_service_requires_auth( + client, fluent_resource, google_translate_locale +): + """MT services require authentication; TM-only is anonymous-friendly.""" + fluent_entity = EntityFactory(resource=fluent_resource, string="hello = Hello\n") + url = reverse("pontoon.machinery_composed") + response = client.get( + url, + { + "entity": str(fluent_entity.pk), + "locale": google_translate_locale.code, + "service": "google-translate", + }, + ) + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_composed_tm_only_full_hit(client, fluent_resource, entity_a, locale_a): + """When every leaf has a TM hit, TM-only returns a composed Fluent string.""" + fluent_string = dedent( + """ + button = Click Me + .title = Tooltip text + """ + ) + fluent_entity = EntityFactory(resource=fluent_resource, string=fluent_string) + + TranslationMemoryFactory.create( + entity=entity_a, source="Click Me", target="TM_value", locale=locale_a + ) + TranslationMemoryFactory.create( + entity=entity_a, + source="Tooltip text", + target="TM_tooltip", + locale=locale_a, + ) + + url = reverse("pontoon.machinery_composed") + response = client.get( + url, + { + "entity": str(fluent_entity.pk), + "locale": locale_a.code, + "service": "translation-memory", + }, + ) + assert response.status_code == 200 + body = json.loads(response.content) + assert body["original"] == fluent_string + assert "TM_value" in body["translation"] + assert "TM_tooltip" in body["translation"] + assert body["sources"] == ["translation-memory"] + # Every leaf is a 100% TM match, so the composed result is a full TM match. + assert body["quality"] == 100 + + +@pytest.mark.django_db +def test_composed_tm_only_partial_returns_empty( + client, fluent_resource, entity_a, locale_a +): + """TM-only mode emits no result when any leaf misses TM.""" + fluent_string = dedent( + """ + button = Click Me + .title = Tooltip text + """ + ) + fluent_entity = EntityFactory(resource=fluent_resource, string=fluent_string) + + # Only one of the two leaves has a TM match. + TranslationMemoryFactory.create( + entity=entity_a, source="Click Me", target="TM_value", locale=locale_a + ) + + url = reverse("pontoon.machinery_composed") + response = client.get( + url, + { + "entity": str(fluent_entity.pk), + "locale": locale_a.code, + "service": "translation-memory", + }, + ) + assert response.status_code == 200 + assert json.loads(response.content) == {} + + +@pytest.mark.django_db +def test_composed_tm_excludes_current_entity(client, fluent_resource, locale_a): + """TM matches belonging to the composed entity itself are excluded. + + Once the entity is translated its leaves become TM entries; like regular TM + matches, those must not be suggested back, so a TM-only composition that + relies solely on them produces no result. + """ + fluent_string = dedent( + """ + button = Click Me + .title = Tooltip text + """ + ) + fluent_entity = EntityFactory(resource=fluent_resource, string=fluent_string) + + # Both leaves only match TM entries that belong to this same entity. + TranslationMemoryFactory.create( + entity=fluent_entity, source="Click Me", target="TM_value", locale=locale_a + ) + TranslationMemoryFactory.create( + entity=fluent_entity, + source="Tooltip text", + target="TM_tooltip", + locale=locale_a, + ) + + url = reverse("pontoon.machinery_composed") + response = client.get( + url, + { + "entity": str(fluent_entity.pk), + "locale": locale_a.code, + "service": "translation-memory", + }, + ) + assert response.status_code == 200 + assert json.loads(response.content) == {} + + +@patch("pontoon.machinery.views.get_google_translate_data") +@pytest.mark.django_db +def test_composed_hybrid_tm_and_mt( + gt_mock, + member, + fluent_resource, + entity_a, + google_translate_locale, + google_translate_api_key, +): + """TM hit for one leaf, MT fallback for the other — `sources` reflects the mix.""" + gt_mock.return_value = "MT_tooltip" + + fluent_string = dedent( + """ + button = Click Me + .title = Tooltip text + """ + ) + fluent_entity = EntityFactory(resource=fluent_resource, string=fluent_string) + + TranslationMemoryFactory.create( + entity=entity_a, + source="Click Me", + target="TM_value", + locale=google_translate_locale, + ) + + url = reverse("pontoon.machinery_composed") + response = member.client.get( + url, + { + "entity": str(fluent_entity.pk), + "locale": google_translate_locale.code, + "service": "google-translate", + }, + ) + assert response.status_code == 200 + body = json.loads(response.content) + assert "TM_value" in body["translation"] + assert "MT_tooltip" in body["translation"] + assert set(body["sources"]) == {"translation-memory", "google-translate"} + # MT-assisted results have no meaningful aggregate quality score. + assert "quality" not in body diff --git a/pontoon/machinery/urls.py b/pontoon/machinery/urls.py index 60e5f0f8a3..a296274ad5 100644 --- a/pontoon/machinery/urls.py +++ b/pontoon/machinery/urls.py @@ -10,6 +10,11 @@ views.translation_memory, name="pontoon.translation_memory", ), + path( + "machinery-composed/", + views.machinery_composed, + name="pontoon.machinery_composed", + ), path( "concordance-search/", views.concordance_search, diff --git a/pontoon/machinery/views.py b/pontoon/machinery/views.py index 36d77cff1b..c02f6e379b 100644 --- a/pontoon/machinery/views.py +++ b/pontoon/machinery/views.py @@ -16,18 +16,51 @@ from django.utils.html import strip_tags from django.views.decorators.http import require_POST -from pontoon.base.models import Comment, Entity, Locale, Project, Translation +from pontoon.base.models import Comment, Entity, Locale, Project, Resource, Translation from pontoon.machinery.utils import ( get_concordance_search_data, get_google_translate_data, get_microsoft_translator_data, get_translation_memory_data, ) +from pontoon.pretranslation.pretranslate import Pretranslation from pontoon.terminology.models import Term from .openai_service import OpenAIService +# Map machinery `service` query param to the (mt_provider, locale-support attr) pair +# used by the composed-translation view. `translation-memory` is special-cased to mean +# "TM-only, no MT fallback". +COMPOSED_MT_SERVICES = { + "google-translate": ( + lambda text, locale, preserve_placeables: get_google_translate_data( + text=text, locale=locale, preserve_placeables=preserve_placeables + ), + "google_translate_code", + ), + "microsoft-translator": ( + lambda text, locale, preserve_placeables: get_microsoft_translator_data( + text, locale.ms_translator_code + ), + "ms_translator_code", + ), +} + + +# Formats whose entities can have multiple translatable leaves (Fluent attributes, +# MF2 selector variants). These are the formats Pretranslation handles structurally; +# other formats fall through to single-leaf behavior and don't need a composed result. +COMPOSED_FORMATS = { + Resource.Format.FLUENT, + Resource.Format.ANDROID, + Resource.Format.GETTEXT, + Resource.Format.WEBEXT, + Resource.Format.XCODE, + Resource.Format.XLIFF, +} + + log = logging.getLogger(__name__) @@ -63,6 +96,102 @@ def translation_memory(request): return JsonResponse(data, safe=False) +def machinery_composed(request): + """ + Return a composed multi-value translation for a Fluent / MF2 entity. + + Each translatable leaf (Fluent value/attribute, MF2 variant) is looked up in + Translation Memory; leaves without a 100% TM match fall back to the requested + MT service. Mirrors the Pretranslation pipeline so the Machinery panel can + surface a directly-pasteable composed translation alongside the per-leaf + results. + + Query params: + entity: Entity pk + locale: Locale code + service: one of `translation-memory`, `google-translate`, + `microsoft-translator`. Defaults to `google-translate`. + `translation-memory` disables MT fallback. + """ + try: + entity_pk = int(request.GET["entity"]) + locale = Locale.objects.get(code=request.GET["locale"]) + service = request.GET.get("service", "google-translate") + entity = Entity.objects.select_related("resource").get(pk=entity_pk) + except ( + Entity.DoesNotExist, + Locale.DoesNotExist, + MultiValueDictKeyError, + ValueError, + ) as e: + return JsonResponse( + {"status": False, "message": f"Bad Request: {e}"}, status=400 + ) + + if entity.resource.format not in COMPOSED_FORMATS: + return JsonResponse({}) + + if service == "translation-memory": + mt_provider = None + mt_service_name = "tm" + mt_supported = False + elif service in COMPOSED_MT_SERVICES: + if not request.user.is_authenticated: + return JsonResponse( + {"status": False, "message": "Authentication required"}, status=403 + ) + mt_provider, locale_attr = COMPOSED_MT_SERVICES[service] + mt_service_name = service + mt_supported = bool(getattr(locale, locale_attr, None)) + else: + return JsonResponse( + {"status": False, "message": f"Bad Request: unknown service `{service}`"}, + status=400, + ) + + try: + pt = Pretranslation( + entity, + locale, + preserve_placeables=False, + mt_provider=mt_provider, + mt_service_name=mt_service_name, + mt_supported=mt_supported, + exclude_entity=True, + ) + translation = pt.walk_entity() + except ValueError: + # Raised when a leaf has no TM match and MT is unavailable. Compose + # endpoint treats this as "nothing to show" rather than an error. + return JsonResponse({}) + except Exception as e: + return _machinery_error_response(f"Composed machinery ({service})", e) + + if not pt.services or translation == entity.string: + return JsonResponse({}) + + # Preserve insertion order while deduplicating. Map the internal `"tm"` + # identifier to the SourceType the frontend uses for the badge. + sources_used = list( + dict.fromkeys("translation-memory" if s == "tm" else s for s in pt.services) + ) + + response = { + "original": entity.string, + "translation": translation, + "sources": sources_used, + } + + # When every leaf came from a 100% TM match (`pattern()` only accepts exact + # source matches from TM), the composed string is a complete TM match — give + # it the same quality badge regular TM matches get. Hybrid results that fall + # back to MT for any leaf have no meaningful aggregate score. + if set(pt.services) == {"tm"}: + response["quality"] = 100 + + return JsonResponse(response) + + def concordance_search(request): """Search for translations in the internal translations memory.""" try: diff --git a/pontoon/pretranslation/pretranslate.py b/pontoon/pretranslation/pretranslate.py index 06ce069e0e..40de81db0e 100644 --- a/pontoon/pretranslation/pretranslate.py +++ b/pontoon/pretranslation/pretranslate.py @@ -1,6 +1,6 @@ from copy import deepcopy from re import compile -from typing import Literal +from typing import Callable, Literal from fluent.syntax import FluentSerializer, ast as FTL from fluent.syntax.serializer import serialize_expression @@ -28,6 +28,9 @@ pt_placeholder = compile(r"{ *\$(\d+) *}") +MTProvider = Callable[..., str] + + def get_pretranslation( entity: Entity, locale: Locale, preserve_placeables: bool = False ) -> tuple[str, Literal["gt", "tm"]]: @@ -42,39 +45,8 @@ def get_pretranslation( - a pretranslation of the entity - a pretranslation service identifier, either "gt" or "tm" """ - pt = Pretranslation(entity, locale, preserve_placeables) - if entity.resource.format == Resource.Format.FLUENT: - entry = fluent_parse_entry(entity.string, with_linepos=False) - if entry.value: - pt.message(entry.value) - accesskeys: list[tuple[str, Message]] = [] - for key, prop in entry.properties.items(): - if key.endswith("accesskey"): - accesskeys.append((key, prop)) - else: - pt.message(prop) - for key, prop in accesskeys: - set_accesskey(entry, key, prop) - pt_res = FluentSerializer().serialize_entry( - fluent_astify_entry(entry, escape_syntax=False) - ) - else: - if entity.resource.format in { - Resource.Format.ANDROID, - Resource.Format.GETTEXT, - Resource.Format.WEBEXT, - Resource.Format.XCODE, - Resource.Format.XLIFF, - }: - format = Format.mf2 - msg = parse_message(format, entity.string) - else: - format = None - msg = PatternMessage([entity.string]) - pt.message(msg) - pt_res = serialize_message(format, msg) - + pt_res = pt.walk_entity() pt_service = max(set(pt.services), key=pt.services.count) if pt.services else "tm" return (pt_res, pt_service) @@ -83,10 +55,36 @@ class Pretranslation: format: Format | None locale: Locale preserve_placeables: bool - services: list[Literal["gt", "tm"]] + services: list[str] source: str - def __init__(self, entity: Entity, locale: Locale, preserve_placeables: bool): + def __init__( + self, + entity: Entity, + locale: Locale, + preserve_placeables: bool, + *, + mt_provider: MTProvider | None = None, + mt_service_name: str = "gt", + mt_supported: bool | None = None, + exclude_entity: bool = False, + ): + """ + :param mt_provider: Callable invoked when no 100% TM match exists. + Signature: ``(text: str, locale: Locale, preserve_placeables: bool) -> str``. + Defaults to Google Translate. + :param mt_service_name: Identifier recorded in ``self.services`` for each + successful MT call. Defaults to ``"gt"``. + :param mt_supported: If False, MT is skipped and ``ValueError`` is raised + when a leaf can't be served from TM. Defaults to whether the locale + has a ``google_translate_code`` (matching the original behavior). + :param exclude_entity: If True, the entity's own TM entries are excluded + from per-leaf TM lookups, matching ``get_translation_memory_data``. + A leaf that can only be served by the entity's own translation then + has no TM match, so a composed result is not reconstructed from the + current entity. Defaults to False. + """ + self.entity = entity match entity.resource.format: case Resource.Format.FLUENT: self.format = Format.fluent @@ -104,6 +102,58 @@ def __init__(self, entity: Entity, locale: Locale, preserve_placeables: bool): self.locale = locale self.preserve_placeables = preserve_placeables self.services = [] + self.mt_provider = mt_provider or get_google_translate_data + self.mt_service_name = mt_service_name + self.mt_supported = ( + mt_supported + if mt_supported is not None + else bool(locale.google_translate_code) + ) + self.exclude_entity = exclude_entity + + def walk_entity(self) -> str: + """ + Walk the entity, translating each leaf via TM (then MT fallback), and + return the composed translation string. + + For Fluent, each value, attribute, and selector variant is translated + independently and recomposed. Accesskey attributes are derived from the + translated label after all other leaves are filled. For MF2-handled + formats (Android, Gettext, Webext, Xcode, Xliff), variants are walked + with plural-category handling. All other formats are treated as a + single leaf. + """ + entity = self.entity + if entity.resource.format == Resource.Format.FLUENT: + entry = fluent_parse_entry(entity.string, with_linepos=False) + if entry.value: + self.message(entry.value) + accesskeys: list[tuple[str, Message]] = [] + for key, prop in entry.properties.items(): + if key.endswith("accesskey"): + accesskeys.append((key, prop)) + else: + self.message(prop) + for key, prop in accesskeys: + set_accesskey(entry, key, prop) + return FluentSerializer().serialize_entry( + fluent_astify_entry(entry, escape_syntax=False) + ) + + if entity.resource.format in { + Resource.Format.ANDROID, + Resource.Format.GETTEXT, + Resource.Format.WEBEXT, + Resource.Format.XCODE, + Resource.Format.XLIFF, + }: + format = Format.mf2 + msg = parse_message(format, entity.string) + else: + format = None + msg = PatternMessage([entity.string]) + self.message(msg) + return serialize_message(format, msg) def message(self, msg: Message) -> None: """Modifies `msg`.""" @@ -163,11 +213,14 @@ def pattern(self, pattern: Pattern) -> Pattern: ) if not tm_source or tm_source.isspace(): return pattern - tm_q100 = list( - TranslationMemoryEntry.objects.filter( - locale=self.locale, source=tm_source - ).values_list("target", flat=True) + tm_entries = TranslationMemoryEntry.objects.filter( + locale=self.locale, source=tm_source ) + if self.exclude_entity: + # Mirror get_translation_memory_data(): never suggest the entity's + # own translation back to itself. + tm_entries = tm_entries.exclude(entity=self.entity) + tm_q100 = list(tm_entries.values_list("target", flat=True)) if tm_q100: tm_best = max(set(tm_q100), key=tm_q100.count) self.services.append("tm") @@ -195,14 +248,14 @@ def pattern(self, pattern: Pattern) -> Pattern: if not has_text: return pattern - if self.locale.google_translate_code: - # Try to fetch from Google Translate - gt_translation = get_google_translate_data( + if self.mt_supported: + # Try to fetch from the configured MT provider (Google by default) + mt_translation = self.mt_provider( text=gt_source, locale=self.locale, preserve_placeables=self.preserve_placeables, ) - self.services.append("gt") + self.services.append(self.mt_service_name) return [ el if idx % 2 == 0 @@ -211,7 +264,7 @@ def pattern(self, pattern: Pattern) -> Pattern: if int(el) < len(placeholders) else "{$" + el + "}" ) - for idx, el in enumerate(pt_placeholder.split(gt_translation)) + for idx, el in enumerate(pt_placeholder.split(mt_translation)) if el != "" ] diff --git a/translate/src/api/machinery.test.ts b/translate/src/api/machinery.test.ts index df518c0d3f..bfdd131ec6 100644 --- a/translate/src/api/machinery.test.ts +++ b/translate/src/api/machinery.test.ts @@ -1,4 +1,6 @@ -import { fetchGPTTransform } from './machinery'; +import type { Locale } from '~/context/Locale'; + +import { fetchComposedMachinery, fetchGPTTransform } from './machinery'; import * as base from './utils/base'; vi.mock('./utils/base', () => ({ @@ -56,3 +58,86 @@ describe('fetchGPTTransform', () => { expect(result).toEqual([]); }); }); + +describe('fetchComposedMachinery', () => { + const GET = vi.mocked(base.GET); + const locale = { code: 'fr' } as Locale; + + it('sends entity, locale, and service params', async () => { + GET.mockResolvedValueOnce({ + original: 'src', + translation: 'composed', + sources: ['translation-memory'], + }); + + await fetchComposedMachinery(42, locale, 'translation-memory'); + + const [url, params] = GET.mock.calls[0] as [string, URLSearchParams]; + expect(url).toBe('/machinery-composed/'); + expect(params.get('entity')).toBe('42'); + expect(params.get('locale')).toBe('fr'); + expect(params.get('service')).toBe('translation-memory'); + }); + + it('returns a MachineryTranslation with the response sources', async () => { + GET.mockResolvedValueOnce({ + original: 'Click Me\n .title = Tip', + translation: 'composed-result', + sources: ['translation-memory', 'google-translate'], + }); + + const result = await fetchComposedMachinery(1, locale, 'google-translate'); + + expect(result).toEqual([ + { + sources: ['translation-memory', 'google-translate'], + original: 'Click Me\n .title = Tip', + translation: 'composed-result', + composed: true, + }, + ]); + }); + + it('passes through the quality of a full TM match', async () => { + GET.mockResolvedValueOnce({ + original: 'Click Me\n .title = Tip', + translation: 'composed-result', + sources: ['translation-memory'], + quality: 100, + }); + + const result = await fetchComposedMachinery( + 1, + locale, + 'translation-memory', + ); + + expect(result[0].quality).toBe(100); + expect(result[0].composed).toBe(true); + }); + + it('returns empty array when response is empty', async () => { + GET.mockResolvedValueOnce({}); + const result = await fetchComposedMachinery( + 1, + locale, + 'translation-memory', + ); + expect(result).toEqual([]); + }); + + it('falls back to the requested service when sources is missing', async () => { + GET.mockResolvedValueOnce({ + original: 'src', + translation: 'composed', + }); + + const result = await fetchComposedMachinery( + 1, + locale, + 'microsoft-translator', + ); + + expect(result[0].sources).toEqual(['microsoft-translator']); + }); +}); diff --git a/translate/src/api/machinery.ts b/translate/src/api/machinery.ts index 1e25c82027..16630b8932 100644 --- a/translate/src/api/machinery.ts +++ b/translate/src/api/machinery.ts @@ -22,6 +22,10 @@ export type MachineryTranslation = { original: string; translation: string; quality?: number; + // Set for `/machinery-composed/` results, whose `original` and `translation` + // are full entry sources (Fluent attributes, MF2 variants) rather than plain + // strings — the Machinery panel renders these in a rich, multi-field view. + composed?: boolean; projects?: { name: string; slug: string; @@ -131,6 +135,51 @@ export async function fetchTranslationMemory( : []; } +/** + * Return a composed multi-value translation for a Fluent / MF2 entity. + * + * Each translatable leaf (Fluent value/attribute, MF2 variant) is looked up + * in Translation Memory, falling back to the requested MT service when no + * exact TM match exists. Use `service: 'translation-memory'` to disable the + * MT fallback and only emit a result when every leaf has a TM hit. + * + * Returns an empty array when the entity isn't a composable format or when + * no composed translation can be produced (e.g. MT unavailable for locale). + */ +export async function fetchComposedMachinery( + pk: number, + locale: Locale, + service: 'translation-memory' | 'google-translate' | 'microsoft-translator', +): Promise { + const url = '/machinery-composed/'; + const params = { + entity: String(pk), + locale: locale.code, + service, + }; + + const result = (await GET_(url, params)) as { + original?: string; + translation?: string; + sources?: string[]; + quality?: number; + }; + + if (!result || !result.translation || !result.original) { + return []; + } + + return [ + { + sources: (result.sources ?? [service]) as SourceType[], + original: result.original, + translation: result.translation, + quality: result.quality, + composed: true, + }, + ]; +} + /** * Return translation by Google Translate. */ diff --git a/translate/src/context/Editor.test.jsx b/translate/src/context/Editor.test.jsx index 75a391b705..231fcceccf 100644 --- a/translate/src/context/Editor.test.jsx +++ b/translate/src/context/Editor.test.jsx @@ -482,6 +482,50 @@ describe('', () => { }); }); + it('distributes a composed helper entry across all fields', () => { + let editor, result, actions; + const Spy = () => { + editor = useContext(EditorData); + result = useContext(EditorResult); + actions = useContext(EditorActions); + return null; + }; + mountSpy(Spy, 'fluent', `key = VALUE\n .title = TITLE\n`); + + const source = ftl` + key = COMPOSED + .title = COMPOSED_TITLE + `; + act(() => + actions.setEditorFromHelpers(source, ['translation-memory'], true, true), + ); + + // The full entry source is spread across the value and attribute fields, + // not dumped into the first field. + expect(editor).toMatchObject({ + sourceView: false, + fields: [ + { + handle: { current: { value: 'COMPOSED' } }, + name: '', + }, + { + handle: { current: { value: 'COMPOSED_TITLE' } }, + name: 'title', + }, + ], + machinery: { manual: true, sources: ['translation-memory'] }, + }); + // The editor result is the rebuilt entry, with the composed value and + // attribute distributed into their respective patterns. + expect(result).toEqual({ + format: 'fluent', + id: 'key', + value: ['COMPOSED'], + attributes: new Map([['title', ['COMPOSED_TITLE']]]), + }); + }); + it('toggles Fluent source view', () => { let editor, result, actions; const Spy = () => { diff --git a/translate/src/context/Editor.tsx b/translate/src/context/Editor.tsx index 55c099f3cb..3eb96d0d0f 100644 --- a/translate/src/context/Editor.tsx +++ b/translate/src/context/Editor.tsx @@ -16,6 +16,7 @@ import { editSource, requiresSourceView, getEmptyMessageEntry, + getPlainMessage, MessageEntry, parseEntry, serializeEntry, @@ -94,11 +95,17 @@ export type EditorActions = { /** If `format: 'fluent'`, must be called with the source of a full entry */ setEditorFromHistory(value: string): void; - /** @param manual Set `true` when value set due to direct user action */ + /** + * @param manual Set `true` when value set due to direct user action + * @param entry Set `true` when `value` is the source of a full entry (e.g. a + * composed Machinery suggestion) that should be parsed and distributed + * across all fields rather than inserted into the focused field. + */ setEditorFromHelpers( value: string, sources: SourceType[], manual: boolean, + entry?: boolean, ): void; setEditorSelection(content: string): void; @@ -173,6 +180,50 @@ export function EditorProvider({ children }: { children: React.ReactElement }) { if (readonly) { return initEditorActions; } + + // Parse a full entry source and distribute its leaves across all editor + // fields, falling back to a raw source-view field when it can't be parsed. + // Shared by history restores and composed Machinery copies. + const distributeEntrySource = ( + prev: EditorData, + str: string, + ): EditorData => { + const next = { ...prev }; + if (specialFormats.has(format)) { + const entry = parseEntry(format, str); + if (entry) { + next.base = entry; + } else if (format !== 'fluent') { + return prev; + } + if (entry && !requiresSourceView(entry)) { + next.fields = prev.sourceView + ? editSource(entry) + : editMessageEntry(entry); + } else { + next.fields = editSource(str); + next.sourceView = true; + } + } else { + next.fields = editMessageEntry(prev.initial); + next.fields[0].handle.current.setValue(str); + } + // `next.fields` carry placeholder handles, but the on-screen editors stay + // bound (via their React key) to `prev.fields`' live handles. EditField + // only re-syncs when its `defaultValue` string changes, so re-applying a + // value that equals a stale `defaultValue` — e.g. restoring a composed + // suggestion after editing one field — would otherwise leave that field + // untouched until a second click. Push the values into the live handles + // directly, matching by field id, like `clearEditor` does. + for (const field of next.fields) { + const live = prev.fields.find((f) => f.id === field.id); + live?.handle.current.setValue(field.handle.current.value); + } + next.focusField.current = next.fields[0]; + setResult(buildMessageEntry(next.base, next.fields)); + return next; + }; + return { clearEditor() { setState((state) => { @@ -187,8 +238,24 @@ export function EditorProvider({ children }: { children: React.ReactElement }) { setEditorBusy: (busy) => setState((prev) => (busy === prev.busy ? prev : { ...prev, busy })), - setEditorFromHelpers: (str, sources, manual) => + setEditorFromHelpers: (str, sources, manual, entry) => setState((prev) => { + // Composed suggestions carry a full entry source. Outside source view + // we must parse it and distribute the leaves across all fields rather + // than dumping the raw syntax into the focused field. Record the plain + // message as `machinery.translation` so source attribution still + // matches the saved translation (see `useSendTranslation`). + if (entry && !prev.sourceView) { + const next = distributeEntrySource(prev, str); + return { + ...next, + machinery: { + manual, + translation: getPlainMessage(str, format), + sources, + }, + }; + } const { fields, focusField, sourceView } = prev; const field = focusField.current ?? fields[0]; field.handle.current.setValue(str); @@ -207,32 +274,7 @@ export function EditorProvider({ children }: { children: React.ReactElement }) { }), setEditorFromHistory: (str) => - setState((prev) => { - const next = { ...prev }; - if (specialFormats.has(format)) { - const entry = parseEntry(format, str); - if (entry) { - next.base = entry; - } else if (format !== 'fluent') { - return prev; - } - if (entry && !requiresSourceView(entry)) { - next.fields = prev.sourceView - ? editSource(entry) - : editMessageEntry(entry); - } else { - next.fields = editSource(str); - next.sourceView = true; - } - } else { - next.fields = editMessageEntry(prev.initial); - next.fields[0].handle.current.setValue(str); - } - next.focusField.current = next.fields[0]; - const result = buildMessageEntry(next.base, next.fields); - setResult(result); - return next; - }), + setState((prev) => distributeEntrySource(prev, str)), setEditorSelection: (content) => setState((state) => { diff --git a/translate/src/context/MachineryTranslations.tsx b/translate/src/context/MachineryTranslations.tsx index b4ee81b239..c6a0d4031f 100644 --- a/translate/src/context/MachineryTranslations.tsx +++ b/translate/src/context/MachineryTranslations.tsx @@ -3,6 +3,7 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; import { abortMachineryRequests, fetchCaighdeanTranslation, + fetchComposedMachinery, fetchGoogleTranslation, fetchMicrosoftTranslation, fetchTranslationMemory, @@ -10,7 +11,7 @@ import { } from '~/api/machinery'; import { USER } from '~/modules/user'; import { useAppSelector } from '~/hooks'; -import { getPlainMessage } from '~/utils/message'; +import { editMessageEntry, getPlainMessage, parseEntry } from '~/utils/message'; import { EntityView } from './EntityView'; import { Locale } from './Locale'; @@ -31,10 +32,38 @@ const initTranslations: MachineryTranslations = { export const MachineryTranslations = createContext(initTranslations); -const sortByQuality = ( - { quality: a }: MachineryTranslation, - { quality: b }: MachineryTranslation, -) => (!a ? 1 : !b ? -1 : a > b ? -1 : a < b ? 1 : 0); +// Composed multi-value suggestions always sort to the top, ahead of the +// per-leaf matches; within each group we sort by descending quality. +const sortByQuality = (a: MachineryTranslation, b: MachineryTranslation) => { + if (a.composed !== b.composed) { + return a.composed ? -1 : 1; + } + const { quality: qa } = a; + const { quality: qb } = b; + return !qa ? 1 : !qb ? -1 : qa > qb ? -1 : qa < qb ? 1 : 0; +}; + +// Formats whose entities can have multiple translatable leaves (Fluent +// attributes, MF2 selector variants). For these we request a composed +// multi-value translation in addition to the per-leaf matches. Mirrors +// `COMPOSED_FORMATS` in pontoon/machinery/views.py. +const COMPOSED_FORMATS = new Set([ + 'fluent', + 'android', + 'gettext', + 'webext', + 'xcode', + 'xliff', +]); + +// A composed translation is only meaningful when the entity has more than one +// translatable leaf (Fluent attributes, MF2 selector variants). For a simple +// single-field entity the composed result would just duplicate the per-leaf TM +// or MT match, so we skip the request entirely. +function hasMultipleFields(original: string, format: string): boolean { + const entry = parseEntry(format, original); + return !!entry && editMessageEntry(entry).length > 1; +} export function MachineryProvider({ children, @@ -102,12 +131,28 @@ export function MachineryProvider({ setFetching(true); const promises: Promise[] = []; + // Composed multi-value translations are emitted only for entity-driven + // navigation (not concordance search) and only for formats that can + // have multiple translatable leaves. + const wantsComposed = + !!pk && + COMPOSED_FORMATS.has(format) && + hasMultipleFields(entity.original, format); + if (pk) { promises.push( fetchTranslationMemory(plain, locale, pk).then(addResults), ); } + if (wantsComposed) { + promises.push( + fetchComposedMachinery(pk!, locale, 'translation-memory').then( + addResults, + ), + ); + } + // Only make requests to paid services if user is authenticated if (isAuthenticated) { const root = document.getElementById('root'); @@ -119,12 +164,26 @@ export function MachineryProvider({ if (isGoogleTranslateSupported && locale.googleTranslateCode) { promises.push(fetchGoogleTranslation(plain, locale).then(addResults)); + if (wantsComposed) { + promises.push( + fetchComposedMachinery(pk!, locale, 'google-translate').then( + addResults, + ), + ); + } } if (isMicrosoftTranslatorSupported && locale.msTranslatorCode) { promises.push( fetchMicrosoftTranslation(plain, locale).then(addResults), ); + if (wantsComposed) { + promises.push( + fetchComposedMachinery(pk!, locale, 'microsoft-translator').then( + addResults, + ), + ); + } } } diff --git a/translate/src/modules/machinery/components/MachineryTranslation.css b/translate/src/modules/machinery/components/MachineryTranslation.css index b1c1aba54d..49179313f4 100644 --- a/translate/src/modules/machinery/components/MachineryTranslation.css +++ b/translate/src/modules/machinery/components/MachineryTranslation.css @@ -44,6 +44,15 @@ padding-right: 3px; } +.machinery + .translation + > header + .sources:not(.projects) + > li:not(:first-child)::before { + content: '•'; + padding-right: 3px; +} + .machinery .translation > header div { cursor: default; } @@ -147,3 +156,26 @@ .machinery .translation p.suggestion .fa-spin { color: var(--translation-secondary-color); } + +/* Composed multi-value suggestions: rendered as labeled fields, mirroring the + original string panel (.fluent-rich-string base styles are shared). */ +.machinery .translation table.fluent-rich-string { + padding: 0; + margin: 0; + color: var(--translation-color); +} + +.machinery .translation table.fluent-rich-string.original { + color: var(--translation-secondary-color); + margin-bottom: 4px; +} + +.machinery .translation table.fluent-rich-string td { + vertical-align: top; +} + +.machinery .translation table.fluent-rich-string td > span { + display: block; + line-height: 22px; + white-space: pre-wrap; +} diff --git a/translate/src/modules/machinery/components/MachineryTranslation.test.js b/translate/src/modules/machinery/components/MachineryTranslation.test.js index 58e62b8494..93ef2bcf9c 100644 --- a/translate/src/modules/machinery/components/MachineryTranslation.test.js +++ b/translate/src/modules/machinery/components/MachineryTranslation.test.js @@ -1,3 +1,6 @@ +import React from 'react'; + +import { EntityView } from '~/context/EntityView'; import { createDefaultUser, createReduxStore, @@ -65,4 +68,37 @@ describe('', () => { expect(container.querySelector('.quality')).toBeInTheDocument(); expect(container.querySelector('.quality')).toHaveTextContent('100%'); }); + + it('renders a composed multi-field translation as a rich table', () => { + const translation = { + sources: ['translation-memory'], + composed: true, + quality: 100, + original: 'button = Click Me\n .title = Tooltip\n', + translation: 'button = Cliquez\n .title = Infobulle\n', + }; + const store = createReduxStore(); + const Wrapped = (props) => + React.createElement( + EntityView.Provider, + { value: { entity: { format: 'fluent' } } }, + React.createElement(MachineryTranslationComponent, props), + ); + const { container } = mountComponentWithStore(Wrapped, store, { + translation, + }); + createDefaultUser(store); + + // Each leaf (value + attribute) is shown as a labeled row, on both the + // original and the suggestion side. + const original = container.querySelector('.fluent-rich-string.original'); + const suggestion = container.querySelector( + '.fluent-rich-string.suggestion', + ); + expect(original).toBeInTheDocument(); + expect(suggestion).toBeInTheDocument(); + expect(original.querySelectorAll('tr')).toHaveLength(2); + expect(suggestion.textContent).toContain('Cliquez'); + expect(suggestion.textContent).toContain('Infobulle'); + }); }); diff --git a/translate/src/modules/machinery/components/MachineryTranslation.tsx b/translate/src/modules/machinery/components/MachineryTranslation.tsx index 20aed69f57..dc5fe3d9e5 100644 --- a/translate/src/modules/machinery/components/MachineryTranslation.tsx +++ b/translate/src/modules/machinery/components/MachineryTranslation.tsx @@ -4,11 +4,17 @@ import React, { useCallback, useContext, useEffect, useRef } from 'react'; import type { MachineryTranslation, SourceType } from '~/api/machinery'; import { logUXAction } from '~/api/uxaction'; -import { EditorActions } from '~/context/Editor'; +import { EditorActions, EditorField } from '~/context/Editor'; +import { EntityView } from '~/context/EntityView'; import { HelperSelection } from '~/context/HelperSelection'; import { Locale } from '~/context/Locale'; import { GenericTranslation } from '~/modules/translation'; import { useReadonlyEditor } from '~/hooks/useReadonlyEditor'; +import { + editMessageEntry, + parseEntry, + requiresSourceView, +} from '~/utils/message'; import { ConcordanceSearch } from './ConcordanceSearch'; import { MachineryTranslationSource } from './MachineryTranslationSource'; @@ -51,7 +57,10 @@ export function MachineryTranslationComponent({ const sources: SourceType[] = llmTranslation ? ['gpt-transform'] : translation.sources; - setEditorFromHelpers(content, sources, true); + // A composed suggestion is a full entry source (the LLM transform output + // is a plain string, so it isn't), to be spread across all fields. + const isEntry = !llmTranslation && !!translation.composed; + setEditorFromHelpers(content, sources, true, isEntry); if (llmTranslation) { logUXAction('LLM Translation Copied', 'LLM Feature Adoption', { action: 'Copy LLM Translation', @@ -110,10 +119,22 @@ function MachineryTranslationSuggestion({ translation: MachineryTranslation; }) { const { code, direction, script } = useContext(Locale); + const { entity } = useContext(EntityView); const getLLMTranslationState = useLLMTranslation(); const { llmTranslation, loading } = getLLMTranslationState(translation); + // Composed suggestions carry full entry sources (Fluent attributes, MF2 + // variants). Render them as labeled fields — the same representation as the + // original string panel — instead of a single raw block of syntax. + const originalFields = translation.composed + ? richFields(entity.format, translation.original) + : null; + const suggestionFields = translation.composed + ? richFields(entity.format, translation.translation) + : null; + const isRich = originalFields !== null && suggestionFields !== null; + return ( <>
@@ -123,30 +144,104 @@ function MachineryTranslationSuggestion({
-

- -

-

- {loading ? ( - - ) : ( - + + - )} -

+ + ) : ( + <> +

+ +

+

+ {loading ? ( + + ) : ( + + )} +

+ + )} ); } + +/** + * Parse a composed entry source into its editable fields, or return `null` when + * it can't be shown as a rich multi-field view (parse error, source-view-only + * entry, or a single field — in which case the plain rendering is used). + */ +function richFields(format: string, content: string): EditorField[] | null { + const entry = parseEntry(format, content); + if (!entry || requiresSourceView(entry)) { + return null; + } + const fields = editMessageEntry(entry); + return fields.length > 1 ? fields : null; +} + +/** Render a parsed message as a labeled table, mirroring the original string panel. */ +function RichMessage({ + className, + fields, + dir, + lang, + script, +}: { + className: string; + fields: EditorField[]; + dir?: string; + lang?: string; + script?: string; +}): React.ReactElement<'table'> { + return ( + + + {fields.map(({ handle, id, labels }) => ( + + + + + ))} + +
+ + + + + +
+ ); +} diff --git a/translate/src/modules/machinery/components/source/GoogleTranslation.tsx b/translate/src/modules/machinery/components/source/GoogleTranslation.tsx index f3b35018da..02c3b39a36 100644 --- a/translate/src/modules/machinery/components/source/GoogleTranslation.tsx +++ b/translate/src/modules/machinery/components/source/GoogleTranslation.tsx @@ -67,7 +67,11 @@ export function GoogleTranslation({ ); - return isOpenAIChatGPTSupported ? ( + // AI refinement isn't supported for composed (multi-field/plural) + // suggestions: the backend refines a single string, so it can't preserve the + // entry structure, and the rich rendering can't show the refined result. Show + // the plain source label instead. See follow-up to add composed support. + return isOpenAIChatGPTSupported && !translation.composed ? (