Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ enum class ExportFormat(
"application/x-xliff+xml",
defaultFileStructureTemplate = "{languageTag}.{extension}",
),
APPLE_XCSTRINGS(
"xcstrings",
"application/json",
defaultFileStructureTemplate = "Localizable.{extension}",
),
ANDROID_XML(
"xml",
"application/xml",
Expand Down
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,121 @@
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() {

override fun process() {
try {
val root = objectMapper.readTree(context.file.data.inputStream())
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 processKey(key: String, value: JsonNode) {
val localizations = value.get("localizations") ?: return

val metadata = mutableMapOf<String, Any?>()
value.fields().forEach { (fieldName, fieldValue) ->
if (fieldName != "localizations") {
metadata[fieldName] = when {
fieldValue.isTextual -> fieldValue.asText()
fieldValue.isBoolean -> fieldValue.asBoolean()
fieldValue.isObject -> objectMapper.convertValue(fieldValue, Map::class.java)
else -> fieldValue.toString()
}
}
}

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

private fun processSingleTranslation(
key: String,
languageTag: String,
localization: JsonNode,
metadata: Map<String, Any?>
) {
val stringUnit = localization.get("stringUnit")
val translationValue = stringUnit?.get("value")?.asText()

if (translationValue != null) {
context.addTranslation(
keyName = key,
languageName = languageTag,
value = translationValue,
convertedBy = importFormat,
metadata = metadata
)
}
}

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

variations.fields().forEach { (form, content) ->
val value = content.get("stringUnit")?.get("value")?.asText()
if (value != null) {
forms[form] = value
}
}

if (forms.isNotEmpty()) {
val converted = messageConvertor.convert(
forms,
languageTag,
context.importSettings.convertPlaceholdersToIcu,
context.projectIcuPlaceholdersEnabled
)

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

companion object {
private val importFormat = ImportFormat.XCSTRINGS
private val messageConvertor = importFormat.messageConvertor
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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.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 (converted.isPlural()) {
handlePluralTranslation(localizations, translation, converted.formsResult)
} else {
handleSingleTranslation(localizations, translation, converted.singleResult)
}

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

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 {
put("state", "translated")
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 {
put("state", "translated")
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,14 @@ enum class ImportFormat(
messageConvertorOrNull = appleConvertor,
),

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 @@ -55,6 +55,7 @@ data class FileProcessorContext(
replaceNonPlurals: Boolean = false,
rawData: Any? = null,
convertedBy: ImportFormat? = null,
metadata: Map<String, Any?>? = null
) {
val stringValue = value as? String

Expand Down Expand Up @@ -86,6 +87,13 @@ data class FileProcessorContext(
_translations[keyName]!!.removeIf { it.language == language && !it.isPlural }
}
_translations[keyName]!!.add(entity)

val key = getOrCreateKey(keyName)
metadata?.let { meta ->
meta["comment"]?.takeIf { it is String }?.let {
addKeyDescription(keyName, it as String)
}
}
return
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ class ExportFilePathProvider(
languageTag: String? = null,
replaceExtension: Boolean = true,
): String {
// If no template is provided, use a default template for xcstrings
if (params.fileStructureTemplate == null) {
return when {
namespace.isNullOrEmpty() -> "Localizable.xcstrings"
else -> "$namespace.xcstrings"
}
}

val template = validateAndGetTemplate()
return template
.replacePlaceholder(ExportFilePathPlaceholder.NAMESPACE, namespace ?: "")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import io.tolgee.dtos.IExportParams
import io.tolgee.dtos.cacheable.LanguageDto
import io.tolgee.formats.ExportFormat
import io.tolgee.formats.apple.out.AppleStringsStringsdictExporter
import io.tolgee.formats.apple.out.AppleXcstringsExporter
import io.tolgee.formats.apple.out.AppleXliffExporter
import io.tolgee.formats.csv.out.CsvFileExporter
import io.tolgee.formats.flutter.out.FlutterArbFileExporter
Expand Down Expand Up @@ -98,6 +99,14 @@ class FileExporterFactory(
ExportFormat.APPLE_STRINGS_STRINGSDICT ->
AppleStringsStringsdictExporter(data, exportParams, projectIcuPlaceholdersSupport)

ExportFormat.APPLE_XCSTRINGS ->
AppleXcstringsExporter(
translations = data,
exportParams = exportParams,
objectMapper = objectMapper,
isProjectIcuPlaceholdersEnabled = projectIcuPlaceholdersSupport,
)

ExportFormat.FLUTTER_ARB ->
FlutterArbFileExporter(
data,
Expand Down
Loading
Loading