Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ enum class ExportFormat(
val extension: String,
val mediaType: String,
val defaultFileStructureTemplate: String = ExportFilePathProvider.DEFAULT_TEMPLATE,
val multiLanguage: Boolean = false,
) {
JSON("json", "application/json"),
JSON_TOLGEE("json", "application/json"),
Expand Down Expand Up @@ -43,4 +44,10 @@ enum class ExportFormat(
CSV("csv", "text/csv"),
RESX_ICU("resx", "text/microsoft-resx"),
XLSX("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
APPLE_XCSTRINGS(
"xcstrings",
"application/json",
defaultFileStructureTemplate = "Localizable.{extension}",
multiLanguage = true,
),
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
import io.tolgee.dtos.dataImport.ImportFileDto
import io.tolgee.exceptions.ImportCannotParseFileException
import io.tolgee.formats.apple.`in`.strings.StringsFileProcessor
import io.tolgee.formats.apple.`in`.xcstrings.XcstringsFileProcessor
import io.tolgee.formats.csv.`in`.CsvFileProcessor
import io.tolgee.formats.flutter.`in`.FlutterArbFileProcessor
import io.tolgee.formats.importCommon.ImportFileFormat
Expand Down Expand Up @@ -66,6 +67,7 @@ class ImportFileProcessorFactory(
ImportFileFormat.CSV -> CsvFileProcessor(context)
ImportFileFormat.RESX -> ResxProcessor(context)
ImportFileFormat.XLSX -> XlsxFileProcessor(context)
ImportFileFormat.XCSTRINGS -> XcstringsFileProcessor(context, objectMapper)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package io.tolgee.formats.apple.`in`.xcstrings

import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import io.tolgee.exceptions.ImportCannotParseFileException
import io.tolgee.formats.ImportFileProcessor
import io.tolgee.formats.apple.`in`.guessNamespaceFromPath
import io.tolgee.formats.importCommon.ImportFormat
import io.tolgee.service.dataImport.processors.FileProcessorContext

class XcstringsFileProcessor(
override val context: FileProcessorContext,
private val objectMapper: ObjectMapper,
) : ImportFileProcessor() {
private lateinit var sourceLanguage: String

override fun process() {
try {
val root = objectMapper.readTree(context.file.data.inputStream())
sourceLanguage = root.get("sourceLanguage")?.asText()
?: throw ImportCannotParseFileException(context.file.name, "Missing sourceLanguage in xcstrings file")

val strings =
root.get("strings")
?: throw ImportCannotParseFileException(context.file.name, "Missing 'strings' object in xcstrings file")

strings.fields().forEach { (key, value) ->
processKey(key, value)
}

context.namespace = guessNamespaceFromPath(context.file.name)
} catch (e: Exception) {
throw ImportCannotParseFileException(context.file.name, e.message)
}
}

private fun addConvertedTranslation(
key: String,
languageTag: String,
rawValue: String,
forms: Map<String, String>? = null,
) {
val converted =
messageConvertor.convert(
rawData = if (forms != null) forms else rawValue,
languageTag = languageTag,
convertPlaceholders = context.importSettings.convertPlaceholdersToIcu,
isProjectIcuEnabled = context.projectIcuPlaceholdersEnabled,
)

context.addTranslation(
keyName = key,
languageName = languageTag,
value = converted.message,
convertedBy = importFormat,
rawData = forms ?: rawValue,
pluralArgName = converted.pluralArgName,
)
}

private fun processKey(
key: String,
value: JsonNode,
) {
val localizations = value.get("localizations") ?: return

value.get("comment")?.asText()?.let { comment ->
context.addKeyDescription(key, comment)
}

if (!localizations.has(sourceLanguage)) {
addConvertedTranslation(key, sourceLanguage, key)
}

localizations.fields().forEach { (languageTag, localization) ->
when {
localization.has("stringUnit") -> {
processSingleTranslation(key, languageTag, localization)
}

localization.has("variations") -> {
processPluralTranslation(key, languageTag, localization)
}
}
}
}

private fun processSingleTranslation(
key: String,
languageTag: String,
localization: JsonNode,
) {
val stringUnit = localization.get("stringUnit")
val xcState = stringUnit?.get("state")?.asText()

val translationValue = stringUnit?.get("value")?.asText()

if (translationValue != null) {
addConvertedTranslation(key, languageTag, translationValue)
return
}
}

private fun processPluralTranslation(
key: String,
languageTag: String,
localization: JsonNode,
) {
val variations = localization.get("variations")?.get("plural") ?: return
val forms = mutableMapOf<String, String>()

variations.fields().forEach { (form, content) ->
val stringUnit = content.get("stringUnit")
val value = stringUnit?.get("value")?.asText()

if (value != null) {
forms[form] = value
}
}

if (forms.isNotEmpty()) {
addConvertedTranslation(key, languageTag, "", forms)
}
}

companion object {
private val importFormat = ImportFormat.APPLE_XCSTRINGS
private val messageConvertor = importFormat.messageConvertor
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package io.tolgee.formats.apple.out

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
import io.tolgee.dtos.IExportParams
import io.tolgee.model.enums.TranslationState
import io.tolgee.service.export.ExportFilePathProvider
import io.tolgee.service.export.dataProvider.ExportTranslationView
import io.tolgee.service.export.exporters.FileExporter
import java.io.InputStream

class AppleXcstringsExporter(
private val translations: List<ExportTranslationView>,
private val exportParams: IExportParams,
private val objectMapper: ObjectMapper,
private val isProjectIcuPlaceholdersEnabled: Boolean = true,
) : FileExporter {
private val preparedFiles = mutableMapOf<String, ObjectNode>()

override fun produceFiles(): Map<String, InputStream> {
translations.forEach { handleTranslation(it) }

return preparedFiles.mapValues { (_, jsonContent) ->
val root =
objectMapper.createObjectNode().apply {
put("sourceLanguage", exportParams.languages?.firstOrNull() ?: "en")
put("version", "1.0")
set<ObjectNode>("strings", jsonContent)
}
objectMapper.writeValueAsString(root).byteInputStream()
}
}

private fun handleTranslation(translation: ExportTranslationView) {
val baseFilePath = getBaseFilePath(translation)
val fileContent = preparedFiles.getOrPut(baseFilePath) { objectMapper.createObjectNode() }

val keyData =
fileContent.get(translation.key.name)?.let {
it as ObjectNode
} ?: createKeyEntry(translation)
fileContent.set<ObjectNode>(translation.key.name, keyData)

val converted =
IcuToAppleMessageConvertor(
message = translation.text ?: "",
translation.key.isPlural,
isProjectIcuPlaceholdersEnabled,
).convert()

val localizations =
keyData.get("localizations")?.let {
it as ObjectNode
} ?: objectMapper.createObjectNode()

if (translation.description != null) {
keyData.put("comment", translation.description)
}

if (converted.isPlural()) {
handlePluralTranslation(localizations, translation, converted.formsResult)
} else {
handleSingleTranslation(localizations, translation, converted.singleResult)
}

keyData.set<ObjectNode>("localizations", localizations)
}

private fun getAppleState(state: TranslationState): String? {
return when (state) {
TranslationState.TRANSLATED -> "needs_review"
TranslationState.REVIEWED -> "translated"
TranslationState.UNTRANSLATED -> null
TranslationState.DISABLED -> null
}
}

private fun handleSingleTranslation(
localizations: ObjectNode,
translation: ExportTranslationView,
convertedText: String?,
) {
if (convertedText == null) return

localizations.set<ObjectNode>(
translation.languageTag,
objectMapper.createObjectNode().apply {
set<ObjectNode>(
"stringUnit",
objectMapper.createObjectNode().apply {
getAppleState(translation.state)?.let { state ->
put("state", state)
}
put("value", convertedText)
},
)
},
)
}

private fun handlePluralTranslation(
localizations: ObjectNode,
translation: ExportTranslationView,
forms: Map<String, String>?,
) {
if (forms == null) return

val pluralForms = objectMapper.createObjectNode()
forms.forEach { (form, text) ->
pluralForms.set<ObjectNode>(
form,
objectMapper.createObjectNode().apply {
set<ObjectNode>(
"stringUnit",
objectMapper.createObjectNode().apply {
getAppleState(translation.state)?.let { state ->
put("state", state)
}
put("value", text)
},
)
},
)
}

localizations.set<ObjectNode>(
translation.languageTag,
objectMapper.createObjectNode().apply {
set<ObjectNode>(
"variations",
objectMapper.createObjectNode().apply {
set<ObjectNode>("plural", pluralForms)
},
)
},
)
}

private fun createKeyEntry(translation: ExportTranslationView): ObjectNode {
return objectMapper.createObjectNode().apply {
translation.key.description?.let {
put("extractionState", "manual")
}
}
}

private fun getBaseFilePath(translation: ExportTranslationView): String {
return ExportFilePathProvider(
exportParams,
"xcstrings",
).getFilePath(
translation.key.namespace,
null,
replaceExtension = true,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ enum class ImportFileFormat(val extensions: Array<String>) {
STRINGS(arrayOf("strings")),
STRINGSDICT(arrayOf("stringsdict")),
XLIFF(arrayOf("xliff", "xlf")),
XCSTRINGS(arrayOf("xcstrings")),
PROPERTIES(arrayOf("properties")),
XML(arrayOf("xml")),
ARB(arrayOf("arb")),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ enum class ImportFormat(
messageConvertorOrNull = appleConvertor,
),

APPLE_XCSTRINGS(
fileFormat = ImportFileFormat.XCSTRINGS,
messageConvertorOrNull =
GenericMapPluralImportRawDataConvertor(
optimizePlurals = true,
canContainIcu = false,
) { AppleToIcuPlaceholderConvertor() },
),

// properties don't store plurals in map, but it doesn't matter.
// Since they don't support nesting at all, we cannot have plurals by nesting in them, so the plural extracting
// code won't be executed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,21 @@ class ExportFilePathProvider(
}

private fun validateTemplate() {
val containsLanguageTag =
arrayOf(
ExportFilePathPlaceholder.LANGUAGE_TAG,
ExportFilePathPlaceholder.ANDROID_LANGUAGE_TAG,
ExportFilePathPlaceholder.SNAKE_LANGUAGE_TAG,
).any { getTemplate().contains(it.placeholder) }

if (!containsLanguageTag) {
throw getMissingPlaceholderException(
ExportFilePathPlaceholder.LANGUAGE_TAG,
ExportFilePathPlaceholder.ANDROID_LANGUAGE_TAG,
ExportFilePathPlaceholder.SNAKE_LANGUAGE_TAG,
)
if (!params.format.multiLanguage) {
val containsLanguageTag =
arrayOf(
ExportFilePathPlaceholder.LANGUAGE_TAG,
ExportFilePathPlaceholder.ANDROID_LANGUAGE_TAG,
ExportFilePathPlaceholder.SNAKE_LANGUAGE_TAG,
).any { getTemplate().contains(it.placeholder) }

if (!containsLanguageTag) {
throw getMissingPlaceholderException(
ExportFilePathPlaceholder.LANGUAGE_TAG,
ExportFilePathPlaceholder.ANDROID_LANGUAGE_TAG,
ExportFilePathPlaceholder.SNAKE_LANGUAGE_TAG,
)
}
}

val containsExtension = getTemplate().contains(ExportFilePathPlaceholder.EXTENSION.placeholder)
Expand Down
Loading