diff --git a/translate/src/context/Editor.test.jsx b/translate/src/context/Editor.test.jsx index 4bd60a9416..ed045b4e8d 100644 --- a/translate/src/context/Editor.test.jsx +++ b/translate/src/context/Editor.test.jsx @@ -1070,3 +1070,57 @@ describe('', () => { expect(editor.fields.map((f) => f.handle.current.value)).toEqual(['', '']); }); }); + +describe('copying plural variants from another locale', () => { + it('retains target plural fields when copying a plain translation', () => { + let editor, actions; + const Spy = () => { + editor = useContext(EditorData); + actions = useContext(EditorActions); + return null; + }; + const source = + 'key = { $count ->\n [one] One item\n *[other] Other items\n }'; + mountSpy(Spy, 'fluent', undefined, source, { + locale: { code: 'en-US', cldrPlurals: [1, 5] }, + }); + act(() => actions.setEditorFromHistory('key = Copied text', true)); + expect(editor.fields.map((field) => field.labels.at(-1).label)).toEqual([ + 'one', + 'other', + ]); + expect(editor.fields.map((field) => field.handle.current.value)).toEqual([ + '', + 'Copied text', + ]); + act(() => actions.setEditorFromHistory('key = History text')); + expect(editor.fields.map((field) => field.handle.current.value)).toEqual([ + 'History text', + ]); + }); + + it('uses target plural categories', () => { + let editor, actions; + const Spy = () => { + editor = useContext(EditorData); + actions = useContext(EditorActions); + return null; + }; + const source = + 'key = { $count ->\n [one] One item\n *[other] Other items\n }'; + const copied = + 'key = { $count ->\n [one] Russian one\n [few] Russian few\n *[other] Russian other\n }'; + mountSpy(Spy, 'fluent', undefined, source, { + locale: { code: 'en-US', cldrPlurals: [1, 5] }, + }); + act(() => actions.setEditorFromHistory(copied, true)); + expect(editor.fields.map((field) => field.labels.at(-1).label)).toEqual([ + 'one', + 'other', + ]); + expect(editor.fields.map((field) => field.handle.current.value)).toEqual([ + 'Russian one', + 'Russian other', + ]); + }); +}); diff --git a/translate/src/context/Editor.tsx b/translate/src/context/Editor.tsx index 970d85a0a7..1fdd688649 100644 --- a/translate/src/context/Editor.tsx +++ b/translate/src/context/Editor.tsx @@ -22,6 +22,7 @@ import { serializeEntry, } from '~/utils/message'; import { createMessageEntry } from '~/utils/message/createMessageEntry'; +import { copyMessageEntry } from '~/utils/message/copyMessageEntry'; import { hasOuterWhitespace, htmlElementEscapes, @@ -104,7 +105,7 @@ export type EditorActions = { setEditorBusy(busy: boolean): void; /** If `format: 'fluent'`, must be called with the source of a full entry */ - setEditorFromHistory(value: string): void; + setEditorFromHistory(value: string, remapPlurals?: boolean): void; /** * @param manual Set `true` when value set due to direct user action @@ -352,11 +353,14 @@ export function EditorProvider({ children }: { children: React.ReactElement }) { }; }), - setEditorFromHistory: (str) => + setEditorFromHistory: (str, remapPlurals = false) => setState((prev) => { const next = { ...prev, autofilled: null }; if (specialFormats.has(format)) { - const entry = parseEntry(format, str); + let entry = parseEntry(format, str); + if (entry && remapPlurals) { + entry = copyMessageEntry(entry, locale, sourceEntry); + } if (entry) { includeSourceAttributesAndDeclarations(entry, sourceEntry); next.base = entry; diff --git a/translate/src/modules/otherlocales/components/OtherLocaleTranslation.test.jsx b/translate/src/modules/otherlocales/components/OtherLocaleTranslation.test.jsx index 16155e2083..c730734d59 100644 --- a/translate/src/modules/otherlocales/components/OtherLocaleTranslation.test.jsx +++ b/translate/src/modules/otherlocales/components/OtherLocaleTranslation.test.jsx @@ -26,10 +26,10 @@ const MF2_TRANSLATION = { locale: LOCALE, }; -function createTranslation(format, translation, setEditorFromHelpers) { +function createTranslation(format, translation, setEditorFromHistory) { const store = createReduxStore(); const Wrapper = (props) => ( - + @@ -84,9 +84,7 @@ describe('', () => { fireEvent.click(getByRole('listitem')); - expect(spy.mock.calls).toEqual([ - ['Un cheval, un cheval ! Mon royaume pour un cheval !', [], true], - ]); + expect(spy.mock.calls).toEqual([[PLAIN_TRANSLATION.translation, true]]); }); it('sets editor value for a Fluent translation', () => { @@ -95,8 +93,6 @@ describe('', () => { fireEvent.click(container.querySelector('li')); - expect(spy.mock.calls).toEqual([ - ['Un cheval, un cheval ! Mon royaume pour un cheval !', [], true], - ]); + expect(spy.mock.calls).toEqual([[FLUENT_TRANSLATION.translation, true]]); }); }); diff --git a/translate/src/modules/otherlocales/components/OtherLocaleTranslation.tsx b/translate/src/modules/otherlocales/components/OtherLocaleTranslation.tsx index 7c1ac8bacb..0e2c1898aa 100644 --- a/translate/src/modules/otherlocales/components/OtherLocaleTranslation.tsx +++ b/translate/src/modules/otherlocales/components/OtherLocaleTranslation.tsx @@ -33,7 +33,7 @@ export function OtherLocaleTranslationComponent({ parameters: { project, resource, entity }, index, }: Props): React.ReactElement { - const { setEditorFromHelpers } = useContext(EditorActions); + const { setEditorFromHistory } = useContext(EditorActions); const { element, setElement } = useContext(HelperSelection); const isSelected = element === index; @@ -43,9 +43,9 @@ export function OtherLocaleTranslationComponent({ const copyTranslationIntoEditor = useCallback(() => { if (window.getSelection()?.isCollapsed !== false) { setElement(index); - setEditorFromHelpers(plain, [], true); + setEditorFromHistory(translation.translation, true); } - }, [index, setEditorFromHelpers, plain]); + }, [index, setElement, setEditorFromHistory, translation.translation]); const className = classNames( 'translation', diff --git a/translate/src/modules/translationform/components/TranslationForm-multiple.test.jsx b/translate/src/modules/translationform/components/TranslationForm-multiple.test.jsx index b08fda6481..ca26c34cd7 100644 --- a/translate/src/modules/translationform/components/TranslationForm-multiple.test.jsx +++ b/translate/src/modules/translationform/components/TranslationForm-multiple.test.jsx @@ -9,6 +9,10 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'; import { EditorActions, EditorProvider, EditorResult } from '~/context/Editor'; import { EntityView } from '~/context/EntityView'; import { Locale } from '~/context/Locale'; +import { HelperSelection } from '~/context/HelperSelection'; +import { OtherLocaleTranslationComponent } from '~/modules/otherlocales/components/OtherLocaleTranslation'; +import { RECEIVE } from '~/modules/otherlocales/actions'; +import { useHandleCtrlShiftArrow } from '../utils/editFieldShortcuts'; import { createDefaultUser, @@ -26,10 +30,18 @@ const DEFAULT_LOCALE = { cldrPlurals: [1, 5], }; -function mountForm(source, target = null, locale = DEFAULT_LOCALE) { +function mountForm( + source, + target = null, + locale = DEFAULT_LOCALE, + otherLocale, +) { target ??= source; const store = createReduxStore(); createDefaultUser(store); + if (otherLocale) { + store.dispatch({ type: RECEIVE, translations: [otherLocale] }); + } const [id, sourceEntry] = fluentParseEntry(source); const [, targetEntry] = fluentParseEntry(target); @@ -53,6 +65,20 @@ function mountForm(source, target = null, locale = DEFAULT_LOCALE) { result = useContext(EditorResult); return null; }; + const LocaleHelpers = () => { + const handleArrow = useHandleCtrlShiftArrow(); + return ( + <> + + + + ); + }; const wrapper = mountComponentWithStore(() => { const [currentEntity, updateCurrentEntity] = useState(entity); @@ -64,6 +90,13 @@ function mountForm(source, target = null, locale = DEFAULT_LOCALE) { + {otherLocale && ( + + + + )} @@ -86,6 +119,81 @@ describe(' with multiple fields', () => { vi.useFakeTimers(); }); + it.each(['click', 'shortcut'])( + 'copies all locale attributes via %s', + (method) => { + const source = 'title =\n .label = Original\n .accesskey = O'; + const { getResult, wrapper } = mountForm(source, null, DEFAULT_LOCALE, { + translation: 'title =\n .label = Traduction\n .accesskey = T', + locale: { + code: 'fr', + name: 'French', + direction: 'ltr', + script: 'Latn', + }, + }); + fireEvent.click( + method === 'click' + ? wrapper.getByRole('listitem') + : wrapper.getByRole('button', { name: 'Next locale' }), + ); + act(() => { + vi.runAllTimers(); + }); + expect(getResult().attributes).toEqual( + new Map([ + ['label', ['Traduction']], + ['accesskey', ['T']], + ]), + ); + }, + ); + + it.each(['click', 'shortcut'])( + 'copies every locale variant via %s', + (method) => { + const source = ftl` + title = + { $count -> + [one] One item + *[other] Many items + } + `; + const translation = ftl` + title = + { $count -> + [one] Un article + *[other] Plusieurs articles + } + `; + const { wrapper } = mountForm(source, null, DEFAULT_LOCALE, { + translation, + locale: { + code: 'fr', + name: 'French', + direction: 'ltr', + script: 'Latn', + }, + }); + fireEvent.click( + method === 'click' + ? wrapper.getByRole('listitem') + : wrapper.getByRole('button', { name: 'Next locale' }), + ); + act(() => { + vi.runAllTimers(); + }); + const fields = wrapper.container.querySelectorAll( + '.translationform .cm-content', + ); + expect( + Array.from(fields, (field) => + EditorView.findFromDOM(field).state.doc.toString(), + ), + ).toEqual(['Un article', 'Plusieurs articles']); + }, + ); + it('renders textarea for a value and each attribute', () => { const { views } = mountForm(ftl` message = Value diff --git a/translate/src/modules/translationform/utils/editFieldShortcuts.ts b/translate/src/modules/translationform/utils/editFieldShortcuts.ts index 21d9f00d61..2ac0641d65 100644 --- a/translate/src/modules/translationform/utils/editFieldShortcuts.ts +++ b/translate/src/modules/translationform/utils/editFieldShortcuts.ts @@ -1,7 +1,6 @@ import { useContext } from 'react'; import { EditorActions } from '~/context/Editor'; -import { EntityView } from '~/context/EntityView'; import { FailedChecksData } from '~/context/FailedChecksData'; import { HelperSelection } from '~/context/HelperSelection'; import { MachineryTranslations } from '~/context/MachineryTranslations'; @@ -10,7 +9,6 @@ import { UnsavedActions, UnsavedChanges } from '~/context/UnsavedChanges'; import { useLLMTranslation } from '~/context/TranslationContext'; import { Locale } from '~/context/Locale'; import { useAppSelector } from '~/hooks'; -import { getPlainMessage, parseEntry } from '~/utils/message'; import { logUXAction } from '~/api/uxaction'; import { useExistingTranslationGetter } from '../../editor/hooks/useExistingTranslationGetter'; @@ -83,8 +81,7 @@ export function useHandleEscape(): () => boolean { export function useHandleCtrlShiftArrow(): ( key: 'ArrowDown' | 'ArrowUp', ) => boolean { - const { entity } = useContext(EntityView); - const { setEditorFromHelpers, setEditorFromComposed } = + const { setEditorFromHelpers, setEditorFromComposed, setEditorFromHistory } = useContext(EditorActions); const helperSelection = useContext(HelperSelection); const { composed, translations: machineryTranslations } = useContext( @@ -146,12 +143,7 @@ export function useHandleCtrlShiftArrow(): ( } } else { const { translation } = otherLocaleTranslations[nextIdx]; - const entry = parseEntry(entity.format, translation); - setEditorFromHelpers( - entry ? getPlainMessage(entry) : translation, - [], - true, - ); + setEditorFromHistory(translation, true); } return true; }; diff --git a/translate/src/utils/message/copyMessageEntry.test.js b/translate/src/utils/message/copyMessageEntry.test.js new file mode 100644 index 0000000000..e68acd65f7 --- /dev/null +++ b/translate/src/utils/message/copyMessageEntry.test.js @@ -0,0 +1,124 @@ +import { copyMessageEntry } from './copyMessageEntry'; +import { parseEntry } from './parseEntry'; +import { editMessageEntry } from './editMessageEntry'; +import { serializeEntry } from './serializeEntry'; + +const plural = 'key = { $n ->\n [one] ONE\n *[other] OTHER\n }'; + +function values(entry) { + return editMessageEntry(entry).map((field) => field.handle.current.value); +} + +describe('copyMessageEntry', () => { + it.each(['ru', 'uk', 'pl', 'be', 'szl'])( + 'copies the source catchall into the %s default form', + (code) => { + const result = copyMessageEntry(parseEntry('fluent', plural), { code }); + expect(values(result)).toEqual(['ONE', '', 'OTHER']); + }, + ); + + it('keeps missing Slovenian categories empty while copying its catchall', () => { + const result = copyMessageEntry(parseEntry('fluent', plural), { + code: 'sl', + }); + expect(values(result)).toEqual(['ONE', '', '', 'OTHER']); + }); + + it('uses a differently named source catchall when the selector collapses', () => { + const source = parseEntry('fluent', plural.replace('*[other]', '*[many]')); + expect(values(copyMessageEntry(source, { code: 'zh' }))).toEqual(['OTHER']); + }); + + it('keeps non-plural branches when a plural selector collapses', () => { + const source = parseEntry( + 'fluent', + [ + 'key = { $n ->', + ' [one] { PLATFORM() ->', + ' [windows] ONE WINDOWS', + ' *[other] ONE OTHER', + ' }', + ' *[other] { PLATFORM() ->', + ' [windows] MANY WINDOWS', + ' *[other] MANY OTHER', + ' }', + ' }', + ].join('\n'), + ); + const result = copyMessageEntry(source, { code: 'zh' }); + expect(values(result)).toEqual(['MANY WINDOWS', 'MANY OTHER']); + expect(editMessageEntry(result).map((field) => field.keys.length)).toEqual([ + 1, 1, + ]); + }); + + it('leaves plural categories absent from the copied locale empty', () => { + const source = parseEntry('fluent', plural); + const result = copyMessageEntry(source, { code: 'ar' }); + expect(values(result)).toEqual(['', 'ONE', '', '', '', 'OTHER']); + expect(serializeEntry(source)).toBe( + serializeEntry(parseEntry('fluent', plural)), + ); + }); + + it('copies a plain translation into the default form without removing plurals', () => { + const source = parseEntry('fluent', 'key = TRANSLATION'); + const original = parseEntry('fluent', plural); + const result = copyMessageEntry(source, { code: 'en' }, original); + expect(values(result)).toEqual(['', 'TRANSLATION']); + }); + + it('collapses a single-category locale to its catchall pattern', () => { + const result = copyMessageEntry(parseEntry('fluent', plural), { + code: 'zh', + }); + expect(values(result)).toEqual(['OTHER']); + expect(editMessageEntry(result)[0].keys).toEqual([]); + }); + + it('retains plural attributes and copied local attributes without mutating the template', () => { + const original = parseEntry( + 'fluent', + 'key = VALUE\n .label = { $n ->\n [one] ONE\n *[other] OTHER\n }', + ); + const before = serializeEntry(original); + const copied = parseEntry( + 'fluent', + 'key = COPIED\n .label = LABEL\n .gender = feminine', + ); + const result = copyMessageEntry(copied, { code: 'en' }, original); + expect(values(result)).toEqual(['COPIED', '', 'LABEL', 'feminine']); + expect(serializeEntry(original)).toBe(before); + }); + + it('preserves explicit numeric variants', () => { + const source = parseEntry( + 'fluent', + plural.replace('[one] ONE', '[0] ZERO\n [one] ONE'), + ); + const result = copyMessageEntry(source, { code: 'zh' }); + expect(values(result)).toEqual(['ZERO', 'OTHER']); + }); + + it('preserves non-plural selectors', () => { + const source = parseEntry( + 'fluent', + 'key = { PLATFORM() ->\n [windows] WINDOWS\n *[other] OTHER\n }', + ); + expect(serializeEntry(copyMessageEntry(source, { code: 'zh' }))).toBe( + serializeEntry(source), + ); + }); + + it('remaps attributes without discarding the value', () => { + const source = parseEntry( + 'fluent', + 'key = VALUE\n .label = { $n ->\n [one] ONE\n *[other] OTHER\n }', + ); + expect(values(copyMessageEntry(source, { code: 'zh' }))).toEqual([ + 'VALUE', + 'OTHER', + ]); + }); +}); diff --git a/translate/src/utils/message/copyMessageEntry.ts b/translate/src/utils/message/copyMessageEntry.ts new file mode 100644 index 0000000000..20e96e7675 --- /dev/null +++ b/translate/src/utils/message/copyMessageEntry.ts @@ -0,0 +1,93 @@ +import { isSelectMessage, type Message } from '@mozilla/l10n'; +import type { Locale } from '~/context/Locale'; +import type { MessageEntry } from '.'; +import { getEmptyMessageEntry } from './getEmptyMessage'; +import { findPluralSelectors } from './findPluralSelectors'; + +/** Copy an entry using the plural categories of the destination locale. */ +export function copyMessageEntry( + source: MessageEntry, + locale: Locale, + original?: MessageEntry, +): MessageEntry { + const template = structuredClone(source); + if ( + source.value && + original?.value && + !isSelectMessage(source.value) && + findPluralSelectors(original.value).size + ) { + template.value = original.value; + } + if (source.attributes && template.attributes) { + for (const [name, message] of source.attributes) { + const reference = original?.attributes?.get(name); + if ( + !isSelectMessage(message) && + reference && + findPluralSelectors(reference).size + ) { + template.attributes.set(name, reference); + } + } + } + const target = getEmptyMessageEntry(template, locale); + if (source.value && target.value) { + target.value = copyPatterns(source.value, target.value); + } + if (source.attributes && target.attributes) { + for (const [name, message] of source.attributes) { + target.attributes.set( + name, + copyPatterns(message, target.attributes.get(name)!), + ); + } + } + return target; +} + +function copyPatterns(source: Message, target: Message): Message { + if (!isSelectMessage(source)) { + if (isSelectMessage(target)) { + const fallback = target.alt.find(({ keys }) => + keys.every((key) => typeof key !== 'string'), + ); + if (fallback) { + fallback.pat = structuredClone( + Array.isArray(source) ? source : source.msg, + ); + } + if (!Array.isArray(source)) { + Object.assign(target.decl, structuredClone(source.decl)); + } + return target; + } + return structuredClone(source); + } + const plurals = findPluralSelectors(source); + const select = isSelectMessage(target); + const variants = select ? target.alt : [{ keys: [], pat: [] }]; + for (const variant of variants) { + let candidates = source.alt; + for (let i = 0; i < source.sel.length; ++i) { + const index = select ? target.sel.indexOf(source.sel[i]) : -1; + const key = index < 0 ? 'other' : variant.keys[index]; + const value = typeof key === 'string' ? key : key['*']; + const exact = candidates.filter(({ keys }) => { + const candidate = keys[i]; + return ( + (typeof candidate === 'string' ? candidate : candidate['*']) === value + ); + }); + candidates = + exact.length || + (plurals.has(i) && index >= 0 && typeof key === 'string') + ? exact + : candidates.filter(({ keys }) => typeof keys[i] !== 'string'); + } + variant.pat = structuredClone(candidates[0]?.pat ?? []); + } + return select + ? target + : { decl: structuredClone(source.decl), msg: variants[0].pat }; +}