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 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,113 @@
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

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

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 translationValue = stringUnit?.get("value")?.asText()

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

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 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,
)
}
}

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,139 @@
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,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
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