From c414b293a9f1d6f87152e19652382f943cf48440 Mon Sep 17 00:00:00 2001 From: Jiri Kuchynka Date: Wed, 8 Jan 2025 12:54:30 +0100 Subject: [PATCH] feat: import and export in dotnet resx format (#2803) --- .../kotlin/io/tolgee/formats/ExportFormat.kt | 1 + .../formats/ImportFileProcessorFactory.kt | 2 + .../formats/importCommon/ImportFileFormat.kt | 1 + .../formats/importCommon/ImportFormat.kt | 9 + .../io/tolgee/formats/resx/ResxEntry.kt | 7 + .../io/tolgee/formats/resx/in/ResxParser.kt | 95 ++ .../tolgee/formats/resx/in/ResxProcessor.kt | 56 + .../tolgee/formats/resx/out/ResxExporter.kt | 74 ++ .../io/tolgee/formats/resx/out/ResxWriter.kt | 63 + .../service/export/FileExporterFactory.kt | 4 + .../unit/formats/resx/in/ResxProcessorTest.kt | 232 ++++ .../unit/formats/resx/out/ResxExporterTest.kt | 280 +++++ .../test/resources/import/resx/strings.resx | 113 ++ .../import/resx/strings_icu_everywhere.resx | 70 ++ webapp/src/service/apiSchema.generated.ts | 1077 ++++++++++++----- webapp/src/svgs/logos/dotnet.svg | 10 + .../export/components/formatGroups.tsx | 11 + .../component/ImportSupportedFormats.tsx | 2 + 18 files changed, 1810 insertions(+), 297 deletions(-) create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/resx/ResxEntry.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/resx/in/ResxParser.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/resx/in/ResxProcessor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/resx/out/ResxExporter.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/formats/resx/out/ResxWriter.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/resx/in/ResxProcessorTest.kt create mode 100644 backend/data/src/test/kotlin/io/tolgee/unit/formats/resx/out/ResxExporterTest.kt create mode 100644 backend/data/src/test/resources/import/resx/strings.resx create mode 100644 backend/data/src/test/resources/import/resx/strings_icu_everywhere.resx create mode 100644 webapp/src/svgs/logos/dotnet.svg 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 330f8554ae..f707cb5444 100644 --- a/backend/data/src/main/kotlin/io/tolgee/formats/ExportFormat.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/ExportFormat.kt @@ -41,4 +41,5 @@ enum class ExportFormat( YAML("yaml", "application/x-yaml"), JSON_I18NEXT("json", "application/json"), CSV("csv", "text/csv"), + RESX_ICU("resx", "text/microsoft-resx"), } 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 b993cb06ab..cb2b75b3bd 100644 --- a/backend/data/src/main/kotlin/io/tolgee/formats/ImportFileProcessorFactory.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/ImportFileProcessorFactory.kt @@ -11,6 +11,7 @@ import io.tolgee.formats.importCommon.ImportFileFormat import io.tolgee.formats.json.`in`.JsonFileProcessor import io.tolgee.formats.po.`in`.PoFileProcessor import io.tolgee.formats.properties.`in`.PropertiesFileProcessor +import io.tolgee.formats.resx.`in`.ResxProcessor import io.tolgee.formats.xliff.`in`.XliffFileProcessor import io.tolgee.formats.xmlResources.`in`.XmlResourcesProcessor import io.tolgee.formats.yaml.`in`.YamlFileProcessor @@ -62,6 +63,7 @@ class ImportFileProcessorFactory( ImportFileFormat.ARB -> FlutterArbFileProcessor(context, objectMapper) ImportFileFormat.YAML -> YamlFileProcessor(context, yamlObjectMapper) ImportFileFormat.CSV -> CsvFileProcessor(context) + ImportFileFormat.RESX -> ResxProcessor(context) } } 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 ca1d737d33..f7922b305e 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 @@ -11,6 +11,7 @@ enum class ImportFileFormat(val extensions: Array) { ARB(arrayOf("arb")), YAML(arrayOf("yaml", "yml")), CSV(arrayOf("csv")), + RESX(arrayOf("resx")), ; 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 d9af97ee2e..9ff5fb5ff5 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 @@ -216,6 +216,15 @@ enum class ImportFormat( messageConvertorOrNull = GenericMapPluralImportRawDataConvertor { RubyToIcuPlaceholderConvertor() }, ), + RESX_ICU( + ImportFileFormat.RESX, + messageConvertorOrNull = + GenericMapPluralImportRawDataConvertor( + canContainIcu = true, + toIcuPlaceholderConvertorFactory = null, + ), + ), + ; val messageConvertor: ImportMessageConvertor diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/resx/ResxEntry.kt b/backend/data/src/main/kotlin/io/tolgee/formats/resx/ResxEntry.kt new file mode 100644 index 0000000000..16563cbf88 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/resx/ResxEntry.kt @@ -0,0 +1,7 @@ +package io.tolgee.formats.resx + +data class ResxEntry( + val key: String, + val data: String? = null, + val comment: String? = null, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/resx/in/ResxParser.kt b/backend/data/src/main/kotlin/io/tolgee/formats/resx/in/ResxParser.kt new file mode 100644 index 0000000000..8512cf4f65 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/resx/in/ResxParser.kt @@ -0,0 +1,95 @@ +package io.tolgee.formats.resx.`in` + +import io.tolgee.formats.resx.ResxEntry +import javax.xml.namespace.QName +import javax.xml.stream.XMLEventReader +import javax.xml.stream.events.Characters +import javax.xml.stream.events.EndElement +import javax.xml.stream.events.StartElement +import javax.xml.stream.events.XMLEvent + +class ResxParser( + private val xmlEventReader: XMLEventReader, +) { + var curState: State? = null + var currentEntry: ResxEntry? = null + var currentText: String = "" + + fun parse(): Sequence = + sequence { + while (xmlEventReader.hasNext()) { + val event = xmlEventReader.nextEvent() + when { + event.isStartElement -> handleStartElement(event.asStartElement()) + event.isEndElement -> handleEndElement(event.asEndElement()) + } + handleText(event) + } + } + + fun handleStartElement(element: StartElement) { + val name = element.name.localPart.lowercase() + if (isAnyToContentSaveOpen()) { + throw IllegalStateException("Unexpected start of xml element: $name") + } + when (name) { + "data" -> { + element.getKeyName()?.let { + currentEntry = ResxEntry(it) + } + } + + "value" -> { + currentText = "" + curState = State.READING_VALUE + } + + "comment" -> { + currentText = "" + curState = State.READING_COMMENT + } + } + } + + suspend fun SequenceScope.handleEndElement(element: EndElement) { + val name = element.name.localPart.lowercase() + when (name) { + "data" -> { + currentEntry?.let { yield(it) } + currentEntry = null + currentText = "" + curState = null + } + + "value" -> { + if (curState == State.READING_VALUE) { + currentEntry = currentEntry?.copy(data = currentText) + } + curState = null + } + + "comment" -> { + if (curState == State.READING_COMMENT) { + currentEntry = currentEntry?.copy(comment = currentText) + } + curState = null + } + } + } + + fun handleText(event: XMLEvent) { + if (!isAnyToContentSaveOpen() || event !is Characters) { + return + } + currentText += event.asCharacters().data + } + + fun isAnyToContentSaveOpen(): Boolean = curState != null + + private fun StartElement.getKeyName() = getAttributeByName(QName(null, "name"))?.value + + enum class State { + READING_VALUE, + READING_COMMENT, + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/resx/in/ResxProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/resx/in/ResxProcessor.kt new file mode 100644 index 0000000000..74e37f0e51 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/resx/in/ResxProcessor.kt @@ -0,0 +1,56 @@ +package io.tolgee.formats.resx.`in` + +import io.tolgee.exceptions.ImportCannotParseFileException +import io.tolgee.formats.ImportFileProcessor +import io.tolgee.formats.importCommon.ImportFormat +import io.tolgee.formats.resx.ResxEntry +import io.tolgee.service.dataImport.processors.FileProcessorContext +import javax.xml.stream.XMLEventReader +import javax.xml.stream.XMLInputFactory + +class ResxProcessor(override val context: FileProcessorContext) : ImportFileProcessor() { + override fun process() { + try { + val parser = ResxParser(xmlEventReader) + val data = parser.parse() + data.importAll() + } catch (e: Exception) { + throw ImportCannotParseFileException(context.file.name, e.message ?: "", e) + } + } + + fun Sequence.importAll() { + forEachIndexed { idx, it -> it.import(idx) } + } + + fun ResxEntry.import(index: Int) { + val converted = + messageConvertor.convert( + data, + firstLanguageTagGuessOrUnknown, + convertPlaceholders = context.importSettings.convertPlaceholdersToIcu, + isProjectIcuEnabled = context.projectIcuPlaceholdersEnabled, + ) + context.addKeyDescription(key, comment) + context.addTranslation( + key, + firstLanguageTagGuessOrUnknown, + converted.message, + index, + pluralArgName = converted.pluralArgName, + rawData = data, + convertedBy = importFormat, + ) + } + + private val xmlEventReader: XMLEventReader by lazy { + val inputFactory: XMLInputFactory = XMLInputFactory.newInstance() + inputFactory.createXMLEventReader(context.file.data.inputStream()) + } + + companion object { + private val importFormat = ImportFormat.RESX_ICU + + private val messageConvertor = importFormat.messageConvertor + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/resx/out/ResxExporter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/resx/out/ResxExporter.kt new file mode 100644 index 0000000000..ddb47eada8 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/resx/out/ResxExporter.kt @@ -0,0 +1,74 @@ +package io.tolgee.formats.resx.out + +import io.tolgee.dtos.IExportParams +import io.tolgee.formats.IcuToIcuPlaceholderConvertor +import io.tolgee.formats.generic.IcuToGenericFormatMessageConvertor +import io.tolgee.formats.resx.ResxEntry +import io.tolgee.service.export.ExportFilePathProvider +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.service.export.exporters.FileExporter +import java.io.InputStream +import kotlin.collections.forEach + +class ResxExporter( + val translations: List, + val exportParams: IExportParams, + private val isProjectIcuPlaceholdersEnabled: Boolean = true, +) : FileExporter { + private val fileUnits = mutableMapOf>() + + private fun getModels(): Map> { + prepare() + return fileUnits + } + + private fun prepare() { + translations.forEach { translation -> + val converted = getConvertedMessage(translation) + val entry = + ResxEntry( + key = translation.key.name, + data = converted, + comment = translation.description, + ) + + val units = getFileUnits(translation) + units.add(entry) + } + } + + private fun getFileUnits(translation: ExportTranslationView): MutableList { + val filePath = + pathProvider.getFilePath( + languageTag = translation.languageTag, + namespace = translation.key.namespace, + ) + return fileUnits.computeIfAbsent(filePath) { mutableListOf() } + } + + private val pathProvider by lazy { + ExportFilePathProvider( + exportParams, + "resx", + ) + } + + private fun getConvertedMessage( + translation: ExportTranslationView, + isPlural: Boolean = translation.key.isPlural, + ): String? { + return IcuToGenericFormatMessageConvertor( + translation.text, + isPlural, + isProjectIcuPlaceholdersEnabled, + ) { + IcuToIcuPlaceholderConvertor() + }.convert() + } + + override fun produceFiles(): Map { + return getModels().map { (path, model) -> + path to ResxWriter(model).produceFiles() + }.toMap() + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/resx/out/ResxWriter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/resx/out/ResxWriter.kt new file mode 100644 index 0000000000..4f29ea3eb3 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/formats/resx/out/ResxWriter.kt @@ -0,0 +1,63 @@ +package io.tolgee.formats.resx.out + +import io.tolgee.formats.resx.ResxEntry +import io.tolgee.util.attr +import io.tolgee.util.buildDom +import io.tolgee.util.element +import org.w3c.dom.Element +import java.io.InputStream + +class ResxWriter(private val model: List) { + fun produceFiles(): InputStream { + return buildDom { + element("root") { + addHeaders() + model.forEach { addToElement(it) } + } + }.write().toByteArray().inputStream() + } + + private fun Element.addHeaders() { + element("resheader") { + attr("name", "resmimetype") + element("value") { + textContent = "text/microsoft-resx" + } + } + element("resheader") { + attr("name", "version") + element("value") { + textContent = "2.0" + } + } + element("resheader") { + attr("name", "reader") + element("value") { + textContent = "System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral" + } + } + element("resheader") { + attr("name", "writer") + element("value") { + textContent = "System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral" + } + } + } + + private fun Element.addToElement(entry: ResxEntry) { + element("data") { + attr("name", entry.key) + attr("xml:space", "preserve") + entry.data?.let { + element("value") { + textContent = it + } + } + entry.comment?.let { + element("comment") { + textContent = it + } + } + } + } +} 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 707ada4cc6..1ee64b3b2c 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 @@ -12,6 +12,7 @@ import io.tolgee.formats.genericStructuredFile.out.CustomPrettyPrinter import io.tolgee.formats.json.out.JsonFileExporter import io.tolgee.formats.po.out.PoFileExporter import io.tolgee.formats.properties.out.PropertiesFileExporter +import io.tolgee.formats.resx.out.ResxExporter import io.tolgee.formats.xliff.out.XliffFileExporter import io.tolgee.formats.xmlResources.out.XmlResourcesExporter import io.tolgee.formats.yaml.out.YamlFileExporter @@ -105,6 +106,9 @@ class FileExporterFactory( ExportFormat.PROPERTIES -> PropertiesFileExporter(data, exportParams, projectIcuPlaceholdersSupport) + + ExportFormat.RESX_ICU -> + ResxExporter(data, exportParams, projectIcuPlaceholdersSupport) } } } diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/resx/in/ResxProcessorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/resx/in/ResxProcessorTest.kt new file mode 100644 index 0000000000..bf447f22d4 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/resx/in/ResxProcessorTest.kt @@ -0,0 +1,232 @@ +package io.tolgee.unit.formats.resx.`in` + +import io.tolgee.formats.resx.`in`.ResxProcessor +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 ResxProcessorTest { + lateinit var mockUtil: FileProcessorContextMockUtil + + @BeforeEach + fun setup() { + mockUtil = FileProcessorContextMockUtil() + mockUtil.mockIt("en.resx", "src/test/resources/import/resx/strings.resx") + } + + @Test + fun `returns correct parsed result`() { + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "Title") + .assertSingle { + hasText("Classic American Cars") + } + mockUtil.fileProcessorContext.assertTranslations("en", "HeaderString1") + .assertSingle { + hasText("Make") + } + mockUtil.fileProcessorContext.assertTranslations("en", "HeaderString2") + .assertSingle { + hasText("Model") + } + mockUtil.fileProcessorContext.assertTranslations("en", "HeaderString3") + .assertSingle { + hasText("Year") + } + mockUtil.fileProcessorContext.assertTranslations("en", "HeaderString4") + .assertSingle { + hasText("Doors") + } + mockUtil.fileProcessorContext.assertTranslations("en", "HeaderString5") + .assertSingle { + hasText("Cylinders") + } + mockUtil.fileProcessorContext.assertTranslations("en", "test") + .assertSingle { + hasText("Text with placeholders {0} and tags
and complex tags

text

") + } + mockUtil.fileProcessorContext.assertTranslations("en", "test2") + .assertSingle { + hasText( + "special \" \\\\ characters\n handling could 耀 be asdf interesting " + + "

a

lot also", + ) + } + mockUtil.fileProcessorContext.assertTranslations("en", "test3") + .assertSingle { + hasText("asdf") + } + mockUtil.fileProcessorContext.assertTranslations("en", "test4") + .assertSingle { + hasText("") + } + mockUtil.fileProcessorContext.assertTranslations("en", "test5") + .assertSingle { + hasText("") + } + mockUtil.fileProcessorContext.assertTranslations("en", "test6") + .assertSingle { + hasText("") + } + mockUtil.fileProcessorContext.assertTranslations("en", "test7") + .assertSingle { + hasText("") + } + mockUtil.fileProcessorContext.assertTranslations("en", "test8") + .assertSingle { + hasText("

") + } + mockUtil.fileProcessorContext.assertTranslations("en", "test9") + .assertSingle { + hasText("

a

") + } + mockUtil.fileProcessorContext.assertTranslations("en", "test10") + .assertSingle { + hasText("

{0}

") + } + mockUtil.fileProcessorContext.assertTranslations("en", "test11") + .assertSingle { + hasText("Text with placeholders {0} and tags
and complex tags

text

") + } + } + + @Test + fun `import with placeholder conversion (disabled ICU)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = false) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "test") + .assertSingle { + hasText("Text with placeholders {0} and tags
and complex tags

text

") + } + mockUtil.fileProcessorContext.assertTranslations("en", "test2") + .assertSinglePlural { + hasText( + """ + {num, plural, + one {Showing '#' item} + other {Showing '{'num'}' items} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("en", "test3") + .assertSingle { + hasText("Text with named placeholders {asdf}") + } + mockUtil.fileProcessorContext.assertKey("test2") { + custom.assert.isNull() + description.assert.isNull() + } + } + + @Test + fun `import with placeholder conversion (no conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = false, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "test") + .assertSingle { + hasText("Text with placeholders {0} and tags
and complex tags

text

") + } + mockUtil.fileProcessorContext.assertTranslations("en", "test2") + .assertSinglePlural { + hasText( + """ + {num, plural, + one {Showing # item} + other {Showing {num} items} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("en", "test3") + .assertSingle { + hasText("Text with named placeholders {asdf}") + } + mockUtil.fileProcessorContext.assertKey("test2") { + custom.assert.isNull() + description.assert.isNull() + } + } + + @Test + fun `import with placeholder conversion (with conversion)`() { + mockPlaceholderConversionTestFile(convertPlaceholders = true, projectIcuPlaceholdersEnabled = true) + processFile() + mockUtil.fileProcessorContext.assertLanguagesCount(1) + mockUtil.fileProcessorContext.assertTranslations("en", "test") + .assertSingle { + hasText("Text with placeholders {0} and tags
and complex tags

text

") + } + mockUtil.fileProcessorContext.assertTranslations("en", "test2") + .assertSinglePlural { + hasText( + """ + {num, plural, + one {Showing # item} + other {Showing {num} items} + } + """.trimIndent(), + ) + isPluralOptimized() + } + mockUtil.fileProcessorContext.assertTranslations("en", "test3") + .assertSingle { + hasText("Text with named placeholders {asdf}") + } + mockUtil.fileProcessorContext.assertKey("test2") { + custom.assert.isNull() + description.assert.isNull() + } + } + + @Test + fun `placeholder conversion setting application works`() { + PlaceholderConversionTestHelper.testFile( + "en.resx", + "src/test/resources/import/resx/strings_icu_everywhere.resx", + assertBeforeSettingsApplication = + listOf( + "Text with placeholders {0} and tags
and complex tags

text

", + "{num, plural,\n" + + "one {Showing # item}\n" + + "other {Showing {num} items}\n" + + "}", + "Text with named placeholders {asdf}", + ), + assertAfterDisablingConversion = + listOf(), + assertAfterReEnablingConversion = + listOf(), + ) + } + + private fun mockPlaceholderConversionTestFile( + convertPlaceholders: Boolean, + projectIcuPlaceholdersEnabled: Boolean, + ) { + mockUtil.mockIt( + "en.resx", + "src/test/resources/import/resx/strings_icu_everywhere.resx", + convertPlaceholders, + projectIcuPlaceholdersEnabled, + ) + } + + private fun processFile() { + ResxProcessor(mockUtil.fileProcessorContext).process() + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/resx/out/ResxExporterTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/resx/out/ResxExporterTest.kt new file mode 100644 index 0000000000..0137da178a --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/resx/out/ResxExporterTest.kt @@ -0,0 +1,280 @@ +package io.tolgee.unit.formats.resx.out + +import io.tolgee.dtos.request.export.ExportParams +import io.tolgee.formats.ExportFormat +import io.tolgee.formats.resx.out.ResxExporter +import io.tolgee.service.export.dataProvider.ExportTranslationView +import io.tolgee.testing.assert +import io.tolgee.util.buildExportTranslationList +import org.junit.jupiter.api.Test + +class ResxExporterTest { + @Test + fun exports() { + val exporter = getExporter() + val data = getExported(exporter) + // generate this with: + // data.map { "data.assertFile(\"${it.key}\", \"\"\"\n |${it.value.replace("\$", "\${'$'}").replace("\n", "\n |")}\n \"\"\".trimMargin())" }.joinToString("\n") + data.assertFile( + "cs.resx", + """ + | + | + | + | text/microsoft-resx + | + | + | 2.0 + | + | + | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral + | + | + | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral + | + | Ahoj! I{number, number}, {name}, {number, number, scientific}, {number, number, 0.000000} + | I am {name}! + | + | {count, plural, one {# den} few {# dny} other {# dní}}This is a description for plural + | {count, plural, one {# den} few {# dny} other {# dní}} + | I will be firstThis is a description for array item + | I will be second + | + | + """.trimMargin(), + ) + data.assertFile( + "en.resx", + """ + | + | + | + | text/microsoft-resx + | + | + | 2.0 + | + | + | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral + | + | + | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral + | + | This is english! + | {count, plural, one {{0} dog} other {{0} dogs}} + | + | + """.trimMargin(), + ) + } + + @Test + fun `honors the provided fileStructureTemplate`() { + val exporter = + getExporter( + params = + getExportParams().also { + it.fileStructureTemplate = "{languageTag}/hello/{namespace}.{extension}" + }, + ) + + val files = exporter.produceFiles() + + files["cs/hello.resx"].assert.isNotNull() + } + + @Test + fun `exports with placeholders (ICU placeholders enabled)`() { + val exporter = getIcuPlaceholdersEnabledExporter() + val data = getExported(exporter) + data.assertFile( + "cs.resx", + """ + | + | + | + | text/microsoft-resx + | + | + | 2.0 + | + | + | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral + | + | + | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral + | + | {count, plural, one {# den {icuParam}} few {# dny} other {# dní}} + | I will be first '{'icuParam'}' + | + | + """.trimMargin(), + ) + } + + @Test + fun `exports with placeholders (ICU placeholders disabled)`() { + val exporter = getIcuPlaceholdersDisabledExporter() + val data = getExported(exporter) + data.assertFile( + "cs.resx", + """ + | + | + | + | text/microsoft-resx + | + | + | 2.0 + | + | + | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral + | + | + | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral + | + | {count, plural, one {# den {icuParam} '} few {# dny} other {# dní}} + | I will be first {icuParam} '{hey}' + | + | + """.trimMargin(), + ) + } + + private fun getExported(exporter: ResxExporter): Map { + val files = exporter.produceFiles() + val data = files.map { it.key to it.value.bufferedReader().readText() }.toMap() + return data + } + + private fun Map.assertFile( + file: String, + content: String, + ) { + this[file]!!.assert.isEqualTo(content) + } + + private fun getExporter(params: ExportParams = getExportParams()): ResxExporter { + val built = + buildExportTranslationList { + add( + languageTag = "cs", + keyName = "key1", + text = + "Ahoj! I" + + "{number, number}, {name}, {number, number, scientific}, " + + "{number, number, 0.000000}", + ) + add( + languageTag = "cs", + keyName = "placeholders", + text = + "I am {name}!", + ) + add( + languageTag = "cs", + keyName = "Empty plural", + text = null, + ) { + key.isPlural = true + } + + add( + languageTag = "cs", + keyName = "key3", + text = "{count, plural, one {# den} few {# dny} other {# dní}}", + description = "This is a description for plural", + ) { + key.isPlural = true + } + add( + languageTag = "cs", + keyName = "forced_not plural", + text = "{count, plural, one {# den} few {# dny} other {# dní}}", + ) { + key.isPlural = false + } + + add( + languageTag = "cs", + keyName = "i_am_array_item[20]", + text = "I will be first", + description = "This is a description for array item", + ) + + add( + languageTag = "cs", + keyName = "i_am_array_item[100]", + text = "I will be second", + ) + + add( + languageTag = "en", + keyName = "i_am_array_english", + text = "This is english!", + ) + add( + languageTag = "en", + keyName = "plural with placeholders", + text = "{count, plural, one {{0} dog} other {{0} dogs}}", + ) { + key.isPlural = true + } + } + return getExporter(built.translations, params = params) + } + + private fun getIcuPlaceholdersEnabledExporter(): ResxExporter { + 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 = "i_am_array_item[20]", + text = "I will be first '{'icuParam'}'", + ) + } + return getExporter(built.translations, true) + } + + private fun getIcuPlaceholdersDisabledExporter(): ResxExporter { + 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 = "i_am_array_item[20]", + text = "I will be first {icuParam} '{hey}'", + ) + } + return getExporter(built.translations, false) + } + + private fun getExporter( + translations: List, + isProjectIcuPlaceholdersEnabled: Boolean = true, + params: ExportParams = getExportParams(), + ): ResxExporter { + return ResxExporter( + translations = translations, + exportParams = params, + isProjectIcuPlaceholdersEnabled = isProjectIcuPlaceholdersEnabled, + ) + } + + private fun getExportParams(): ExportParams { + return ExportParams().also { it.format = ExportFormat.RESX_ICU } + } +} diff --git a/backend/data/src/test/resources/import/resx/strings.resx b/backend/data/src/test/resources/import/resx/strings.resx new file mode 100644 index 0000000000..94a465f0f6 --- /dev/null +++ b/backend/data/src/test/resources/import/resx/strings.resx @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Classic American Cars + + + Make + + + Model + + + Year + + + Doors + + + Cylinders + + + Text with placeholders {0} and tags </br> and complex tags <p>text</p> + + + special " \\ characters + handling could 耀 be <value>asdf</value> interesting </value> <p>a</p> lot </data> also + + + <value>asdf</value> + + + </value> + + + </data> + + + </root> + + + </asdf> + + + <p> + + + <p>a</p> + + + <p>{0}</p> + + + Text with placeholders {0} and tags </br> and complex tags <p>text</p> + + \ No newline at end of file diff --git a/backend/data/src/test/resources/import/resx/strings_icu_everywhere.resx b/backend/data/src/test/resources/import/resx/strings_icu_everywhere.resx new file mode 100644 index 0000000000..6ac8f81fef --- /dev/null +++ b/backend/data/src/test/resources/import/resx/strings_icu_everywhere.resx @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Text with placeholders {0} and tags </br> and complex tags <p>text</p> + + + {num, plural, one {Showing # item} other {Showing {num} items}} + + + Text with named placeholders {asdf} + + \ No newline at end of file diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index a82e0af2f5..c5a36394e3 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -343,6 +343,12 @@ export interface paths { /** Pairs user account with slack account. */ post: operations["userLogin"]; }; + "/v2/public/translator/translate": { + post: operations["translate"]; + }; + "/v2/public/telemetry/report": { + post: operations["report"]; + }; "/v2/public/slack": { post: operations["slackCommand"]; }; @@ -358,8 +364,26 @@ export interface paths { */ post: operations["fetchBotEvent"]; }; + "/v2/public/licensing/subscription": { + post: operations["getMySubscription"]; + }; + "/v2/public/licensing/set-key": { + post: operations["onLicenceSetKey"]; + }; + "/v2/public/licensing/report-usage": { + post: operations["reportUsage"]; + }; + "/v2/public/licensing/report-error": { + post: operations["reportError"]; + }; + "/v2/public/licensing/release-key": { + post: operations["releaseKey"]; + }; + "/v2/public/licensing/prepare-set-key": { + post: operations["prepareSetLicenseKey"]; + }; "/v2/public/business-events/report": { - post: operations["report"]; + post: operations["report_1"]; }; "/v2/public/business-events/identify": { post: operations["identify"]; @@ -438,7 +462,7 @@ export interface paths { }; "/v2/projects/{projectId}/start-batch-job/pre-translate-by-tm": { /** Pre-translate provided keys to provided languages by TM. */ - post: operations["translate"]; + post: operations["translate_1"]; }; "/v2/projects/{projectId}/start-batch-job/machine-translate": { /** Translate provided keys to provided languages through primary MT provider. */ @@ -524,7 +548,7 @@ export interface paths { }; "/v2/ee-license/prepare-set-license-key": { /** Get info about the upcoming EE subscription. This will show, how much the subscription will cost when key is applied. */ - post: operations["prepareSetLicenseKey"]; + post: operations["prepareSetLicenseKey_1"]; }; "/v2/api-keys": { get: operations["allByUser"]; @@ -1207,23 +1231,10 @@ export interface components { /** @description The user's permission type. This field is null if uses granular permissions */ type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; /** - * @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 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. + * @description List of languages user can view. If null, all languages view is permitted. * @example 200001,200004 */ - stateChangeLanguageIds?: number[]; + 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 @@ -1259,10 +1270,23 @@ export interface components { | "tasks.edit" )[]; /** - * @description List of languages user can view. If null, all languages view is permitted. + * @deprecated + * @description Deprecated (use translateLanguageIds). + * + * List of languages current user has TRANSLATE permission to. If null, all languages edition is permitted. * @example 200001,200004 */ - viewLanguageIds?: number[]; + permittedLanguageIds?: 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[]; }; LanguageModel: { /** Format: int64 */ @@ -1911,6 +1935,7 @@ export interface components { invitedUserName?: string; invitedUserEmail?: string; permission: components["schemas"]["PermissionWithAgencyModel"]; + createdBy?: components["schemas"]["SimpleUserAccountModel"]; }; AzureContentStorageConfigDto: { connectionString?: string; @@ -1928,8 +1953,8 @@ export interface components { secretKey?: string; endpoint: string; signingRegion: string; - enabled?: boolean; contentStorageType?: "S3" | "AZURE"; + enabled?: boolean; }; AzureContentStorageConfigModel: { containerName?: string; @@ -1992,7 +2017,8 @@ export interface components { | "YAML_RUBY" | "YAML" | "JSON_I18NEXT" - | "CSV"; + | "CSV" + | "RESX_ICU"; /** * @description Delimiter to structure file content. * @@ -2094,7 +2120,8 @@ export interface components { | "YAML_RUBY" | "YAML" | "JSON_I18NEXT" - | "CSV"; + | "CSV" + | "RESX_ICU"; /** * @description Delimiter to structure file content. * @@ -2206,12 +2233,12 @@ export interface components { createNewKeys: boolean; }; ImportSettingsModel: { - /** @description If true, key descriptions will be overridden by the import */ - overrideKeyDescriptions: boolean; - /** @description If true, placeholders from other formats will be converted to ICU when possible */ - convertPlaceholdersToIcu: boolean; /** @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; }; TranslationCommentModel: { /** @@ -2368,13 +2395,13 @@ 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 */ @@ -2442,6 +2469,7 @@ export interface components { createdAt: string; invitedUserName?: string; invitedUserEmail?: string; + createdBy?: components["schemas"]["SimpleUserAccountModel"]; }; SetLicenseKeyDto: { licenseKey: string; @@ -2535,19 +2563,19 @@ export interface components { RevealedApiKeyModel: { /** @description Resulting user's api key */ key: string; - description: string; /** Format: int64 */ id: number; + userFullName?: string; projectName: string; /** Format: int64 */ lastUsedAt?: number; /** Format: int64 */ projectId: number; + username?: string; scopes: string[]; /** Format: int64 */ expiresAt?: number; - username?: string; - userFullName?: string; + description: string; }; SuperTokenRequest: { /** @description Has to be provided when TOTP enabled */ @@ -2559,6 +2587,49 @@ export interface components { name: string; oldSlug?: string; }; + ExampleItem: { + source: string; + target: string; + key: string; + keyNamespace?: string; + }; + Metadata: { + examples: components["schemas"]["ExampleItem"][]; + closeItems: components["schemas"]["ExampleItem"][]; + keyDescription?: string; + projectDescription?: string; + languageDescription?: string; + }; + TolgeeTranslateParams: { + text: string; + keyName?: string; + sourceTag: string; + targetTag: string; + metadata?: components["schemas"]["Metadata"]; + formality?: "FORMAL" | "INFORMAL" | "DEFAULT"; + isBatch: boolean; + pluralForms?: { [key: string]: string }; + pluralFormExamples?: { [key: string]: string }; + }; + MtResult: { + translated?: string; + /** Format: int32 */ + price: number; + contextDescription?: string; + }; + TelemetryReportRequest: { + instanceId: string; + /** Format: int64 */ + projectsCount: number; + /** Format: int64 */ + translationsCount: number; + /** Format: int64 */ + languagesCount: number; + /** Format: int64 */ + distinctLanguagesCount: number; + /** Format: int64 */ + usersCount: number; + }; SlackCommandDto: { token?: string; team_id: string; @@ -2571,6 +2642,130 @@ export interface components { trigger_id?: string; team_domain: string; }; + GetMySubscriptionDto: { + licenseKey: string; + instanceId: string; + }; + PlanIncludedUsageModel: { + /** Format: int64 */ + seats: number; + /** Format: int64 */ + translationSlots: number; + /** Format: int64 */ + translations: number; + /** Format: int64 */ + mtCredits: number; + }; + PlanPricesModel: { + perSeat: number; + perThousandTranslations?: number; + perThousandMtCredits?: number; + subscriptionMonthly: number; + subscriptionYearly: number; + }; + SelfHostedEePlanModel: { + /** Format: int64 */ + id: number; + name: string; + public: boolean; + enabledFeatures: ( + | "GRANULAR_PERMISSIONS" + | "PRIORITIZED_FEATURE_REQUESTS" + | "PREMIUM_SUPPORT" + | "DEDICATED_SLACK_CHANNEL" + | "ASSISTED_UPDATES" + | "DEPLOYMENT_ASSISTANCE" + | "BACKUP_CONFIGURATION" + | "TEAM_TRAINING" + | "ACCOUNT_MANAGER" + | "STANDARD_SUPPORT" + | "PROJECT_LEVEL_CONTENT_STORAGES" + | "WEBHOOKS" + | "MULTIPLE_CONTENT_DELIVERY_CONFIGS" + | "AI_PROMPT_CUSTOMIZATION" + | "SLACK_INTEGRATION" + | "TASKS" + | "SSO" + | "ORDER_TRANSLATION" + )[]; + prices: components["schemas"]["PlanPricesModel"]; + includedUsage: components["schemas"]["PlanIncludedUsageModel"]; + hasYearlyPrice: boolean; + free: boolean; + nonCommercial: boolean; + }; + SelfHostedEeSubscriptionModel: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + currentPeriodStart?: number; + /** Format: int64 */ + currentPeriodEnd?: number; + currentBillingPeriod: "MONTHLY" | "YEARLY"; + /** Format: int64 */ + createdAt: number; + plan: components["schemas"]["SelfHostedEePlanModel"]; + status: + | "ACTIVE" + | "CANCELED" + | "PAST_DUE" + | "UNPAID" + | "ERROR" + | "KEY_USED_BY_ANOTHER_INSTANCE"; + licenseKey?: string; + estimatedCosts?: number; + }; + SetLicenseKeyLicensingDto: { + licenseKey: string; + /** Format: int64 */ + seats: number; + instanceId: string; + }; + ReportUsageDto: { + licenseKey: string; + /** Format: int64 */ + seats: number; + }; + ReportErrorDto: { + stackTrace: string; + licenseKey: string; + }; + ReleaseKeyDto: { + licenseKey: string; + }; + PrepareSetLicenseKeyDto: { + licenseKey: string; + /** Format: int64 */ + seats: number; + }; + AverageProportionalUsageItemModel: { + total: number; + unusedQuantity: number; + usedQuantity: number; + usedQuantityOverPlan: number; + }; + PrepareSetEeLicenceKeyModel: { + plan: components["schemas"]["SelfHostedEePlanModel"]; + usage: components["schemas"]["UsageModel"]; + }; + SumUsageItemModel: { + total: number; + /** Format: int64 */ + unusedQuantity: number; + /** Format: int64 */ + usedQuantity: number; + /** Format: int64 */ + usedQuantityOverPlan: number; + }; + UsageModel: { + subscriptionPrice?: number; + /** @description Relevant for invoices only. When there are applied stripe credits, we need to reduce the total price by this amount. */ + appliedStripeCredits?: number; + seats: components["schemas"]["AverageProportionalUsageItemModel"]; + translations: components["schemas"]["AverageProportionalUsageItemModel"]; + credits?: components["schemas"]["SumUsageItemModel"]; + total: number; + }; BusinessEventReportRequest: { eventName: string; anonymousUserId?: string; @@ -3138,7 +3333,8 @@ export interface components { | "XLIFF_ICU" | "XLIFF_JAVA" | "XLIFF_PHP" - | "XLIFF_RUBY"; + | "XLIFF_RUBY" + | "RESX_ICU"; /** * @description The existing language tag in the Tolgee platform to which the imported language should be mapped. * @@ -3274,7 +3470,8 @@ export interface components { | "YAML_RUBY" | "YAML" | "JSON_I18NEXT" - | "CSV"; + | "CSV" + | "RESX_ICU"; /** * @description Delimiter to structure file content. * @@ -3446,111 +3643,35 @@ export interface components { createdAt: string; location?: string; }; - AverageProportionalUsageItemModel: { - total: number; - unusedQuantity: number; - usedQuantity: number; - usedQuantityOverPlan: number; - }; - PlanIncludedUsageModel: { - /** Format: int64 */ - seats: number; - /** Format: int64 */ - translationSlots: number; - /** Format: int64 */ - translations: number; + CreateApiKeyDto: { /** Format: int64 */ - mtCredits: number; + projectId: number; + scopes: string[]; + /** @description Description of the project API key */ + description?: string; + /** + * Format: int64 + * @description Expiration date in epoch format (milliseconds). When null key never expires. + * @example 1661172869000 + */ + expiresAt?: number; }; - PlanPricesModel: { - perSeat: number; - perThousandTranslations?: number; - perThousandMtCredits?: number; - subscriptionMonthly: number; - subscriptionYearly: number; + TextNode: { [key: string]: unknown }; + SignUpDto: { + name: string; + email: string; + organizationName?: string; + password: string; + invitationCode?: string; + callbackUrl?: string; + /** @description Where did the user find us? */ + userSource?: string; + recaptchaToken?: string; }; - PrepareSetEeLicenceKeyModel: { - plan: components["schemas"]["SelfHostedEePlanModel"]; - usage: components["schemas"]["UsageModel"]; - }; - SelfHostedEePlanModel: { - /** Format: int64 */ - id: number; - name: string; - public: boolean; - enabledFeatures: ( - | "GRANULAR_PERMISSIONS" - | "PRIORITIZED_FEATURE_REQUESTS" - | "PREMIUM_SUPPORT" - | "DEDICATED_SLACK_CHANNEL" - | "ASSISTED_UPDATES" - | "DEPLOYMENT_ASSISTANCE" - | "BACKUP_CONFIGURATION" - | "TEAM_TRAINING" - | "ACCOUNT_MANAGER" - | "STANDARD_SUPPORT" - | "PROJECT_LEVEL_CONTENT_STORAGES" - | "WEBHOOKS" - | "MULTIPLE_CONTENT_DELIVERY_CONFIGS" - | "AI_PROMPT_CUSTOMIZATION" - | "SLACK_INTEGRATION" - | "TASKS" - | "SSO" - | "ORDER_TRANSLATION" - )[]; - prices: components["schemas"]["PlanPricesModel"]; - includedUsage: components["schemas"]["PlanIncludedUsageModel"]; - hasYearlyPrice: boolean; - free: boolean; - nonCommercial: boolean; - }; - SumUsageItemModel: { - total: number; - /** Format: int64 */ - unusedQuantity: number; - /** Format: int64 */ - usedQuantity: number; - /** Format: int64 */ - usedQuantityOverPlan: number; - }; - UsageModel: { - subscriptionPrice?: number; - /** @description Relevant for invoices only. When there are applied stripe credits, we need to reduce the total price by this amount. */ - appliedStripeCredits?: number; - seats: components["schemas"]["AverageProportionalUsageItemModel"]; - translations: components["schemas"]["AverageProportionalUsageItemModel"]; - credits?: components["schemas"]["SumUsageItemModel"]; - total: number; - }; - CreateApiKeyDto: { - /** Format: int64 */ - projectId: number; - scopes: string[]; - /** @description Description of the project API key */ - description?: string; - /** - * Format: int64 - * @description Expiration date in epoch format (milliseconds). When null key never expires. - * @example 1661172869000 - */ - expiresAt?: number; - }; - TextNode: { [key: string]: unknown }; - SignUpDto: { - name: string; - email: string; - organizationName?: string; - password: string; - invitationCode?: string; - callbackUrl?: string; - /** @description Where did the user find us? */ - userSource?: string; - recaptchaToken?: string; - }; - ResetPassword: { - email: string; - code: string; - password?: string; + ResetPassword: { + email: string; + code: string; + password?: string; }; ResetPasswordRequest: { callbackUrl: string; @@ -3749,15 +3870,10 @@ export interface components { | "ORDER_TRANSLATION" )[]; 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; basePermissions: components["schemas"]["PermissionModel"]; /** * @description The role of currently authorized user. @@ -3765,6 +3881,11 @@ export interface components { * Can be null when user has direct access to one of the projects owned by the organization. */ currentUserRole?: "MEMBER" | "OWNER"; + /** @example btforg */ + slug: string; + avatar?: components["schemas"]["Avatar"]; + /** @example This is a beautiful organization full of beautiful and clever people */ + description?: string; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -3834,15 +3955,16 @@ export interface components { | "YAML_RUBY" | "YAML" | "JSON_I18NEXT" - | "CSV"; + | "CSV" + | "RESX_ICU"; extension: string; mediaType: string; defaultFileStructureTemplate: string; }; DocItem: { - description?: string; name: string; displayName?: string; + description?: string; }; PagedModelProjectModel: { _embedded?: { @@ -3937,23 +4059,23 @@ export interface components { formalitySupported: boolean; }; KeySearchResultView: { - description?: string; name: string; /** Format: int64 */ id: number; + baseTranslation?: string; translation?: string; + description?: string; namespace?: string; - baseTranslation?: string; }; KeySearchSearchResultModel: { view?: components["schemas"]["KeySearchResultView"]; - description?: string; name: string; /** Format: int64 */ id: number; + baseTranslation?: string; translation?: string; + description?: string; namespace?: string; - baseTranslation?: string; }; PagedModelKeySearchSearchResultModel: { _embedded?: { @@ -4552,13 +4674,13 @@ 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 */ @@ -4679,19 +4801,19 @@ export interface components { * @description Languages for which user has translate permission. */ permittedLanguageIds?: number[]; - description: string; /** Format: int64 */ id: number; + userFullName?: string; projectName: string; /** Format: int64 */ lastUsedAt?: number; /** Format: int64 */ projectId: number; + username?: string; scopes: string[]; /** Format: int64 */ expiresAt?: number; - username?: string; - userFullName?: string; + description: string; }; PagedModelUserAccountModel: { _embedded?: { @@ -9760,19 +9882,394 @@ export interface operations { }; }; }; - uploadAvatar_2: { + uploadAvatar_2: { + parameters: { + path: { + id: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["OrganizationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "multipart/form-data": { + /** Format: binary */ + avatar: string; + }; + }; + }; + }; + removeAvatar_2: { + parameters: { + path: { + id: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["OrganizationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + setLicenseKey: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["EeSubscriptionModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetLicenseKeyDto"]; + }; + }; + }; + /** This will remove the licence key from the instance. */ + release: { + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + /** This will refresh the subscription information from the license server and update the subscription info. */ + refreshSubscription: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["EeSubscriptionModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + update_9: { + parameters: { + path: { + apiKeyId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["ApiKeyModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["V2EditApiKeyDto"]; + }; + }; + }; + delete_13: { + parameters: { + path: { + apiKeyId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; + regenerate_1: { + parameters: { + path: { + apiKeyId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["RevealedApiKeyModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RegenerateApiKeyDto"]; + }; + }; + }; + /** Enables previously disabled user. */ + enableUser: { parameters: { path: { - id: number; + userId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["OrganizationModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -9806,28 +10303,17 @@ export interface operations { }; }; }; - requestBody: { - content: { - "multipart/form-data": { - /** Format: binary */ - avatar: string; - }; - }; - }; }; - removeAvatar_2: { + /** Disables user account. User will not be able to log in, but their user data will be preserved, so you can enable the user later using the `enable` endpoint. */ + disableUser: { parameters: { path: { - id: number; + userId: number; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["OrganizationModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -9862,14 +10348,17 @@ export interface operations { }; }; }; - setLicenseKey: { + /** Set's the global role on the Tolgee Platform server. */ + setRole: { + parameters: { + path: { + userId: number; + role: "USER" | "ADMIN"; + }; + }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["EeSubscriptionModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -9903,14 +10392,9 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["SetLicenseKeyDto"]; - }; - }; }; - /** This will remove the licence key from the instance. */ - release: { + /** Resends email verification email to currently authenticated user. */ + sendEmailVerification: { responses: { /** OK */ 200: unknown; @@ -9948,13 +10432,13 @@ export interface operations { }; }; }; - /** This will refresh the subscription information from the license server and update the subscription info. */ - refreshSubscription: { + /** Generates new JWT token permitted to sensitive operations */ + getSuperToken: { responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["EeSubscriptionModel"]; + "application/json": components["schemas"]["JwtAuthenticationResponse"]; }; }; /** Bad Request */ @@ -9990,18 +10474,18 @@ export interface operations { }; }; }; - }; - update_9: { - parameters: { - path: { - apiKeyId: number; + requestBody: { + content: { + "application/json": components["schemas"]["SuperTokenRequest"]; }; }; + }; + generateProjectSlug: { responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["ApiKeyModel"]; + "application/json": string; }; }; /** Bad Request */ @@ -10039,19 +10523,18 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["V2EditApiKeyDto"]; + "application/json": components["schemas"]["GenerateSlugDto"]; }; }; }; - delete_13: { - parameters: { - path: { - apiKeyId: number; - }; - }; + generateOrganizationSlug: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": string; + }; + }; /** Bad Request */ 400: { content: { @@ -10085,20 +10568,23 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["GenerateSlugDto"]; + }; + }; }; - regenerate_1: { + /** Pairs user account with slack account. */ + userLogin: { parameters: { - path: { - apiKeyId: number; + query: { + /** The encrypted data about the desired connection between Slack account and Tolgee account */ + data: string; }; }; responses: { /** OK */ - 200: { - content: { - "application/json": components["schemas"]["RevealedApiKeyModel"]; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -10132,22 +10618,15 @@ export interface operations { }; }; }; - requestBody: { - content: { - "application/json": components["schemas"]["RegenerateApiKeyDto"]; - }; - }; }; - /** Enables previously disabled user. */ - enableUser: { - parameters: { - path: { - userId: number; - }; - }; + translate: { responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": components["schemas"]["MtResult"]; + }; + }; /** Bad Request */ 400: { content: { @@ -10181,14 +10660,13 @@ export interface operations { }; }; }; - }; - /** Disables user account. User will not be able to log in, but their user data will be preserved, so you can enable the user later using the `enable` endpoint. */ - disableUser: { - parameters: { - path: { - userId: number; + requestBody: { + content: { + "application/json": components["schemas"]["TolgeeTranslateParams"]; }; }; + }; + report: { responses: { /** OK */ 200: unknown; @@ -10225,18 +10703,26 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": components["schemas"]["TelemetryReportRequest"]; + }; + }; }; - /** Set's the global role on the Tolgee Platform server. */ - setRole: { + slackCommand: { parameters: { - path: { - userId: number; - role: "USER" | "ADMIN"; + header: { + "X-Slack-Signature": string; + "X-Slack-Request-Timestamp": string; }; }; responses: { /** OK */ - 200: unknown; + 200: { + content: { + "application/json": string; + }; + }; /** Bad Request */ 400: { content: { @@ -10270,9 +10756,23 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": { + payload?: components["schemas"]["SlackCommandDto"]; + body?: string; + }; + }; + }; }; - /** Resends email verification email to currently authenticated user. */ - sendEmailVerification: { + /** This is triggered when interactivity event is triggered. E.g., when user clicks button provided in previous messages. */ + onInteractivityEvent: { + parameters: { + header: { + "X-Slack-Signature": string; + "X-Slack-Request-Timestamp": string; + }; + }; responses: { /** OK */ 200: unknown; @@ -10309,14 +10809,29 @@ export interface operations { }; }; }; + requestBody: { + content: { + "application/json": string; + }; + }; }; - /** Generates new JWT token permitted to sensitive operations */ - getSuperToken: { + /** + * This is triggered when bot event is triggered. E.g., when app is uninstalled from workspace. + * + * Heads up! The events have to be configured via Slack App configuration in Event Subscription section. + */ + fetchBotEvent: { + parameters: { + header: { + "X-Slack-Signature": string; + "X-Slack-Request-Timestamp": string; + }; + }; responses: { /** OK */ 200: { content: { - "application/json": components["schemas"]["JwtAuthenticationResponse"]; + "application/json": { [key: string]: unknown }; }; }; /** Bad Request */ @@ -10354,16 +10869,16 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["SuperTokenRequest"]; + "application/json": string; }; }; }; - generateProjectSlug: { + getMySubscription: { responses: { /** OK */ 200: { content: { - "application/json": string; + "application/json": components["schemas"]["SelfHostedEeSubscriptionModel"]; }; }; /** Bad Request */ @@ -10401,16 +10916,16 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["GenerateSlugDto"]; + "application/json": components["schemas"]["GetMySubscriptionDto"]; }; }; }; - generateOrganizationSlug: { + onLicenceSetKey: { responses: { /** OK */ 200: { content: { - "application/json": string; + "application/json": components["schemas"]["SelfHostedEeSubscriptionModel"]; }; }; /** Bad Request */ @@ -10448,18 +10963,11 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["GenerateSlugDto"]; + "application/json": components["schemas"]["SetLicenseKeyLicensingDto"]; }; }; }; - /** Pairs user account with slack account. */ - userLogin: { - parameters: { - query: { - /** The encrypted data about the desired connection between Slack account and Tolgee account */ - data: string; - }; - }; + reportUsage: { responses: { /** OK */ 200: unknown; @@ -10496,21 +11004,16 @@ export interface operations { }; }; }; - }; - slackCommand: { - parameters: { - header: { - "X-Slack-Signature": string; - "X-Slack-Request-Timestamp": string; + requestBody: { + content: { + "application/json": components["schemas"]["ReportUsageDto"]; }; }; + }; + reportError: { responses: { /** OK */ - 200: { - content: { - "application/json": string; - }; - }; + 200: unknown; /** Bad Request */ 400: { content: { @@ -10546,21 +11049,11 @@ export interface operations { }; requestBody: { content: { - "application/json": { - payload?: components["schemas"]["SlackCommandDto"]; - body?: string; - }; + "application/json": components["schemas"]["ReportErrorDto"]; }; }; }; - /** This is triggered when interactivity event is triggered. E.g., when user clicks button provided in previous messages. */ - onInteractivityEvent: { - parameters: { - header: { - "X-Slack-Signature": string; - "X-Slack-Request-Timestamp": string; - }; - }; + releaseKey: { responses: { /** OK */ 200: unknown; @@ -10599,27 +11092,16 @@ export interface operations { }; requestBody: { content: { - "application/json": string; + "application/json": components["schemas"]["ReleaseKeyDto"]; }; }; }; - /** - * This is triggered when bot event is triggered. E.g., when app is uninstalled from workspace. - * - * Heads up! The events have to be configured via Slack App configuration in Event Subscription section. - */ - fetchBotEvent: { - parameters: { - header: { - "X-Slack-Signature": string; - "X-Slack-Request-Timestamp": string; - }; - }; + prepareSetLicenseKey: { responses: { /** OK */ 200: { content: { - "application/json": { [key: string]: unknown }; + "application/json": components["schemas"]["PrepareSetEeLicenceKeyModel"]; }; }; /** Bad Request */ @@ -10657,11 +11139,11 @@ export interface operations { }; requestBody: { content: { - "application/json": string; + "application/json": components["schemas"]["PrepareSetLicenseKeyDto"]; }; }; }; - report: { + report_1: { responses: { /** OK */ 200: unknown; @@ -12160,7 +12642,7 @@ export interface operations { }; }; /** Pre-translate provided keys to provided languages by TM. */ - translate: { + translate_1: { parameters: { path: { projectId: number; @@ -12602,7 +13084,8 @@ export interface operations { | "YAML_RUBY" | "YAML" | "JSON_I18NEXT" - | "CSV"; + | "CSV" + | "RESX_ICU"; /** * Delimiter to structure file content. * @@ -13679,7 +14162,7 @@ export interface operations { }; }; /** Get info about the upcoming EE subscription. This will show, how much the subscription will cost when key is applied. */ - prepareSetLicenseKey: { + prepareSetLicenseKey_1: { responses: { /** OK */ 200: { diff --git a/webapp/src/svgs/logos/dotnet.svg b/webapp/src/svgs/logos/dotnet.svg new file mode 100644 index 0000000000..6bd903e52d --- /dev/null +++ b/webapp/src/svgs/logos/dotnet.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/webapp/src/views/projects/export/components/formatGroups.tsx b/webapp/src/views/projects/export/components/formatGroups.tsx index 4525adeab3..9cc271f854 100644 --- a/webapp/src/views/projects/export/components/formatGroups.tsx +++ b/webapp/src/views/projects/export/components/formatGroups.tsx @@ -254,6 +254,17 @@ export const formatGroups: FormatGroup[] = [ }, ], }, + { + name: '.NET', + formats: [ + { + id: 'resx_icu', + extension: 'resx', + name: , + format: 'RESX_ICU', + }, + ], + }, ]; type ExportParamsWithoutZip = Omit< diff --git a/webapp/src/views/projects/import/component/ImportSupportedFormats.tsx b/webapp/src/views/projects/import/component/ImportSupportedFormats.tsx index 4d55523ae7..8aa0c9fcd2 100644 --- a/webapp/src/views/projects/import/component/ImportSupportedFormats.tsx +++ b/webapp/src/views/projects/import/component/ImportSupportedFormats.tsx @@ -13,6 +13,7 @@ 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'; +import DotnetLogo from 'tg.svgs/logos/dotnet.svg?react'; const TechLogo = ({ svg, @@ -59,6 +60,7 @@ const FORMATS = [ { name: 'Ruby YAML', logo: }, { name: 'i18next', logo: }, { name: 'CSV', logo: }, + { name: '.NET RESX', logo: }, ]; export const ImportSupportedFormats = () => {