diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ExportInfoControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ExportInfoControllerTest.kt index 09afb1bdec..e7ce89dc62 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ExportInfoControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/ExportInfoControllerTest.kt @@ -14,19 +14,19 @@ class ExportInfoControllerTest : AbstractControllerTest() { .andAssertThatJson { node("_embedded.exportFormats") { isArray.hasSizeGreaterThan(5) - node("[0]") { + node("[1]") { node("extension").isEqualTo("json") node("mediaType").isEqualTo("application/json") node("defaultFileStructureTemplate") .isString.isEqualTo("{namespace}/{languageTag}.{extension}") } - node("[1]") { + node("[2]") { node("extension").isEqualTo("json") node("mediaType").isEqualTo("application/json") node("defaultFileStructureTemplate") .isString.isEqualTo("{namespace}/{languageTag}.{extension}") } - node("[5]") { + node("[6]") { node("extension").isEqualTo("") node("mediaType").isEqualTo("") node("defaultFileStructureTemplate") diff --git a/backend/data/build.gradle b/backend/data/build.gradle index 1fecf34701..c6df704bda 100644 --- a/backend/data/build.gradle +++ b/backend/data/build.gradle @@ -171,6 +171,7 @@ dependencies { implementation libs.jacksonKotlin implementation("org.apache.commons:commons-configuration2:2.10.1") implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:$jacksonVersion" + implementation("com.opencsv:opencsv:5.9") /** * Google translation API diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/ExportFormat.kt b/backend/data/src/main/kotlin/io/tolgee/formats/ExportFormat.kt index ba14b1d3b4..3e2ffe6fe3 100644 --- a/backend/data/src/main/kotlin/io/tolgee/formats/ExportFormat.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/ExportFormat.kt @@ -7,6 +7,7 @@ enum class ExportFormat( val mediaType: String, val defaultFileStructureTemplate: String = ExportFilePathProvider.DEFAULT_TEMPLATE, ) { + CSV("csv", "text/csv"), JSON("json", "application/json"), JSON_TOLGEE("json", "application/json"), JSON_I18NEXT("json", "application/json"), diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/ImportFileProcessorFactory.kt b/backend/data/src/main/kotlin/io/tolgee/formats/ImportFileProcessorFactory.kt index edf8d26877..9272a1c622 100644 --- a/backend/data/src/main/kotlin/io/tolgee/formats/ImportFileProcessorFactory.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/ImportFileProcessorFactory.kt @@ -6,6 +6,7 @@ import io.tolgee.dtos.dataImport.ImportFileDto import io.tolgee.exceptions.ImportCannotParseFileException import io.tolgee.formats.android.`in`.AndroidStringsXmlProcessor import io.tolgee.formats.apple.`in`.strings.StringsFileProcessor +import io.tolgee.formats.csv.`in`.CsvFileProcessor import io.tolgee.formats.flutter.`in`.FlutterArbFileProcessor import io.tolgee.formats.importCommon.ImportFileFormat import io.tolgee.formats.json.`in`.JsonFileProcessor @@ -60,6 +61,7 @@ class ImportFileProcessorFactory( ImportFileFormat.XML -> AndroidStringsXmlProcessor(context) ImportFileFormat.ARB -> FlutterArbFileProcessor(context, objectMapper) ImportFileFormat.YAML -> YamlFileProcessor(context, yamlObjectMapper) + ImportFileFormat.CSV -> CsvFileProcessor(context) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/csv/CsvModel.kt b/backend/data/src/main/kotlin/io/tolgee/formats/csv/CsvModel.kt new file mode 100644 index 0000000000..5231a56d82 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/csv/CsvModel.kt @@ -0,0 +1,7 @@ +package io.tolgee.formats.csv + +data class CsvEntry( + val key: String, + val language: String, + val value: String?, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/csv/in/CSVImportFormatDetector.kt b/backend/data/src/main/kotlin/io/tolgee/formats/csv/in/CSVImportFormatDetector.kt new file mode 100644 index 0000000000..be153fcaef --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/csv/in/CSVImportFormatDetector.kt @@ -0,0 +1,46 @@ +package io.tolgee.formats.csv.`in` + +import io.tolgee.formats.genericStructuredFile.`in`.FormatDetectionUtil +import io.tolgee.formats.genericStructuredFile.`in`.FormatDetectionUtil.ICU_DETECTION_REGEX +import io.tolgee.formats.genericStructuredFile.`in`.FormatDetectionUtil.detectFromPossibleFormats +import io.tolgee.formats.importCommon.ImportFormat +import io.tolgee.formats.paramConvertors.`in`.JavaToIcuPlaceholderConvertor +import io.tolgee.formats.paramConvertors.`in`.PhpToIcuPlaceholderConvertor +import io.tolgee.formats.paramConvertors.`in`.RubyToIcuPlaceholderConvertor + +class CSVImportFormatDetector { + companion object { + private val possibleFormats = + mapOf( + ImportFormat.CSV_ICU to + arrayOf( + FormatDetectionUtil.regexFactor( + ICU_DETECTION_REGEX, + ), + ), + ImportFormat.CSV_PHP to + arrayOf( + FormatDetectionUtil.regexFactor( + PhpToIcuPlaceholderConvertor.PHP_DETECTION_REGEX, + ), + ), + ImportFormat.CSV_JAVA to + arrayOf( + FormatDetectionUtil.regexFactor( + JavaToIcuPlaceholderConvertor.JAVA_DETECTION_REGEX, + ), + ), + ImportFormat.CSV_RUBY to + arrayOf( + FormatDetectionUtil.regexFactor( + RubyToIcuPlaceholderConvertor.RUBY_DETECTION_REGEX, + 0.95, + ), + ), + ) + } + + fun detectFormat(data: Any?): ImportFormat { + return detectFromPossibleFormats(possibleFormats, data) ?: ImportFormat.CSV_ICU + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/csv/in/CsvDelimiterDetector.kt b/backend/data/src/main/kotlin/io/tolgee/formats/csv/in/CsvDelimiterDetector.kt new file mode 100644 index 0000000000..bea4aea80b --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/csv/in/CsvDelimiterDetector.kt @@ -0,0 +1,23 @@ +package io.tolgee.formats.csv.`in` + +import java.io.InputStream + +class CsvDelimiterDetector(private val inputStream: InputStream) { + companion object { + val DELIMITERS = listOf(',', ';', '\t') + } + + val delimiter by lazy { + val headerLine = inputStream.reader().buffered().lineSequence().firstOrNull() ?: "" + val counts = + DELIMITERS.map { delimiter -> + headerLine.count { it == delimiter } + } + val bestIndex = + counts.foldIndexed(0) { index, maxIndex, value -> + val maxValue = counts[maxIndex] + index.takeIf { value > maxValue } ?: maxIndex + } + DELIMITERS[bestIndex] + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/csv/in/CsvFileParser.kt b/backend/data/src/main/kotlin/io/tolgee/formats/csv/in/CsvFileParser.kt new file mode 100644 index 0000000000..1a5de42bd2 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/csv/in/CsvFileParser.kt @@ -0,0 +1,61 @@ +package io.tolgee.formats.csv.`in` + +import com.opencsv.CSVParserBuilder +import com.opencsv.CSVReaderBuilder +import io.tolgee.formats.csv.CsvEntry +import java.io.InputStream + +class CsvFileParser( + private val inputStream: InputStream, + private val delimiter: Char, + private val languageFallback: String, +) { + val rawData: List> by lazy { + val inputReader = inputStream.reader() + val parser = CSVParserBuilder().withSeparator(delimiter).build() + val reader = CSVReaderBuilder(inputReader).withCSVParser(parser).build() + + return@lazy reader.readAll() + } + + val headers: Array? by lazy { + rawData.firstOrNull() + } + + val languages: List by lazy { + headers?.takeIf { it.size > 1 }?.drop(1) ?: emptyList() + } + + val languagesWithFallback: Sequence + get() = languages.asSequence().plus(generateSequence { languageFallback }) + + val rows: List> by lazy { + rawData.takeIf { it.size > 1 }?.drop(1) ?: emptyList() + } + + fun Array.rowToCsvEntries(): Sequence { + if (isEmpty()) { + return emptySequence() + } + val keyName = getOrNull(0) ?: "" + if (size == 1) { + return sequenceOf(CsvEntry(keyName, languageFallback, null)) + } + val translations = drop(1).asSequence() + return translations + .zip(languagesWithFallback) + .map { (translation, languageTag) -> + CsvEntry( + keyName, + languageTag, + translation, + ) + } + } + + fun parse(): List { + return rows.flatMap { + it.rowToCsvEntries() + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/csv/in/CsvFileProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/csv/in/CsvFileProcessor.kt new file mode 100644 index 0000000000..3f251a556f --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/csv/in/CsvFileProcessor.kt @@ -0,0 +1,63 @@ +package io.tolgee.formats.csv.`in` + +import io.tolgee.exceptions.ImportCannotParseFileException +import io.tolgee.formats.ImportFileProcessor +import io.tolgee.formats.csv.CsvEntry +import io.tolgee.formats.importCommon.ImportFormat +import io.tolgee.service.dataImport.processors.FileProcessorContext + +class CsvFileProcessor( + override val context: FileProcessorContext, +) : ImportFileProcessor() { + override fun process() { + val (data, format) = parse() + data.importAll(format) + } + + fun Iterable.importAll(format: ImportFormat) { + forEachIndexed { idx, it -> it.import(idx, format) } + } + + fun CsvEntry.import( + index: Int, + format: ImportFormat, + ) { + val converted = + format.messageConvertor.convert( + value, + language, + convertPlaceholders = context.importSettings.convertPlaceholdersToIcu, + isProjectIcuEnabled = context.projectIcuPlaceholdersEnabled, + ) + context.addTranslation( + key, + language, + converted.message, + index, + pluralArgName = converted.pluralArgName, + rawData = value, + convertedBy = format, + ) + } + + private fun parse(): Pair, ImportFormat> { + try { + val detector = CsvDelimiterDetector(context.file.data.inputStream()) + val parser = + CsvFileParser( + inputStream = context.file.data.inputStream(), + delimiter = detector.delimiter, + languageFallback = firstLanguageTagGuessOrUnknown, + ) + val data = parser.parse() + val format = getFormat(parser.rows) + return data to format + } catch (e: Exception) { + throw ImportCannotParseFileException(context.file.name, e.message ?: "", e) + } + } + + private fun getFormat(data: Any?): ImportFormat { + return context.mapping?.format ?: CSVImportFormatDetector().detectFormat(data) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/csv/out/CsvFileExporter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/csv/out/CsvFileExporter.kt new file mode 100644 index 0000000000..fbba94f1e5 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/csv/out/CsvFileExporter.kt @@ -0,0 +1,68 @@ +package io.tolgee.formats.csv.out + +import io.tolgee.dtos.IExportParams +import io.tolgee.formats.ExportMessageFormat +import io.tolgee.formats.csv.CsvEntry +import io.tolgee.formats.generic.IcuToGenericFormatMessageConvertor +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.service.export.exporters.FileExporter +import java.io.InputStream + +class CsvFileExporter( + val translations: List, + val exportParams: IExportParams, + private val isProjectIcuPlaceholdersEnabled: Boolean = true, +) : FileExporter { + private val fileName + get() = exportParams.languages?.takeIf { it.size == 1 }?.iterator()?.next() ?: "exported" + + private val messageFormat + get() = exportParams.messageFormat ?: ExportMessageFormat.ICU + + private val placeholderConvertorFactory + get() = messageFormat.paramConvertorFactory + + val entries = + translations.map { + val converted = convertMessage(it.text, it.key.isPlural) + CsvEntry( + key = it.key.name, + language = it.languageTag, + value = converted, + ) + } + + private fun convertMessage( + text: String?, + isPlural: Boolean, + ): String? { + return getMessageConvertor(text, isPlural).convert() + } + + private fun getMessageConvertor( + text: String?, + isPlural: Boolean, + ) = IcuToGenericFormatMessageConvertor( + text, + isPlural, + isProjectIcuPlaceholdersEnabled = isProjectIcuPlaceholdersEnabled, + paramConvertorFactory = placeholderConvertorFactory, + ) + + override fun produceFiles(): Map { + return mapOf( + "$fileName.csv" to entries.toCsv(), + ) + } + + private fun List.toCsv(): InputStream { + val languageTags = + exportParams.languages?.sorted()?.toTypedArray() + ?: this.map { it.language }.distinct().sorted().toTypedArray() + return CsvFileWriter( + languageTags = languageTags, + data = this, + delimiter = ',', + ).produceFiles() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/csv/out/CsvFileWriter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/csv/out/CsvFileWriter.kt new file mode 100644 index 0000000000..01fce234fd --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/csv/out/CsvFileWriter.kt @@ -0,0 +1,35 @@ +package io.tolgee.formats.csv.out + +import com.opencsv.CSVWriterBuilder +import io.tolgee.formats.csv.CsvEntry +import java.io.InputStream +import java.io.StringWriter + +class CsvFileWriter( + private val languageTags: Array, + private val data: List, + private val delimiter: Char, +) { + val translations: Map> by lazy { + data.groupBy { it.key }.mapValues { (_, values) -> + values.associate { it.language to it.value } + } + } + + fun produceFiles(): InputStream { + val output = StringWriter() + val writer = CSVWriterBuilder(output).withSeparator(delimiter).build() + writer.writeNext( + arrayOf("key") + languageTags, + ) + translations.forEach { + writer.writeNext( + arrayOf(it.key) + + languageTags.map { languageTag -> + it.value.getOrDefault(languageTag, null) ?: "" + }.toTypedArray(), + ) + } + return output.toString().byteInputStream() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/genericStructuredFile/in/FormatDetectionUtil.kt b/backend/data/src/main/kotlin/io/tolgee/formats/genericStructuredFile/in/FormatDetectionUtil.kt index 2930ef431e..47a6c08a5f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/formats/genericStructuredFile/in/FormatDetectionUtil.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/genericStructuredFile/in/FormatDetectionUtil.kt @@ -36,6 +36,7 @@ object FormatDetectionUtil { when (data) { is Map<*, *> -> data.forEach { (_, value) -> processMapRecursive(value, regex, hits, total) } is List<*> -> data.forEach { item -> processMapRecursive(item, regex, hits, total) } + is Array<*> -> data.forEach { item -> processMapRecursive(item, regex, hits, total) } else -> { if (data is String) { val count = regex.findAll(data).count() diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/importCommon/ImportFileFormat.kt b/backend/data/src/main/kotlin/io/tolgee/formats/importCommon/ImportFileFormat.kt index 2fdaefd882..ca1d737d33 100644 --- a/backend/data/src/main/kotlin/io/tolgee/formats/importCommon/ImportFileFormat.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/importCommon/ImportFileFormat.kt @@ -10,6 +10,7 @@ enum class ImportFileFormat(val extensions: Array) { XML(arrayOf("xml")), ARB(arrayOf("arb")), YAML(arrayOf("yaml", "yml")), + CSV(arrayOf("csv")), ; companion object { diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/importCommon/ImportFormat.kt b/backend/data/src/main/kotlin/io/tolgee/formats/importCommon/ImportFormat.kt index c59c828578..936f7ad259 100644 --- a/backend/data/src/main/kotlin/io/tolgee/formats/importCommon/ImportFormat.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/importCommon/ImportFormat.kt @@ -15,6 +15,27 @@ enum class ImportFormat( val messageConvertorOrNull: ImportMessageConvertor? = null, val rootKeyIsLanguageTag: Boolean = false, ) { + CSV_ICU( + ImportFileFormat.CSV, + messageConvertorOrNull = + GenericMapPluralImportRawDataConvertor( + canContainIcu = true, + toIcuPlaceholderConvertorFactory = null, + ), + ), + CSV_JAVA( + ImportFileFormat.CSV, + messageConvertorOrNull = GenericMapPluralImportRawDataConvertor { JavaToIcuPlaceholderConvertor() }, + ), + CSV_PHP( + ImportFileFormat.CSV, + messageConvertorOrNull = GenericMapPluralImportRawDataConvertor { PhpToIcuPlaceholderConvertor() }, + ), + CSV_RUBY( + ImportFileFormat.CSV, + messageConvertorOrNull = GenericMapPluralImportRawDataConvertor { RubyToIcuPlaceholderConvertor() }, + ), + JSON_I18NEXT( ImportFileFormat.JSON, messageConvertorOrNull = GenericMapPluralImportRawDataConvertor { I18nextToIcuPlaceholderConvertor() }, diff --git a/backend/data/src/main/kotlin/io/tolgee/service/export/FileExporterFactory.kt b/backend/data/src/main/kotlin/io/tolgee/service/export/FileExporterFactory.kt index e52863ef78..c7eb1d5c92 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/export/FileExporterFactory.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/export/FileExporterFactory.kt @@ -7,6 +7,7 @@ import io.tolgee.formats.ExportFormat import io.tolgee.formats.android.out.AndroidStringsXmlExporter import io.tolgee.formats.apple.out.AppleStringsStringsdictExporter import io.tolgee.formats.apple.out.AppleXliffExporter +import io.tolgee.formats.csv.out.CsvFileExporter import io.tolgee.formats.flutter.out.FlutterArbFileExporter import io.tolgee.formats.genericStructuredFile.out.CustomPrettyPrinter import io.tolgee.formats.json.out.JsonFileExporter @@ -34,6 +35,13 @@ class FileExporterFactory( projectIcuPlaceholdersSupport: Boolean, ): FileExporter { return when (exportParams.format) { + ExportFormat.CSV -> + CsvFileExporter( + data, + exportParams, + projectIcuPlaceholdersSupport, + ) + ExportFormat.JSON, ExportFormat.JSON_TOLGEE, ExportFormat.JSON_I18NEXT -> JsonFileExporter( data, diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/csv/in/CsvFormatProcessorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/csv/in/CsvFormatProcessorTest.kt new file mode 100644 index 0000000000..88e085cfd5 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/csv/in/CsvFormatProcessorTest.kt @@ -0,0 +1,432 @@ +package io.tolgee.unit.formats.csv.`in` + +import io.tolgee.formats.csv.`in`.CsvFileProcessor +import io.tolgee.testing.assert +import io.tolgee.unit.formats.PlaceholderConversionTestHelper +import io.tolgee.util.FileProcessorContextMockUtil +import io.tolgee.util.assertKey +import io.tolgee.util.assertLanguagesCount +import io.tolgee.util.assertSingle +import io.tolgee.util.assertSinglePlural +import io.tolgee.util.assertTranslations +import io.tolgee.util.custom +import io.tolgee.util.description +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class CsvFormatProcessorTest { + lateinit var mockUtil: FileProcessorContextMockUtil + + @BeforeEach + fun setup() { + mockUtil = FileProcessorContextMockUtil() + } + + // This is how to generate the test: + // 1. run the test in debug mode + // 2. copy the result of calling: + // io.tolgee.unit.util.generateTestsForImportResult(mockUtil.fileProcessorContext) + // from the debug window + @Test + fun `returns correct parsed result`() { + mockUtil.mockIt("example.csv", "src/test/resources/import/csv/example.csv") + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(2) + mockUtil.fileProcessorContext.assertTranslations("en", "key") + .assertSingle { + hasText("value") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "key") + .assertSingle { + hasText("hodnota") + } + mockUtil.fileProcessorContext.assertTranslations("en", "keyDeep.inner") + .assertSingle { + hasText("value") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "keyDeep.inner") + .assertSingle { + hasText("hodnota") + } + mockUtil.fileProcessorContext.assertTranslations("en", "keyInterpolate") + .assertSingle { + hasText("replace this {value}") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "keyInterpolate") + .assertSingle { + hasText("nahradit toto {value}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "keyInterpolateWithFormatting") + .assertSingle { + hasText("replace this {value, number}") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "keyInterpolateWithFormatting") + .assertSingle { + hasText("nahradit toto {value, number}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "keyPluralSimple") + .assertSinglePlural { + hasText( + """ + {value, plural, + one { the singular} + other { the plural {value}} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("cs", "keyPluralSimple") + .assertSinglePlural { + hasText( + """ + {value, plural, + one { jednotné číslo} + other { množné číslo {value}} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("en", "escapedCharacters") + .assertSingle { + hasText("this is a \"quote\"") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "escapedCharacters") + .assertSingle { + hasText("toto je \"citace\"") + } + mockUtil.fileProcessorContext.assertTranslations("en", "escapedCharacters2") + .assertSingle { + hasText("this is a\nnew line") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "escapedCharacters2") + .assertSingle { + hasText("toto je\nnový řádek") + } + mockUtil.fileProcessorContext.assertTranslations("en", "escapedCharacters3") + .assertSingle { + hasText("this is a \\ backslash") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "escapedCharacters3") + .assertSingle { + hasText("toto je zpětné \\ lomítko") + } + mockUtil.fileProcessorContext.assertTranslations("en", "escapedCharacters4") + .assertSingle { + hasText("this is a , comma") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "escapedCharacters4") + .assertSingle { + hasText("toto je , čárka") + } + mockUtil.fileProcessorContext.assertKey("keyPluralSimple") { + custom.assert.isNull() + description.assert.isNull() + } + } + + @Test + fun `returns correct parsed result (semicolon delimiter)`() { + mockUtil.mockIt("example.csv", "src/test/resources/import/csv/example_semicolon.csv") + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(2) + mockUtil.fileProcessorContext.assertTranslations("en", "key") + .assertSingle { + hasText("value") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "key") + .assertSingle { + hasText("hodnota") + } + mockUtil.fileProcessorContext.assertTranslations("en", "keyDeep.inner") + .assertSingle { + hasText("value") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "keyDeep.inner") + .assertSingle { + hasText("hodnota") + } + mockUtil.fileProcessorContext.assertTranslations("en", "keyInterpolate") + .assertSingle { + hasText("replace this {value}") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "keyInterpolate") + .assertSingle { + hasText("nahradit toto {value}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "keyInterpolateWithFormatting") + .assertSingle { + hasText("replace this {value, number}") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "keyInterpolateWithFormatting") + .assertSingle { + hasText("nahradit toto {value, number}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "keyPluralSimple") + .assertSinglePlural { + hasText( + """ + {value, plural, + one { the singular} + other { the plural {value}} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("cs", "keyPluralSimple") + .assertSinglePlural { + hasText( + """ + {value, plural, + one { jednotné číslo} + other { množné číslo {value}} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertKey("keyPluralSimple") { + custom.assert.isNull() + description.assert.isNull() + } + } + + @Test + fun `returns correct parsed result (tab delimiter)`() { + mockUtil.mockIt("example.csv", "src/test/resources/import/csv/example_tab.csv") + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(2) + mockUtil.fileProcessorContext.assertTranslations("en", "key") + .assertSingle { + hasText("value") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "key") + .assertSingle { + hasText("hodnota") + } + mockUtil.fileProcessorContext.assertTranslations("en", "keyDeep.inner") + .assertSingle { + hasText("value") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "keyDeep.inner") + .assertSingle { + hasText("hodnota") + } + mockUtil.fileProcessorContext.assertTranslations("en", "keyInterpolate") + .assertSingle { + hasText("replace this {value}") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "keyInterpolate") + .assertSingle { + hasText("nahradit toto {value}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "keyInterpolateWithFormatting") + .assertSingle { + hasText("replace this {value, number}") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "keyInterpolateWithFormatting") + .assertSingle { + hasText("nahradit toto {value, number}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "keyPluralSimple") + .assertSinglePlural { + hasText( + """ + {value, plural, + one { the singular} + other { the plural {value}} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("cs", "keyPluralSimple") + .assertSinglePlural { + hasText( + """ + {value, plural, + one { jednotné číslo} + other { množné číslo {value}} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertKey("keyPluralSimple") { + custom.assert.isNull() + description.assert.isNull() + } + } + + @Test + fun `import with placeholder conversion (disabled ICU)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = false) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(2) + mockUtil.fileProcessorContext.assertTranslations("en", "key") + .assertSingle { + hasText("Hello {icuPara}") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "key") + .assertSingle { + hasText("Ahoj {icuPara}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "plural") + .assertSinglePlural { + hasText( + """ + {icuPara, plural, + one {Hello one '#' '{'icuParam'}'} + other {Hello other '{'icuParam'}'} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("cs", "plural") + .assertSinglePlural { + hasText( + """ + {icuPara, plural, + one {Ahoj jedno '#' '{'icuParam'}'} + other {Ahoj jiné '{'icuParam'}'} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertKey("plural") { + custom.assert.isNull() + description.assert.isNull() + } + } + + @Test + fun `import with placeholder conversion (no conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(2) + mockUtil.fileProcessorContext.assertTranslations("en", "key") + .assertSingle { + hasText("Hello {icuPara}") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "key") + .assertSingle { + hasText("Ahoj {icuPara}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "plural") + .assertSinglePlural { + hasText( + """ + {icuPara, plural, + one {Hello one # {icuParam}} + other {Hello other {icuParam}} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("cs", "plural") + .assertSinglePlural { + hasText( + """ + {icuPara, plural, + one {Ahoj jedno # {icuParam}} + other {Ahoj jiné {icuParam}} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertKey("plural") { + custom.assert.isNull() + description.assert.isNull() + } + } + + @Test + fun `import with placeholder conversion (with conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = true, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(2) + mockUtil.fileProcessorContext.assertTranslations("en", "key") + .assertSingle { + hasText("Hello {icuPara}") + } + mockUtil.fileProcessorContext.assertTranslations("cs", "key") + .assertSingle { + hasText("Ahoj {icuPara}") + } + mockUtil.fileProcessorContext.assertTranslations("en", "plural") + .assertSinglePlural { + hasText( + """ + {icuPara, plural, + one {Hello one # {icuParam}} + other {Hello other {icuParam}} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("cs", "plural") + .assertSinglePlural { + hasText( + """ + {icuPara, plural, + one {Ahoj jedno # {icuParam}} + other {Ahoj jiné {icuParam}} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertKey("plural") { + custom.assert.isNull() + description.assert.isNull() + } + } + + @Test + fun `placeholder conversion setting application works`() { + PlaceholderConversionTestHelper.testFile( + "import.csv", + "src/test/resources/import/csv/placeholder_conversion.csv", + assertBeforeSettingsApplication = + listOf( + "this is csv {0, number}", + "this is csv", + "toto je csv {0, number}", + "toto je csv", + ), + assertAfterDisablingConversion = + listOf( + "this is csv %d", + "toto je csv %d", + ), + assertAfterReEnablingConversion = + listOf( + "this is csv {0, number}", + "toto je csv {0, number}", + ), + ) + } + + private fun mockPlaceholderConversionTestFile( + convertPlaceholders: Boolean, + projectIcuPlaceholdersEnabled: Boolean, + ) { + mockUtil.mockIt( + "import.csv", + "src/test/resources/import/csv/example_params.csv", + convertPlaceholders, + projectIcuPlaceholdersEnabled, + ) + } + + private fun processFile() { + CsvFileProcessor(mockUtil.fileProcessorContext).process() + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/csv/in/CsvImportFormatDetectorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/csv/in/CsvImportFormatDetectorTest.kt new file mode 100644 index 0000000000..b83cda08e7 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/csv/in/CsvImportFormatDetectorTest.kt @@ -0,0 +1,58 @@ +package io.tolgee.unit.formats.csv.`in` + +import io.tolgee.formats.csv.`in`.CSVImportFormatDetector +import io.tolgee.formats.csv.`in`.CsvFileParser +import io.tolgee.formats.importCommon.ImportFormat +import io.tolgee.testing.assert +import io.tolgee.util.FileProcessorContextMockUtil +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.File + +class CsvImportFormatDetectorTest { + lateinit var mockUtil: FileProcessorContextMockUtil + + @BeforeEach + fun setup() { + mockUtil = FileProcessorContextMockUtil() + } + + @Test + fun `detected i18next`() { + "src/test/resources/import/csv/example.csv".assertDetected(ImportFormat.CSV_ICU) + } + + @Test + fun `detected icu`() { + "src/test/resources/import/csv/icu.csv".assertDetected(ImportFormat.CSV_ICU) + } + + @Test + fun `detected java`() { + "src/test/resources/import/csv/java.csv".assertDetected(ImportFormat.CSV_JAVA) + } + + @Test + fun `detected php`() { + "src/test/resources/import/csv/php.csv".assertDetected(ImportFormat.CSV_PHP) + } + + @Test + fun `fallbacks to icu`() { + "src/test/resources/import/csv/unknown.csv".assertDetected(ImportFormat.CSV_ICU) + } + + private fun parseFile(path: String): Any? { + val parser = + CsvFileParser( + inputStream = File(path).inputStream(), + delimiter = ',', + languageFallback = "unknown", + ) + return parser.rows + } + + private fun String.assertDetected(format: ImportFormat) { + CSVImportFormatDetector().detectFormat(parseFile(this)).assert.isEqualTo(format) + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/csv/out/CsvFileExporterTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/csv/out/CsvFileExporterTest.kt new file mode 100644 index 0000000000..dc87853699 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/csv/out/CsvFileExporterTest.kt @@ -0,0 +1,124 @@ +package io.tolgee.unit.formats.csv.out + +import io.tolgee.dtos.request.export.ExportParams +import io.tolgee.formats.csv.out.CsvFileExporter +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.unit.util.assertFile +import io.tolgee.unit.util.getExported +import io.tolgee.util.buildExportTranslationList +import org.junit.jupiter.api.Test + +class CsvFileExporterTest { + @Test + fun `exports with placeholders (ICU placeholders disabled)`() { + val exporter = getIcuPlaceholdersDisabledExporter() + val data = getExported(exporter) + data.assertFile( + "exported.csv", + """ + |"key","cs" + |"key3","{count, plural, one {# den {icuParam}} few {# dny} other {# dní}}" + |"item","I will be first {icuParam, number}" + |"key","Text with multiple lines + |and , commas and ""quotes"" " + | + """.trimMargin(), + ) + } + + private fun getIcuPlaceholdersDisabledExporter(): CsvFileExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {'#' den '{'icuParam'}'} few {'#' dny} other {'#' dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "item", + text = "I will be first {icuParam, number}", + ) + add( + languageTag = "cs", + keyName = "key", + text = "Text with multiple lines\nand , commas and \"quotes\" ", + ) + } + return getExporter(built.translations, false) + } + + @Test + fun `exports with placeholders (ICU placeholders enabled)`() { + val exporter = getIcuPlaceholdersEnabledExporter() + val data = getExported(exporter) + data.assertFile( + "exported.csv", + """ + |"key","cs" + |"key3","{count, plural, one {# den {icuParam, number}} few {# dny} other {# dní}}" + |"item","I will be first '{'icuParam'}' {hello, number}" + | + """.trimMargin(), + ) + } + + @Test + fun `correct exports translation with colon`() { + val exporter = getExporter(getTranslationWithColon()) + val data = getExported(exporter) + data.assertFile( + "exported.csv", + """ + |"key","cs" + |"item","name : {name}" + | + """.trimMargin(), + ) + } + + private fun getTranslationWithColon(): MutableList { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "item", + text = "name : {name}", + ) + } + return built.translations + } + + private fun getIcuPlaceholdersEnabledExporter(): CsvFileExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {# den {icuParam, number}} few {# dny} other {# dní}}", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "item", + text = "I will be first '{'icuParam'}' {hello, number}", + ) + } + return getExporter(built.translations, true) + } + + private fun getExporter( + translations: List, + isProjectIcuPlaceholdersEnabled: Boolean = true, + exportParams: ExportParams = ExportParams(), + ): CsvFileExporter { + return CsvFileExporter( + translations = translations, + exportParams = exportParams, + isProjectIcuPlaceholdersEnabled = isProjectIcuPlaceholdersEnabled, + ) + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/json/in/JsonImportFormatDetectorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/json/in/JsonImportFormatDetectorTest.kt index c3c4684625..6f60dda76b 100644 --- a/backend/data/src/test/kotlin/io/tolgee/unit/formats/json/in/JsonImportFormatDetectorTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/json/in/JsonImportFormatDetectorTest.kt @@ -1,7 +1,6 @@ package io.tolgee.unit.formats.json.`in` -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import io.tolgee.formats.importCommon.ImportFormat import io.tolgee.formats.json.`in`.JsonImportFormatDetector @@ -45,7 +44,7 @@ class JsonImportFormatDetectorTest { } private fun parseFile(path: String): Map<*, *> { - return ObjectMapper(YAMLFactory()).readValue>( + return jacksonObjectMapper().readValue>( File(path) .readBytes(), ) diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/util/testGenerationUtil.kt b/backend/data/src/test/kotlin/io/tolgee/unit/util/testGenerationUtil.kt index d8de1c9f81..02d307477c 100644 --- a/backend/data/src/test/kotlin/io/tolgee/unit/util/testGenerationUtil.kt +++ b/backend/data/src/test/kotlin/io/tolgee/unit/util/testGenerationUtil.kt @@ -21,7 +21,7 @@ fun generateTestsForImportResult(fileProcessorContext: FileProcessorContext): St code.appendLine("""${i(indent)}${"\"\"\""}.trimIndent()""") } val escape = { str: String?, newLines: Boolean -> - str?.replace("\"", "\\\"").let { + str?.replace("\\", "\\\\")?.replace("\"", "\\\"").let { if (newLines) { return@let it?.replace("\n", "\\n") } diff --git a/backend/data/src/test/resources/import/csv/example.csv b/backend/data/src/test/resources/import/csv/example.csv new file mode 100644 index 0000000000..c8d7ac5ced --- /dev/null +++ b/backend/data/src/test/resources/import/csv/example.csv @@ -0,0 +1,12 @@ +"key","en","cs" +"key","value",hodnota +"keyDeep.inner","value","hodnota" +"keyInterpolate","replace this {value}",nahradit toto {value} +"keyInterpolateWithFormatting","replace this {value, number}","nahradit toto {value, number}" +"keyPluralSimple"," {value, plural, one {the singular} other {the plural {value}}}"," {value, plural, one {jednotné číslo} other {množné číslo {value}}}" +"escapedCharacters","this is a \"quote\"","toto je \"citace\"" +"escapedCharacters2","this is a +new line","toto je +nový řádek" +"escapedCharacters3","this is a \\ backslash","toto je zpětné \\ lomítko" +"escapedCharacters4","this is a , comma","toto je , čárka" \ No newline at end of file diff --git a/backend/data/src/test/resources/import/csv/example_params.csv b/backend/data/src/test/resources/import/csv/example_params.csv new file mode 100644 index 0000000000..1f794d5e63 --- /dev/null +++ b/backend/data/src/test/resources/import/csv/example_params.csv @@ -0,0 +1,3 @@ +"key","en","cs" +"key","Hello {icuPara}","Ahoj {icuPara}" +"plural","{icuPara, plural, one {Hello one # {icuParam}} other {Hello other {icuParam}}}","{icuPara, plural, one {Ahoj jedno # {icuParam}} other {Ahoj jiné {icuParam}}}" diff --git a/backend/data/src/test/resources/import/csv/example_semicolon.csv b/backend/data/src/test/resources/import/csv/example_semicolon.csv new file mode 100644 index 0000000000..34cfe6099b --- /dev/null +++ b/backend/data/src/test/resources/import/csv/example_semicolon.csv @@ -0,0 +1,6 @@ +"key";"en";"cs" +"key";"value";"hodnota" +"keyDeep.inner";"value";"hodnota" +"keyInterpolate";"replace this {value}";"nahradit toto {value}" +"keyInterpolateWithFormatting";"replace this {value, number}";"nahradit toto {value, number}" +"keyPluralSimple";" {value, plural, one {the singular} other {the plural {value}}}";" {value, plural, one {jednotné číslo} other {množné číslo {value}}}" diff --git a/backend/data/src/test/resources/import/csv/example_tab.csv b/backend/data/src/test/resources/import/csv/example_tab.csv new file mode 100644 index 0000000000..7ab151c2c0 --- /dev/null +++ b/backend/data/src/test/resources/import/csv/example_tab.csv @@ -0,0 +1,6 @@ +"key" "en" "cs" +"key" "value" "hodnota" +"keyDeep.inner" "value" "hodnota" +"keyInterpolate" "replace this {value}" "nahradit toto {value}" +"keyInterpolateWithFormatting" "replace this {value, number}" "nahradit toto {value, number}" +"keyPluralSimple" " {value, plural, one {the singular} other {the plural {value}}}" " {value, plural, one {jednotné číslo} other {množné číslo {value}}}" diff --git a/backend/data/src/test/resources/import/csv/icu.csv b/backend/data/src/test/resources/import/csv/icu.csv new file mode 100644 index 0000000000..4e5dbf8872 --- /dev/null +++ b/backend/data/src/test/resources/import/csv/icu.csv @@ -0,0 +1,2 @@ +key,en +key,{param} \ No newline at end of file diff --git a/backend/data/src/test/resources/import/csv/java.csv b/backend/data/src/test/resources/import/csv/java.csv new file mode 100644 index 0000000000..29799edbd3 --- /dev/null +++ b/backend/data/src/test/resources/import/csv/java.csv @@ -0,0 +1,3 @@ +key,en +"key","%D this is java %d" +"key2","%D this is java" \ No newline at end of file diff --git a/backend/data/src/test/resources/import/csv/php.csv b/backend/data/src/test/resources/import/csv/php.csv new file mode 100644 index 0000000000..ac696803bd --- /dev/null +++ b/backend/data/src/test/resources/import/csv/php.csv @@ -0,0 +1,2 @@ +key,en +key,"%'s with ' flag" \ No newline at end of file diff --git a/backend/data/src/test/resources/import/csv/placeholder_conversion.csv b/backend/data/src/test/resources/import/csv/placeholder_conversion.csv new file mode 100644 index 0000000000..9ede1af17d --- /dev/null +++ b/backend/data/src/test/resources/import/csv/placeholder_conversion.csv @@ -0,0 +1,3 @@ +"key","en","cs" +"key","this is csv %d","toto je csv %d" +"key2","this is csv","toto je csv" diff --git a/backend/data/src/test/resources/import/csv/simple.csv b/backend/data/src/test/resources/import/csv/simple.csv new file mode 100644 index 0000000000..c17d53b4f4 --- /dev/null +++ b/backend/data/src/test/resources/import/csv/simple.csv @@ -0,0 +1,3 @@ +"key","en","cs" +"key","this is csv {count}","toto je csv {count}" +"key2","this is csv","toto je csv" diff --git a/backend/data/src/test/resources/import/csv/unknown.csv b/backend/data/src/test/resources/import/csv/unknown.csv new file mode 100644 index 0000000000..9c711ab70c --- /dev/null +++ b/backend/data/src/test/resources/import/csv/unknown.csv @@ -0,0 +1,2 @@ +key,en +key,This suits php and java \ No newline at end of file diff --git a/e2e/cypress/common/export.ts b/e2e/cypress/common/export.ts index 788e6bc8ea..5dfb86a6a7 100644 --- a/e2e/cypress/common/export.ts +++ b/e2e/cypress/common/export.ts @@ -224,6 +224,16 @@ export const testExportFormats = ( structureDelimiter: '.', }, }); + + testFormatWithMessageFormats( + ['ICU', 'PHP Sprintf', 'C Sprintf', 'Ruby Sprintf', 'Java String.format'], + { + format: 'CSV', + expectedParams: { + format: 'CSV', + }, + } + ); }; const testFormat = ( diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 9a810ebd41..2a6febfd4e 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -1168,6 +1168,29 @@ export interface components { | "SERVER_ADMIN"; /** @description The user's permission type. This field is null if uses granular permissions */ type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; + /** + * @description List of languages user can translate to. If null, all languages editing is permitted. + * @example 200001,200004 + */ + translateLanguageIds?: number[]; + /** + * @description List of languages user can change state to. If null, changing state of all language values is permitted. + * @example 200001,200004 + */ + stateChangeLanguageIds?: number[]; + /** + * @deprecated + * @description Deprecated (use translateLanguageIds). + * + * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. + * @example 200001,200004 + */ + permittedLanguageIds?: number[]; + /** + * @description List of languages user can view. If null, all languages view is permitted. + * @example 200001,200004 + */ + viewLanguageIds?: number[]; /** * @description Granted scopes to the user. When user has type permissions, this field contains permission scopes of the type. * @example KEYS_EDIT,TRANSLATIONS_VIEW @@ -1202,29 +1225,6 @@ export interface components { | "tasks.view" | "tasks.edit" )[]; - /** - * @description List of languages user can view. If null, all languages view is permitted. - * @example 200001,200004 - */ - viewLanguageIds?: number[]; - /** - * @description List of languages user can translate to. If null, all languages editing is permitted. - * @example 200001,200004 - */ - translateLanguageIds?: number[]; - /** - * @description List of languages user can change state to. If null, changing state of all language values is permitted. - * @example 200001,200004 - */ - stateChangeLanguageIds?: number[]; - /** - * @deprecated - * @description Deprecated (use translateLanguageIds). - * - * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. - * @example 200001,200004 - */ - permittedLanguageIds?: number[]; }; LanguageModel: { /** Format: int64 */ @@ -1814,8 +1814,8 @@ export interface components { secretKey?: string; endpoint: string; signingRegion: string; - enabled?: boolean; contentStorageType?: "S3" | "AZURE"; + enabled?: boolean; }; AzureContentStorageConfigModel: { containerName?: string; @@ -1865,6 +1865,7 @@ export interface components { languages?: string[]; /** @description Format to export to */ format: + | "CSV" | "JSON" | "JSON_TOLGEE" | "JSON_I18NEXT" @@ -1964,6 +1965,7 @@ export interface components { languages?: string[]; /** @description Format to export to */ format: + | "CSV" | "JSON" | "JSON_TOLGEE" | "JSON_I18NEXT" @@ -2087,12 +2089,12 @@ export interface components { createNewKeys: boolean; }; ImportSettingsModel: { - /** @description If false, only updates keys, skipping the creation of new keys */ - createNewKeys: boolean; /** @description If true, placeholders from other formats will be converted to ICU when possible */ convertPlaceholdersToIcu: boolean; /** @description If true, key descriptions will be overridden by the import */ overrideKeyDescriptions: boolean; + /** @description If false, only updates keys, skipping the creation of new keys */ + createNewKeys: boolean; }; TranslationCommentModel: { /** @@ -2249,17 +2251,17 @@ export interface components { }; RevealedPatModel: { token: string; - description: string; /** Format: int64 */ id: number; - /** Format: int64 */ - lastUsedAt?: number; - /** Format: int64 */ - expiresAt?: number; + description: string; /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; + /** Format: int64 */ + expiresAt?: number; + /** Format: int64 */ + lastUsedAt?: number; }; SetOrganizationRoleDto: { roleType: "MEMBER" | "OWNER"; @@ -2396,19 +2398,19 @@ export interface components { RevealedApiKeyModel: { /** @description Resulting user's api key */ key: string; - description: string; /** Format: int64 */ id: number; + userFullName?: string; projectName: string; + description: string; username?: string; - /** Format: int64 */ - lastUsedAt?: number; scopes: string[]; /** Format: int64 */ + projectId: number; + /** Format: int64 */ expiresAt?: number; /** Format: int64 */ - projectId: number; - userFullName?: string; + lastUsedAt?: number; }; SuperTokenRequest: { /** @description Has to be provided when TOTP enabled */ @@ -2951,6 +2953,10 @@ export interface components { * It is recommended to provide these values to prevent any issues with format detection. */ format?: + | "CSV_ICU" + | "CSV_JAVA" + | "CSV_PHP" + | "CSV_RUBY" | "JSON_I18NEXT" | "JSON_ICU" | "JSON_JAVA" @@ -3100,6 +3106,7 @@ export interface components { languages?: string[]; /** @description Format to export to */ format: + | "CSV" | "JSON" | "JSON_TOLGEE" | "JSON_I18NEXT" @@ -3571,15 +3578,10 @@ export interface components { | "TASKS" )[]; quickStart?: components["schemas"]["QuickStartModel"]; - /** @example This is a beautiful organization full of beautiful and clever people */ - description?: string; /** @example Beautiful organization */ name: string; /** Format: int64 */ id: number; - avatar?: components["schemas"]["Avatar"]; - /** @example btforg */ - slug: string; /** * @description The role of currently authorized user. * @@ -3587,6 +3589,11 @@ export interface components { */ currentUserRole?: "MEMBER" | "OWNER"; basePermissions: components["schemas"]["PermissionModel"]; + /** @example This is a beautiful organization full of beautiful and clever people */ + description?: string; + avatar?: components["schemas"]["Avatar"]; + /** @example btforg */ + slug: string; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -3632,6 +3639,7 @@ export interface components { }; ExportFormatModel: { format: + | "CSV" | "JSON" | "JSON_TOLGEE" | "JSON_I18NEXT" @@ -3649,9 +3657,9 @@ export interface components { defaultFileStructureTemplate: string; }; DocItem: { - description?: string; name: string; displayName?: string; + description?: string; }; PagedModelProjectModel: { _embedded?: { @@ -3746,23 +3754,23 @@ export interface components { formalitySupported: boolean; }; KeySearchResultView: { - description?: string; name: string; /** Format: int64 */ id: number; + baseTranslation?: string; namespace?: string; + description?: string; translation?: string; - baseTranslation?: string; }; KeySearchSearchResultModel: { view?: components["schemas"]["KeySearchResultView"]; - description?: string; name: string; /** Format: int64 */ id: number; + baseTranslation?: string; namespace?: string; + description?: string; translation?: string; - baseTranslation?: string; }; PagedModelKeySearchSearchResultModel: { _embedded?: { @@ -4329,17 +4337,17 @@ export interface components { }; PatWithUserModel: { user: components["schemas"]["SimpleUserAccountModel"]; - description: string; /** Format: int64 */ id: number; - /** Format: int64 */ - lastUsedAt?: number; - /** Format: int64 */ - expiresAt?: number; + description: string; /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; + /** Format: int64 */ + expiresAt?: number; + /** Format: int64 */ + lastUsedAt?: number; }; PagedModelOrganizationModel: { _embedded?: { @@ -4456,19 +4464,19 @@ export interface components { * @description Languages for which user has translate permission. */ permittedLanguageIds?: number[]; - description: string; /** Format: int64 */ id: number; + userFullName?: string; projectName: string; + description: string; username?: string; - /** Format: int64 */ - lastUsedAt?: number; scopes: string[]; /** Format: int64 */ + projectId: number; + /** Format: int64 */ expiresAt?: number; /** Format: int64 */ - projectId: number; - userFullName?: string; + lastUsedAt?: number; }; PagedModelUserAccountModel: { _embedded?: { @@ -12257,6 +12265,7 @@ export interface operations { languages?: string[]; /** Format to export to */ format?: + | "CSV" | "JSON" | "JSON_TOLGEE" | "JSON_I18NEXT" diff --git a/webapp/src/svgs/logos/csv.svg b/webapp/src/svgs/logos/csv.svg new file mode 100644 index 0000000000..92167f0266 --- /dev/null +++ b/webapp/src/svgs/logos/csv.svg @@ -0,0 +1,3 @@ + + + diff --git a/webapp/src/views/projects/export/components/formatGroups.tsx b/webapp/src/views/projects/export/components/formatGroups.tsx index bb4bd5f897..bc1d8d8afc 100644 --- a/webapp/src/views/projects/export/components/formatGroups.tsx +++ b/webapp/src/views/projects/export/components/formatGroups.tsx @@ -143,6 +143,19 @@ export const formatGroups: FormatGroup[] = [ 'C_SPRINTF', ], }, + { + id: 'generic_csv', + extension: 'csv', + name: , + format: 'CSV', + supportedMessageFormats: [ + 'ICU', + 'JAVA_STRING_FORMAT', + 'PHP_SPRINTF', + 'C_SPRINTF', + 'RUBY_SPRINTF', + ], + }, ], }, { diff --git a/webapp/src/views/projects/import/component/ImportSupportedFormats.tsx b/webapp/src/views/projects/import/component/ImportSupportedFormats.tsx index 7a265a1495..24ca9877ec 100644 --- a/webapp/src/views/projects/import/component/ImportSupportedFormats.tsx +++ b/webapp/src/views/projects/import/component/ImportSupportedFormats.tsx @@ -11,6 +11,7 @@ import AndroidLogo from 'tg.svgs/logos/android.svg?react'; import FluttrerLogo from 'tg.svgs/logos/flutter.svg?react'; import RailsLogo from 'tg.svgs/logos/rails.svg?react'; import I18nextLogo from 'tg.svgs/logos/i18next.svg?react'; +import CsvLogo from 'tg.svgs/logos/csv.svg?react'; const TechLogo = ({ svg, @@ -55,6 +56,7 @@ const FORMATS = [ { name: 'Flutter ARB', logo: }, { name: 'Ruby YAML', logo: }, { name: 'i18next', logo: }, + { name: 'CSV', logo: }, ]; export const ImportSupportedFormats = () => {