Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Apple String Catalog Support #2893

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
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(
neo773 marked this conversation as resolved.
Show resolved Hide resolved
"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(
neo773 marked this conversation as resolved.
Show resolved Hide resolved
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)
JanCizmar marked this conversation as resolved.
Show resolved Hide resolved
neo773 marked this conversation as resolved.
Show resolved Hide resolved
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) {
neo773 marked this conversation as resolved.
Show resolved Hide resolved
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