Skip to content

Commit 12b3fd6

Browse files
authored
fix: Exact plural forms for basic MT translators (tolgee#2454)
1 parent 3765634 commit 12b3fd6

File tree

10 files changed

+176
-46
lines changed

10 files changed

+176
-46
lines changed

backend/data/src/main/kotlin/io/tolgee/service/machineTranslation/PluralTranslationUtil.kt

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,14 @@ class PluralTranslationUtil(
1212
private val item: MtBatchItemParams,
1313
private val translateFn: (String) -> MtTranslatorResult,
1414
) {
15-
val forms by lazy {
16-
context.getPluralFormsReplacingReplaceParam(baseTranslationText)
17-
?: throw IllegalStateException("Plural forms are null")
18-
}
19-
2015
fun translate(): MtTranslatorResult {
2116
return result
2217
}
2318

2419
private val preparedFormSourceStrings: Sequence<Pair<String, String>> by lazy {
25-
return@lazy targetExamples.asSequence().map {
26-
val form = sourceRules?.select(it.value.toDouble())
27-
val formValue = forms.forms[form] ?: forms.forms[PluralRules.KEYWORD_OTHER] ?: ""
28-
it.key to formValue.replaceReplaceNumberPlaceholderWithExample(it.value)
29-
}
20+
val targetLanguageTag = context.getLanguage(item.targetLanguageId).tag
21+
val sourceLanguageTag = context.baseLanguage.tag
22+
getPreparedSourceStrings(sourceLanguageTag, targetLanguageTag, forms)
3023
}
3124

3225
private val translated by lazy {
@@ -35,6 +28,11 @@ class PluralTranslationUtil(
3528
}
3629
}
3730

31+
private val forms by lazy {
32+
context.getPluralFormsReplacingReplaceParam(baseTranslationText)
33+
?: throw IllegalStateException("Plural forms are null")
34+
}
35+
3836
private val result: MtTranslatorResult by lazy {
3937
val result =
4038
translated.map { (form, result) ->
@@ -59,18 +57,6 @@ class PluralTranslationUtil(
5957
)
6058
}
6159

62-
private val targetExamples by lazy {
63-
val targetLanguageTag = context.getLanguage(item.targetLanguageId).tag
64-
val targetULocale = getULocaleFromTag(targetLanguageTag)
65-
val targetRules = PluralRules.forLocale(targetULocale)
66-
getPluralFormExamples(targetRules)
67-
}
68-
69-
private val sourceRules by lazy {
70-
val sourceLanguageTag = context.baseLanguage.tag
71-
getRulesByTag(sourceLanguageTag)
72-
}
73-
7460
private fun String.replaceNumberTags(): String {
7561
return this.replace(TOLGEE_TAG_REGEX, "#")
7662
}
@@ -126,5 +112,43 @@ class PluralTranslationUtil(
126112
val sourceULocale = getULocaleFromTag(languageTag)
127113
return PluralRules.forLocale(sourceULocale)
128114
}
115+
116+
fun getPreparedSourceStrings(
117+
sourceLanguageTag: String,
118+
targetLanguageTag: String,
119+
forms: PluralForms,
120+
): Sequence<Pair<String, String>> {
121+
val sourceRules = getRulesByTag(sourceLanguageTag)
122+
val keywordCases =
123+
getTargetExamples(targetLanguageTag).asSequence().map {
124+
val form = sourceRules?.select(it.value.toDouble())
125+
val formValue = forms.forms[form] ?: forms.forms[PluralRules.KEYWORD_OTHER] ?: ""
126+
it.key to formValue.replaceReplaceNumberPlaceholderWithExample(it.value)
127+
}
128+
129+
val exactCases =
130+
forms.forms.asSequence().filter {
131+
it.key.startsWith("=")
132+
}.mapNotNull {
133+
val number = it.key.substring(1).toDoubleOrNull() ?: return@mapNotNull null
134+
it.key to it.value.replaceReplaceNumberPlaceholderWithExample(number)
135+
}
136+
137+
return keywordCases + exactCases
138+
}
139+
140+
private fun String.toDoubleOrNull(): Number? {
141+
return try {
142+
this.toBigDecimalOrNull()
143+
} catch (e: NumberFormatException) {
144+
null
145+
}
146+
}
147+
148+
private fun getTargetExamples(targetLanguageTag: String): Map<String, Number> {
149+
val targetULocale = getULocaleFromTag(targetLanguageTag)
150+
val targetRules = PluralRules.forLocale(targetULocale)
151+
return getPluralFormExamples(targetRules)
152+
}
129153
}
130154
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package io.tolgee.unit.util
2+
3+
import io.tolgee.formats.getPluralFormsReplacingReplaceParam
4+
import io.tolgee.service.machineTranslation.PluralTranslationUtil
5+
import io.tolgee.testing.assert
6+
import org.junit.jupiter.api.Test
7+
8+
class PluralTranslationUtilTest {
9+
@Test
10+
fun `provides correct forms for basic MT providers`() {
11+
val baseString = """{number, plural, one {# apple} =1 {one apple} =2 {Two apples} =5 {# apples} other {# apples}}"""
12+
val result =
13+
PluralTranslationUtil.getPreparedSourceStrings(
14+
"en",
15+
"cs",
16+
getPluralFormsReplacingReplaceParam(baseString, PluralTranslationUtil.REPLACE_NUMBER_PLACEHOLDER)!!,
17+
)
18+
19+
result.toMap().assert.isEqualTo(
20+
mapOf(
21+
"one" to "<x id=\"tolgee-number\">1</x> apple",
22+
"few" to "<x id=\"tolgee-number\">2</x> apples",
23+
"many" to "<x id=\"tolgee-number\">0.5</x> apples",
24+
"other" to "<x id=\"tolgee-number\">10</x> apples",
25+
"=1" to "one apple",
26+
"=2" to "Two apples",
27+
"=5" to "<x id=\"tolgee-number\">5</x> apples",
28+
),
29+
)
30+
}
31+
}

e2e/cypress/e2e/import/importApplication.cy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ describe('Import application', () => {
2222
'Applies import',
2323
{
2424
retries: {
25-
runMode: 4,
25+
runMode: 10,
2626
},
2727
},
2828
() => {

e2e/cypress/e2e/import/importResultManupulation.cy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ describe('Import result & manipulation', () => {
188188
}
189189
);
190190

191-
it('imports with selects namespaces', () => {
191+
it('imports with selects namespaces', { retries: { runMode: 5 } }, () => {
192192
gcy('import_apply_import_button').click();
193193
assertMessage('Import successful');
194194
gcy('import-result-row').should('have.length', 0);

e2e/cypress/e2e/translations/plurals.cy.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from '../../common/translations';
99
import { waitForGlobalLoading } from '../../common/loading';
1010
import { createKey, deleteProject } from '../../common/apiCalls/common';
11-
import { confirmStandard } from '../../common/shared';
11+
import { confirmStandard, gcyAdvanced } from '../../common/shared';
1212

1313
describe('Translations Base', () => {
1414
let project: ProjectDTO = null;
@@ -60,6 +60,27 @@ describe('Translations Base', () => {
6060
.should('be.visible');
6161
});
6262

63+
it('shows base and existing exact forms', () => {
64+
createKey(
65+
project.id,
66+
'Test key',
67+
{
68+
en: 'You have {testValue, plural, one {# item} =2 {Two items} other {# items}}',
69+
cs: 'Máte {testValue, plural, one {# položku} =4 {# položky } few {# položky} other {# položek}}',
70+
},
71+
{ isPlural: true }
72+
);
73+
visitTranslations(project.id);
74+
waitForGlobalLoading();
75+
getTranslationCell('Test key', 'cs').click();
76+
gcyAdvanced({ value: 'translation-editor', variant: '=2' }).should(
77+
'be.visible'
78+
);
79+
gcyAdvanced({ value: 'translation-editor', variant: '=4' }).should(
80+
'be.visible'
81+
);
82+
});
83+
6384
it('will change plural parameter name for all translations', () => {
6485
createKey(
6586
project.id,

webapp/src/views/projects/translations/TranslationEditor.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const TranslationEditor = ({ mode, tools, editorRef }: Props) => {
1818
handleSave,
1919
handleClose,
2020
handleInsertBase,
21+
baseValue,
2122
} = tools;
2223

2324
return (
@@ -30,6 +31,7 @@ export const TranslationEditor = ({ mode, tools, editorRef }: Props) => {
3031
autofocus={true}
3132
activeEditorRef={editorRef}
3233
mode={mode}
34+
baseValue={baseValue}
3335
editorProps={{
3436
shortcuts: [
3537
{ key: 'Escape', run: () => (handleClose(true), true) },

webapp/src/views/projects/translations/TranslationsTable/TranslationWrite.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const TranslationWrite: React.FC<Props> = ({ tools }) => {
5656
handleInsertBase,
5757
editEnabled,
5858
disabled,
59+
baseText,
5960
} = tools;
6061
const editVal = tools.editVal!;
6162
const state = translation?.state || 'UNTRANSLATED';
@@ -68,7 +69,7 @@ export const TranslationWrite: React.FC<Props> = ({ tools }) => {
6869

6970
const baseTranslation = useBaseTranslation(
7071
activeVariant,
71-
keyData.translations[baseLanguage]?.text,
72+
baseText,
7273
keyData.keyIsPlural
7374
);
7475

webapp/src/views/projects/translations/translationVisual/PluralEditor.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type Props = {
1717
autofocus?: boolean;
1818
activeEditorRef?: RefObject<EditorView | null>;
1919
mode: 'placeholders' | 'syntax';
20+
baseValue?: TolgeeFormat;
2021
};
2122

2223
export const PluralEditor = ({
@@ -29,6 +30,7 @@ export const PluralEditor = ({
2930
activeEditorRef,
3031
editorProps,
3132
mode,
33+
baseValue,
3234
}: Props) => {
3335
function handleChange(text: string, variant: string) {
3436
onChange?.({ ...value, variants: { ...value.variants, [variant]: text } });
@@ -38,13 +40,25 @@ export const PluralEditor = ({
3840

3941
const editorMode = project.icuPlaceholders ? mode : 'plain';
4042

43+
function getExactForms() {
44+
if (!baseValue) {
45+
return [];
46+
}
47+
return Object.keys(baseValue.variants)
48+
.filter((key) => /^=\d+(\.\d+)?$/.test(key))
49+
.map((key) => parseFloat(key.substring(1)));
50+
}
51+
52+
const exactForms = getExactForms();
53+
4154
return (
4255
<TranslationPlurals
4356
value={value}
4457
locale={locale}
4558
showEmpty
4659
activeVariant={activeVariant}
4760
variantPaddingTop="8px"
61+
exactForms={exactForms}
4862
render={({ content, variant, exampleValue }) => {
4963
const variantOrOther = variant || 'other';
5064
return (

webapp/src/views/projects/translations/translationVisual/TranslationPlurals.tsx

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { useMemo } from 'react';
1+
import React, { useMemo } from 'react';
22
import { styled } from '@mui/material';
3-
import React from 'react';
43
import {
5-
TolgeeFormat,
64
getPluralVariants,
75
getVariantExample,
6+
TolgeeFormat,
87
} from '@tginternal/editor';
98

109
const StyledContainer = styled('div')`
@@ -67,6 +66,7 @@ type Props = {
6766
showEmpty?: boolean;
6867
activeVariant?: string;
6968
variantPaddingTop?: number | string;
69+
exactForms?: number[];
7070
};
7171

7272
export const TranslationPlurals = ({
@@ -76,19 +76,12 @@ export const TranslationPlurals = ({
7676
showEmpty,
7777
activeVariant,
7878
variantPaddingTop,
79+
exactForms,
7980
}: Props) => {
80-
const variants = useMemo(() => {
81-
const existing = new Set(Object.keys(value.variants));
82-
const required = getPluralVariants(locale);
83-
required.forEach((val) => existing.delete(val));
84-
const result = Array.from(existing).map((value) => {
85-
return [value, getVariantExample(locale, value)] as const;
86-
});
87-
required.forEach((value) => {
88-
result.push([value, getVariantExample(locale, value)]);
89-
});
90-
return result;
91-
}, [locale]);
81+
const variants = useMemo(
82+
() => getForms(locale, value, exactForms),
83+
[locale, exactForms, value]
84+
);
9285

9386
if (value.parameter) {
9487
return (
@@ -137,3 +130,27 @@ export const TranslationPlurals = ({
137130
</StyledContainerSimple>
138131
);
139132
};
133+
134+
function getForms(locale: string, value: TolgeeFormat, exactForms?: number[]) {
135+
const forms: Set<string> = new Set();
136+
getPluralVariants(locale).forEach((value) => forms.add(value));
137+
Object.keys(value.variants).forEach((value) => forms.add(value));
138+
(exactForms || [])
139+
.map((value) => `=${value.toString()}`)
140+
.forEach((value) => forms.add(value));
141+
142+
const formsArray = sortExactForms(forms);
143+
144+
return formsArray.map((value) => {
145+
return [value, getVariantExample(locale, value)] as const;
146+
});
147+
}
148+
149+
function sortExactForms(forms: Set<string>) {
150+
return [...forms].sort((a, b) => {
151+
if (a.startsWith('=') && b.startsWith('=')) {
152+
return Number(a.substring(1)) - Number(b.substring(1));
153+
}
154+
return 0;
155+
});
156+
}

0 commit comments

Comments
 (0)