Skip to content

Commit 3a676c4

Browse files
bdshadowclaude
andcommitted
fix: detect i18next plural suffix collision on export (Sentry TOLGEE-BACKEND-3ED)
When exporting to i18next format, plural keys get suffixed (e.g., "plan" → "plan_other"). If a non-plural key with the same suffixed name already exists, the export crashed with an IllegalStateException. Now detect such collisions upfront and throw a BadRequestException with a clear message identifying both the plural key and the colliding key, so the user can resolve the naming conflict. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fae0255 commit 3a676c4

7 files changed

Lines changed: 80 additions & 2 deletions

File tree

backend/data/src/main/kotlin/io/tolgee/constants/Message.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ enum class Message {
324324
BRANCH_MERGE_CONFLICTS_NOT_RESOLVED,
325325
BRANCH_MERGE_ALREADY_MERGED,
326326
BRANCHING_NOT_ENABLED_FOR_PROJECT,
327+
EXPORT_KEY_PLURAL_SUFFIX_COLLISION,
327328
;
328329

329330
val code: String
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package io.tolgee.exceptions
2+
3+
import io.tolgee.constants.Message
4+
5+
class ExportCollidingKeysException(
6+
val pluralKey: String,
7+
val collidingKey: String,
8+
val suffix: String,
9+
) : BadRequestException(
10+
Message.EXPORT_KEY_PLURAL_SUFFIX_COLLISION,
11+
listOf(pluralKey, collidingKey, suffix),
12+
)

backend/data/src/main/kotlin/io/tolgee/formats/genericStructuredFile/out/GenericStructuredFileExporter.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package io.tolgee.formats.genericStructuredFile.out
22

33
import com.fasterxml.jackson.databind.ObjectMapper
44
import io.tolgee.dtos.IExportParams
5+
import io.tolgee.exceptions.ExportCollidingKeysException
56
import io.tolgee.formats.ExportFormat
67
import io.tolgee.formats.ExportMessageFormat
8+
import io.tolgee.formats.formKeywords
79
import io.tolgee.formats.generic.IcuToGenericFormatMessageConvertor
810
import io.tolgee.formats.nestedStructureModel.StructureModelBuilder
911
import io.tolgee.formats.path.ObjectPathItem
@@ -39,11 +41,30 @@ class GenericStructuredFileExporter(
3941
}
4042

4143
private fun prepare() {
44+
if (pluralsViaSuffixes) {
45+
checkPluralSuffixCollisions()
46+
}
4247
translations.forEach { translation ->
4348
addTranslationToBuilder(translation)
4449
}
4550
}
4651

52+
private fun checkPluralSuffixCollisions() {
53+
val allKeyNames = translations.map { it.key.name }.toSet()
54+
translations.filter { it.key.isPlural }.forEach { translation ->
55+
for (suffix in formKeywords) {
56+
val suffixedName = "${translation.key.name}_$suffix"
57+
if (suffixedName in allKeyNames) {
58+
throw ExportCollidingKeysException(
59+
pluralKey = translation.key.name,
60+
collidingKey = suffixedName,
61+
suffix = suffix,
62+
)
63+
}
64+
}
65+
}
66+
}
67+
4768
private fun addTranslationToBuilder(translation: ExportTranslationView) {
4869
if (translation.key.isPlural) {
4970
addPluralTranslation(translation)

backend/data/src/test/kotlin/io/tolgee/unit/formats/i18next/out/I18nextFileExporterTest.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package io.tolgee.unit.formats.i18next.out
22

33
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
44
import io.tolgee.dtos.request.export.ExportParams
5+
import io.tolgee.exceptions.ExportCollidingKeysException
56
import io.tolgee.formats.ExportFormat
67
import io.tolgee.formats.genericStructuredFile.out.CustomPrettyPrinter
78
import io.tolgee.formats.json.out.JsonFileExporter
@@ -11,6 +12,8 @@ import io.tolgee.service.export.dataProvider.ExportTranslationView
1112
import io.tolgee.unit.util.assertFile
1213
import io.tolgee.unit.util.getExported
1314
import io.tolgee.util.buildExportTranslationList
15+
import org.assertj.core.api.Assertions.assertThat
16+
import org.assertj.core.api.Assertions.assertThatThrownBy
1417
import org.junit.jupiter.api.Test
1518

1619
class I18nextFileExporterTest {
@@ -89,6 +92,38 @@ class I18nextFileExporterTest {
8992
)
9093
}
9194

95+
@Test
96+
fun `throws on plural suffix collision with existing key`() {
97+
val built =
98+
buildExportTranslationList {
99+
add(
100+
languageTag = "en",
101+
keyName = "profile.my-goals.plan",
102+
text = "{count, plural, other {Other}}",
103+
) {
104+
key.isPlural = true
105+
}
106+
add(
107+
languageTag = "en",
108+
keyName = "profile.my-goals.plan_other",
109+
text = "Some translation",
110+
)
111+
}
112+
val exporter =
113+
getExporter(
114+
built.translations,
115+
exportParams = ExportParams(format = ExportFormat.JSON_I18NEXT),
116+
)
117+
assertThatThrownBy { exporter.produceFiles() }
118+
.isInstanceOf(ExportCollidingKeysException::class.java)
119+
.satisfies({ ex ->
120+
val bre = ex as ExportCollidingKeysException
121+
assertThat(bre.pluralKey).isEqualTo("profile.my-goals.plan")
122+
assertThat(bre.collidingKey).isEqualTo("profile.my-goals.plan_other")
123+
assertThat(bre.suffix).isEqualTo("other")
124+
})
125+
}
126+
92127
private fun getTranslationWithColon(): MutableList<ExportTranslationView> {
93128
val built =
94129
buildExportTranslationList {

webapp/src/i18n/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,7 @@
738738
"cannot_change_your_own_access_tooltip": "Cannot revoke access to yourself",
739739
"cannot_delete_base_language_message": "Cannot delete base language",
740740
"cannot_delete_branch_with_children": "Cannot delete this branch because other branches were created from it.",
741+
"export_key_plural_suffix_collision": "Export failed: plural key \"{pluralKey}\" with suffix \"_{suffix}\" collides with existing key \"{collidingKey}\". Rename one of the keys to resolve the conflict.",
741742
"cannot_leave_project_with_organization_role_error_message": "Cannot leave project owned by the organization you are member of.",
742743
"cannot_modify_disabled_translation": "Cannot modify disabled translation",
743744
"cannot_modify_reviewed_translation": "Cannot modify reviewed translation",

webapp/src/service/apiSchema.generated.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2893,7 +2893,8 @@ export interface components {
28932893
| "branch_merge_revision_not_valid"
28942894
| "branch_merge_conflicts_not_resolved"
28952895
| "branch_merge_already_merged"
2896-
| "branching_not_enabled_for_project";
2896+
| "branching_not_enabled_for_project"
2897+
| "export_key_plural_suffix_collision";
28972898
params?: unknown[];
28982899
};
28992900
ExistenceEntityDescription: {
@@ -6268,7 +6269,8 @@ export interface components {
62686269
| "branch_merge_revision_not_valid"
62696270
| "branch_merge_conflicts_not_resolved"
62706271
| "branch_merge_already_merged"
6271-
| "branching_not_enabled_for_project";
6272+
| "branching_not_enabled_for_project"
6273+
| "export_key_plural_suffix_collision";
62726274
params?: unknown[];
62736275
success: boolean;
62746276
};

webapp/src/translationTools/useErrorTranslation.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,12 @@ export function useErrorTranslation() {
213213
return t('operation_not_permitted_in_read_only_mode');
214214
case 'cannot_delete_branch_with_children':
215215
return t('cannot_delete_branch_with_children');
216+
case 'export_key_plural_suffix_collision':
217+
return t('export_key_plural_suffix_collision', {
218+
pluralKey: params?.[0] || '',
219+
collidingKey: params?.[1] || '',
220+
suffix: params?.[2] || '',
221+
});
216222
default:
217223
return code;
218224
}

0 commit comments

Comments
 (0)