From 8c287ea65559490a665d669bdac18e3c390b2fba Mon Sep 17 00:00:00 2001 From: Hanabi <317387557+Hanabi9248@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:15:30 +0800 Subject: [PATCH 1/6] Fix #4520 Copy complete locale translations --- .../components/OtherLocaleTranslation.test.jsx | 12 ++++-------- .../components/OtherLocaleTranslation.tsx | 6 +++--- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/translate/src/modules/otherlocales/components/OtherLocaleTranslation.test.jsx b/translate/src/modules/otherlocales/components/OtherLocaleTranslation.test.jsx index 16155e2083..2af6e06716 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]]); }); 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]]); }); }); diff --git a/translate/src/modules/otherlocales/components/OtherLocaleTranslation.tsx b/translate/src/modules/otherlocales/components/OtherLocaleTranslation.tsx index 7c1ac8bacb..572ec3461d 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); } - }, [index, setEditorFromHelpers, plain]); + }, [index, setElement, setEditorFromHistory, translation.translation]); const className = classNames( 'translation', From fa98567cafc98185e77f8e2be2312886f98cb11d Mon Sep 17 00:00:00 2001 From: Hanabi <317387557+Hanabi9248@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:17:11 +0800 Subject: [PATCH 2/6] #4520 Test complete locale copying --- .../TranslationForm-multiple.test.jsx | 110 +++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) 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 From b47844c5f393dc2792d97d5deba3db8e40fc7ce0 Mon Sep 17 00:00:00 2001 From: Hanabi <317387557+Hanabi9248@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:17:39 +0800 Subject: [PATCH 3/6] #4520 Copy complete entries with locale shortcuts --- .../translationform/utils/editFieldShortcuts.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/translate/src/modules/translationform/utils/editFieldShortcuts.ts b/translate/src/modules/translationform/utils/editFieldShortcuts.ts index 21d9f00d61..0c2514f3f5 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); } return true; }; From 567356acdab3f1709073dac00e22ebea4fdb6bc5 Mon Sep 17 00:00:00 2001 From: Hanabi <317387557+Hanabi9248@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:34:24 +0000 Subject: [PATCH 4/6] Remap copied translations to target plural categories --- translate/src/context/Editor.test.jsx | 27 ++++++ translate/src/context/Editor.tsx | 10 ++- .../OtherLocaleTranslation.test.jsx | 4 +- .../components/OtherLocaleTranslation.tsx | 2 +- .../utils/editFieldShortcuts.ts | 2 +- .../utils/message/copyMessageEntry.test.js | 89 +++++++++++++++++++ .../src/utils/message/copyMessageEntry.ts | 53 +++++++++++ 7 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 translate/src/utils/message/copyMessageEntry.test.js create mode 100644 translate/src/utils/message/copyMessageEntry.ts diff --git a/translate/src/context/Editor.test.jsx b/translate/src/context/Editor.test.jsx index 4bd60a9416..eea29d6f59 100644 --- a/translate/src/context/Editor.test.jsx +++ b/translate/src/context/Editor.test.jsx @@ -1070,3 +1070,30 @@ describe('', () => { expect(editor.fields.map((f) => f.handle.current.value)).toEqual(['', '']); }); }); + +describe('copying plural variants from another locale', () => { + 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..a32b6d4771 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); + } 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 2af6e06716..c730734d59 100644 --- a/translate/src/modules/otherlocales/components/OtherLocaleTranslation.test.jsx +++ b/translate/src/modules/otherlocales/components/OtherLocaleTranslation.test.jsx @@ -84,7 +84,7 @@ describe('', () => { fireEvent.click(getByRole('listitem')); - expect(spy.mock.calls).toEqual([[PLAIN_TRANSLATION.translation]]); + expect(spy.mock.calls).toEqual([[PLAIN_TRANSLATION.translation, true]]); }); it('sets editor value for a Fluent translation', () => { @@ -93,6 +93,6 @@ describe('', () => { fireEvent.click(container.querySelector('li')); - expect(spy.mock.calls).toEqual([[FLUENT_TRANSLATION.translation]]); + 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 572ec3461d..0e2c1898aa 100644 --- a/translate/src/modules/otherlocales/components/OtherLocaleTranslation.tsx +++ b/translate/src/modules/otherlocales/components/OtherLocaleTranslation.tsx @@ -43,7 +43,7 @@ export function OtherLocaleTranslationComponent({ const copyTranslationIntoEditor = useCallback(() => { if (window.getSelection()?.isCollapsed !== false) { setElement(index); - setEditorFromHistory(translation.translation); + setEditorFromHistory(translation.translation, true); } }, [index, setElement, setEditorFromHistory, translation.translation]); diff --git a/translate/src/modules/translationform/utils/editFieldShortcuts.ts b/translate/src/modules/translationform/utils/editFieldShortcuts.ts index 0c2514f3f5..2ac0641d65 100644 --- a/translate/src/modules/translationform/utils/editFieldShortcuts.ts +++ b/translate/src/modules/translationform/utils/editFieldShortcuts.ts @@ -143,7 +143,7 @@ export function useHandleCtrlShiftArrow(): ( } } else { const { translation } = otherLocaleTranslations[nextIdx]; - setEditorFromHistory(translation); + 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..055611cdf0 --- /dev/null +++ b/translate/src/utils/message/copyMessageEntry.test.js @@ -0,0 +1,89 @@ +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('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('fills additional plural categories from the source catchall', () => { + const source = parseEntry('fluent', plural); + const result = copyMessageEntry(source, { code: 'ar' }); + expect(values(result)).toEqual([ + 'OTHER', + 'ONE', + 'OTHER', + 'OTHER', + 'OTHER', + 'OTHER', + ]); + expect(serializeEntry(source)).toBe( + serializeEntry(parseEntry('fluent', plural)), + ); + }); + + 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('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..ab8536b3b0 --- /dev/null +++ b/translate/src/utils/message/copyMessageEntry.ts @@ -0,0 +1,53 @@ +import { isSelectMessage, type Message } from '@mozilla/l10n'; +import type { Locale } from '~/context/Locale'; +import type { MessageEntry } from '.'; +import { getEmptyMessageEntry } from './getEmptyMessage'; + +/** Copy an entry using the plural categories of the destination locale. */ +export function copyMessageEntry( + source: MessageEntry, + locale: Locale, +): MessageEntry { + const target = getEmptyMessageEntry(source, 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)) { + return structuredClone(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 + ? 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 }; +} From 277ccb182fc2acd23b78c6667eba79edcc96dfd9 Mon Sep 17 00:00:00 2001 From: Hanabi <317387557+Hanabi9248@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:40:27 +0000 Subject: [PATCH 5/6] Keep missing target plural forms empty when copying locales --- translate/src/context/Editor.test.jsx | 27 +++++++++++ translate/src/context/Editor.tsx | 2 +- .../utils/message/copyMessageEntry.test.js | 33 +++++++++---- .../src/utils/message/copyMessageEntry.ts | 47 +++++++++++++++++-- 4 files changed, 95 insertions(+), 14 deletions(-) diff --git a/translate/src/context/Editor.test.jsx b/translate/src/context/Editor.test.jsx index eea29d6f59..ed045b4e8d 100644 --- a/translate/src/context/Editor.test.jsx +++ b/translate/src/context/Editor.test.jsx @@ -1072,6 +1072,33 @@ describe('', () => { }); 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 = () => { diff --git a/translate/src/context/Editor.tsx b/translate/src/context/Editor.tsx index a32b6d4771..1fdd688649 100644 --- a/translate/src/context/Editor.tsx +++ b/translate/src/context/Editor.tsx @@ -359,7 +359,7 @@ export function EditorProvider({ children }: { children: React.ReactElement }) { if (specialFormats.has(format)) { let entry = parseEntry(format, str); if (entry && remapPlurals) { - entry = copyMessageEntry(entry, locale); + entry = copyMessageEntry(entry, locale, sourceEntry); } if (entry) { includeSourceAttributesAndDeclarations(entry, sourceEntry); diff --git a/translate/src/utils/message/copyMessageEntry.test.js b/translate/src/utils/message/copyMessageEntry.test.js index 055611cdf0..902d518221 100644 --- a/translate/src/utils/message/copyMessageEntry.test.js +++ b/translate/src/utils/message/copyMessageEntry.test.js @@ -33,22 +33,22 @@ describe('copyMessageEntry', () => { ]); }); - it('fills additional plural categories from the source catchall', () => { + 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([ - 'OTHER', - 'ONE', - 'OTHER', - 'OTHER', - 'OTHER', - 'OTHER', - ]); + 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', @@ -57,6 +57,21 @@ describe('copyMessageEntry', () => { 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', diff --git a/translate/src/utils/message/copyMessageEntry.ts b/translate/src/utils/message/copyMessageEntry.ts index ab8536b3b0..1335ec7556 100644 --- a/translate/src/utils/message/copyMessageEntry.ts +++ b/translate/src/utils/message/copyMessageEntry.ts @@ -2,13 +2,36 @@ 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 target = getEmptyMessageEntry(source, locale); + 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); } @@ -25,8 +48,23 @@ export function copyMessageEntry( 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) { @@ -41,9 +79,10 @@ function copyPatterns(source: Message, target: Message): Message { (typeof candidate === 'string' ? candidate : candidate['*']) === value ); }); - candidates = exact.length - ? exact - : candidates.filter(({ keys }) => typeof keys[i] !== 'string'); + candidates = + exact.length || plurals.has(i) + ? exact + : candidates.filter(({ keys }) => typeof keys[i] !== 'string'); } variant.pat = structuredClone(candidates[0]?.pat ?? []); } From 4808c5a11224ce349f93ba386db840bff46bbaf2 Mon Sep 17 00:00:00 2001 From: Hanabi <317387557+Hanabi9248@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:29:50 +0000 Subject: [PATCH 6/6] Preserve catchall patterns across plural category names --- .../utils/message/copyMessageEntry.test.js | 20 +++++++++++++++++++ .../src/utils/message/copyMessageEntry.ts | 3 ++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/translate/src/utils/message/copyMessageEntry.test.js b/translate/src/utils/message/copyMessageEntry.test.js index 902d518221..e68acd65f7 100644 --- a/translate/src/utils/message/copyMessageEntry.test.js +++ b/translate/src/utils/message/copyMessageEntry.test.js @@ -10,6 +10,26 @@ function values(entry) { } 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', diff --git a/translate/src/utils/message/copyMessageEntry.ts b/translate/src/utils/message/copyMessageEntry.ts index 1335ec7556..20e96e7675 100644 --- a/translate/src/utils/message/copyMessageEntry.ts +++ b/translate/src/utils/message/copyMessageEntry.ts @@ -80,7 +80,8 @@ function copyPatterns(source: Message, target: Message): Message { ); }); candidates = - exact.length || plurals.has(i) + exact.length || + (plurals.has(i) && index >= 0 && typeof key === 'string') ? exact : candidates.filter(({ keys }) => typeof keys[i] !== 'string'); }