Skip to content

Commit 6f6217d

Browse files
authored
feat: Apple String Catalog Support (#2893)
1 parent eed54d6 commit 6f6217d

File tree

28 files changed

+592
-13
lines changed

28 files changed

+592
-13
lines changed

backend/data/src/main/kotlin/io/tolgee/formats/ExportFormat.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ enum class ExportFormat(
66
val extension: String,
77
val mediaType: String,
88
val defaultFileStructureTemplate: String = ExportFilePathProvider.DEFAULT_TEMPLATE,
9+
val multiLanguage: Boolean = false,
910
) {
1011
JSON("json", "application/json"),
1112
JSON_TOLGEE("json", "application/json"),
@@ -43,4 +44,10 @@ enum class ExportFormat(
4344
CSV("csv", "text/csv"),
4445
RESX_ICU("resx", "text/microsoft-resx"),
4546
XLSX("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
47+
APPLE_XCSTRINGS(
48+
"xcstrings",
49+
"application/json",
50+
defaultFileStructureTemplate = "Localizable.{extension}",
51+
multiLanguage = true,
52+
),
4653
}

backend/data/src/main/kotlin/io/tolgee/formats/ImportFileProcessorFactory.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
55
import io.tolgee.dtos.dataImport.ImportFileDto
66
import io.tolgee.exceptions.ImportCannotParseFileException
77
import io.tolgee.formats.apple.`in`.strings.StringsFileProcessor
8+
import io.tolgee.formats.apple.`in`.xcstrings.XcstringsFileProcessor
89
import io.tolgee.formats.csv.`in`.CsvFileProcessor
910
import io.tolgee.formats.flutter.`in`.FlutterArbFileProcessor
1011
import io.tolgee.formats.importCommon.ImportFileFormat
@@ -66,6 +67,7 @@ class ImportFileProcessorFactory(
6667
ImportFileFormat.CSV -> CsvFileProcessor(context)
6768
ImportFileFormat.RESX -> ResxProcessor(context)
6869
ImportFileFormat.XLSX -> XlsxFileProcessor(context)
70+
ImportFileFormat.XCSTRINGS -> XcstringsFileProcessor(context, objectMapper)
6971
}
7072
}
7173

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package io.tolgee.formats.apple.`in`.xcstrings
2+
3+
import com.fasterxml.jackson.databind.JsonNode
4+
import com.fasterxml.jackson.databind.ObjectMapper
5+
import io.tolgee.exceptions.ImportCannotParseFileException
6+
import io.tolgee.formats.ImportFileProcessor
7+
import io.tolgee.formats.apple.`in`.guessNamespaceFromPath
8+
import io.tolgee.formats.importCommon.ImportFormat
9+
import io.tolgee.service.dataImport.processors.FileProcessorContext
10+
11+
class XcstringsFileProcessor(
12+
override val context: FileProcessorContext,
13+
private val objectMapper: ObjectMapper,
14+
) : ImportFileProcessor() {
15+
private lateinit var sourceLanguage: String
16+
17+
override fun process() {
18+
try {
19+
val root = objectMapper.readTree(context.file.data.inputStream())
20+
sourceLanguage = root.get("sourceLanguage")?.asText()
21+
?: throw ImportCannotParseFileException(context.file.name, "Missing sourceLanguage in xcstrings file")
22+
23+
val strings =
24+
root.get("strings")
25+
?: throw ImportCannotParseFileException(context.file.name, "Missing 'strings' object in xcstrings file")
26+
27+
strings.fields().forEach { (key, value) ->
28+
processKey(key, value)
29+
}
30+
31+
context.namespace = guessNamespaceFromPath(context.file.name)
32+
} catch (e: Exception) {
33+
throw ImportCannotParseFileException(context.file.name, e.message)
34+
}
35+
}
36+
37+
private fun addConvertedTranslation(
38+
key: String,
39+
languageTag: String,
40+
rawValue: String,
41+
forms: Map<String, String>? = null,
42+
) {
43+
val converted =
44+
messageConvertor.convert(
45+
rawData = if (forms != null) forms else rawValue,
46+
languageTag = languageTag,
47+
convertPlaceholders = context.importSettings.convertPlaceholdersToIcu,
48+
isProjectIcuEnabled = context.projectIcuPlaceholdersEnabled,
49+
)
50+
51+
context.addTranslation(
52+
keyName = key,
53+
languageName = languageTag,
54+
value = converted.message,
55+
convertedBy = importFormat,
56+
rawData = forms ?: rawValue,
57+
pluralArgName = converted.pluralArgName,
58+
)
59+
}
60+
61+
private fun processKey(
62+
key: String,
63+
value: JsonNode,
64+
) {
65+
val localizations = value.get("localizations") ?: return
66+
67+
value.get("comment")?.asText()?.let { comment ->
68+
context.addKeyDescription(key, comment)
69+
}
70+
71+
if (!localizations.has(sourceLanguage)) {
72+
addConvertedTranslation(key, sourceLanguage, key)
73+
}
74+
75+
localizations.fields().forEach { (languageTag, localization) ->
76+
when {
77+
localization.has("stringUnit") -> {
78+
processSingleTranslation(key, languageTag, localization)
79+
}
80+
81+
localization.has("variations") -> {
82+
processPluralTranslation(key, languageTag, localization)
83+
}
84+
}
85+
}
86+
}
87+
88+
private fun processSingleTranslation(
89+
key: String,
90+
languageTag: String,
91+
localization: JsonNode,
92+
) {
93+
val stringUnit = localization.get("stringUnit")
94+
val xcState = stringUnit?.get("state")?.asText()
95+
96+
val translationValue = stringUnit?.get("value")?.asText()
97+
98+
if (translationValue != null) {
99+
addConvertedTranslation(key, languageTag, translationValue)
100+
return
101+
}
102+
}
103+
104+
private fun processPluralTranslation(
105+
key: String,
106+
languageTag: String,
107+
localization: JsonNode,
108+
) {
109+
val variations = localization.get("variations")?.get("plural") ?: return
110+
val forms = mutableMapOf<String, String>()
111+
112+
variations.fields().forEach { (form, content) ->
113+
val stringUnit = content.get("stringUnit")
114+
val value = stringUnit?.get("value")?.asText()
115+
116+
if (value != null) {
117+
forms[form] = value
118+
}
119+
}
120+
121+
if (forms.isNotEmpty()) {
122+
addConvertedTranslation(key, languageTag, "", forms)
123+
}
124+
}
125+
126+
companion object {
127+
private val importFormat = ImportFormat.APPLE_XCSTRINGS
128+
private val messageConvertor = importFormat.messageConvertor
129+
}
130+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package io.tolgee.formats.apple.out
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper
4+
import com.fasterxml.jackson.databind.node.ObjectNode
5+
import io.tolgee.dtos.IExportParams
6+
import io.tolgee.model.enums.TranslationState
7+
import io.tolgee.service.export.ExportFilePathProvider
8+
import io.tolgee.service.export.dataProvider.ExportTranslationView
9+
import io.tolgee.service.export.exporters.FileExporter
10+
import java.io.InputStream
11+
12+
class AppleXcstringsExporter(
13+
private val translations: List<ExportTranslationView>,
14+
private val exportParams: IExportParams,
15+
private val objectMapper: ObjectMapper,
16+
private val isProjectIcuPlaceholdersEnabled: Boolean = true,
17+
) : FileExporter {
18+
private val preparedFiles = mutableMapOf<String, ObjectNode>()
19+
20+
override fun produceFiles(): Map<String, InputStream> {
21+
translations.forEach { handleTranslation(it) }
22+
23+
return preparedFiles.mapValues { (_, jsonContent) ->
24+
val root =
25+
objectMapper.createObjectNode().apply {
26+
put("sourceLanguage", exportParams.languages?.firstOrNull() ?: "en")
27+
put("version", "1.0")
28+
set<ObjectNode>("strings", jsonContent)
29+
}
30+
objectMapper.writeValueAsString(root).byteInputStream()
31+
}
32+
}
33+
34+
private fun handleTranslation(translation: ExportTranslationView) {
35+
val baseFilePath = getBaseFilePath(translation)
36+
val fileContent = preparedFiles.getOrPut(baseFilePath) { objectMapper.createObjectNode() }
37+
38+
val keyData =
39+
fileContent.get(translation.key.name)?.let {
40+
it as ObjectNode
41+
} ?: createKeyEntry(translation)
42+
fileContent.set<ObjectNode>(translation.key.name, keyData)
43+
44+
val converted =
45+
IcuToAppleMessageConvertor(
46+
message = translation.text ?: "",
47+
translation.key.isPlural,
48+
isProjectIcuPlaceholdersEnabled,
49+
).convert()
50+
51+
val localizations =
52+
keyData.get("localizations")?.let {
53+
it as ObjectNode
54+
} ?: objectMapper.createObjectNode()
55+
56+
if (translation.description != null) {
57+
keyData.put("comment", translation.description)
58+
}
59+
60+
if (converted.isPlural()) {
61+
handlePluralTranslation(localizations, translation, converted.formsResult)
62+
} else {
63+
handleSingleTranslation(localizations, translation, converted.singleResult)
64+
}
65+
66+
keyData.set<ObjectNode>("localizations", localizations)
67+
}
68+
69+
private fun getAppleState(state: TranslationState): String? {
70+
return when (state) {
71+
TranslationState.TRANSLATED -> "needs_review"
72+
TranslationState.REVIEWED -> "translated"
73+
TranslationState.UNTRANSLATED -> null
74+
TranslationState.DISABLED -> null
75+
}
76+
}
77+
78+
private fun handleSingleTranslation(
79+
localizations: ObjectNode,
80+
translation: ExportTranslationView,
81+
convertedText: String?,
82+
) {
83+
if (convertedText == null) return
84+
85+
localizations.set<ObjectNode>(
86+
translation.languageTag,
87+
objectMapper.createObjectNode().apply {
88+
set<ObjectNode>(
89+
"stringUnit",
90+
objectMapper.createObjectNode().apply {
91+
getAppleState(translation.state)?.let { state ->
92+
put("state", state)
93+
}
94+
put("value", convertedText)
95+
},
96+
)
97+
},
98+
)
99+
}
100+
101+
private fun handlePluralTranslation(
102+
localizations: ObjectNode,
103+
translation: ExportTranslationView,
104+
forms: Map<String, String>?,
105+
) {
106+
if (forms == null) return
107+
108+
val pluralForms = objectMapper.createObjectNode()
109+
forms.forEach { (form, text) ->
110+
pluralForms.set<ObjectNode>(
111+
form,
112+
objectMapper.createObjectNode().apply {
113+
set<ObjectNode>(
114+
"stringUnit",
115+
objectMapper.createObjectNode().apply {
116+
getAppleState(translation.state)?.let { state ->
117+
put("state", state)
118+
}
119+
put("value", text)
120+
},
121+
)
122+
},
123+
)
124+
}
125+
126+
localizations.set<ObjectNode>(
127+
translation.languageTag,
128+
objectMapper.createObjectNode().apply {
129+
set<ObjectNode>(
130+
"variations",
131+
objectMapper.createObjectNode().apply {
132+
set<ObjectNode>("plural", pluralForms)
133+
},
134+
)
135+
},
136+
)
137+
}
138+
139+
private fun createKeyEntry(translation: ExportTranslationView): ObjectNode {
140+
return objectMapper.createObjectNode().apply {
141+
translation.key.description?.let {
142+
put("extractionState", "manual")
143+
}
144+
}
145+
}
146+
147+
private fun getBaseFilePath(translation: ExportTranslationView): String {
148+
return ExportFilePathProvider(
149+
exportParams,
150+
"xcstrings",
151+
).getFilePath(
152+
translation.key.namespace,
153+
null,
154+
replaceExtension = true,
155+
)
156+
}
157+
}

backend/data/src/main/kotlin/io/tolgee/formats/importCommon/ImportFileFormat.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ enum class ImportFileFormat(val extensions: Array<String>) {
66
STRINGS(arrayOf("strings")),
77
STRINGSDICT(arrayOf("stringsdict")),
88
XLIFF(arrayOf("xliff", "xlf")),
9+
XCSTRINGS(arrayOf("xcstrings")),
910
PROPERTIES(arrayOf("properties")),
1011
XML(arrayOf("xml")),
1112
ARB(arrayOf("arb")),

backend/data/src/main/kotlin/io/tolgee/formats/importCommon/ImportFormat.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,15 @@ enum class ImportFormat(
125125
messageConvertorOrNull = appleConvertor,
126126
),
127127

128+
APPLE_XCSTRINGS(
129+
fileFormat = ImportFileFormat.XCSTRINGS,
130+
messageConvertorOrNull =
131+
GenericMapPluralImportRawDataConvertor(
132+
optimizePlurals = true,
133+
canContainIcu = false,
134+
) { AppleToIcuPlaceholderConvertor() },
135+
),
136+
128137
// properties don't store plurals in map, but it doesn't matter.
129138
// Since they don't support nesting at all, we cannot have plurals by nesting in them, so the plural extracting
130139
// code won't be executed

backend/data/src/main/kotlin/io/tolgee/service/export/ExportFilePathProvider.kt

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -52,19 +52,21 @@ class ExportFilePathProvider(
5252
}
5353

5454
private fun validateTemplate() {
55-
val containsLanguageTag =
56-
arrayOf(
57-
ExportFilePathPlaceholder.LANGUAGE_TAG,
58-
ExportFilePathPlaceholder.ANDROID_LANGUAGE_TAG,
59-
ExportFilePathPlaceholder.SNAKE_LANGUAGE_TAG,
60-
).any { getTemplate().contains(it.placeholder) }
61-
62-
if (!containsLanguageTag) {
63-
throw getMissingPlaceholderException(
64-
ExportFilePathPlaceholder.LANGUAGE_TAG,
65-
ExportFilePathPlaceholder.ANDROID_LANGUAGE_TAG,
66-
ExportFilePathPlaceholder.SNAKE_LANGUAGE_TAG,
67-
)
55+
if (!params.format.multiLanguage) {
56+
val containsLanguageTag =
57+
arrayOf(
58+
ExportFilePathPlaceholder.LANGUAGE_TAG,
59+
ExportFilePathPlaceholder.ANDROID_LANGUAGE_TAG,
60+
ExportFilePathPlaceholder.SNAKE_LANGUAGE_TAG,
61+
).any { getTemplate().contains(it.placeholder) }
62+
63+
if (!containsLanguageTag) {
64+
throw getMissingPlaceholderException(
65+
ExportFilePathPlaceholder.LANGUAGE_TAG,
66+
ExportFilePathPlaceholder.ANDROID_LANGUAGE_TAG,
67+
ExportFilePathPlaceholder.SNAKE_LANGUAGE_TAG,
68+
)
69+
}
6870
}
6971

7072
val containsExtension = getTemplate().contains(ExportFilePathPlaceholder.EXTENSION.placeholder)

0 commit comments

Comments
 (0)